Skip to Content
Docs@PortalAction — custom actions

@PortalAction + @PortalFormField — custom actions

@PortalAction

Class-level annotation (repeatable) — declares a custom action button. Actions are shown per-row in the table and executed via /api/portal/data/{entity}/{id}/action/{name}.

@Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.RUNTIME) @Repeatable annotation class PortalAction( val name: String, val label: String, val labelKey: String = "", val icon: String = "play", val handler: KClass<*>, val formModel: KClass<*> = Void::class, val confirmMessage: String = "", val confirmMessageKey: String = "", val bulkAllowed: Boolean = false, val order: Int = 0, val variant: String = "default" )
ParameterDefaultDescription
nameUnique action identifier — used as URL segment
labelButton label in the UI
icon"play"Lucide icon for the button
handlerCDI bean class (@ApplicationScoped @Unremovable)
formModelVoid::classOptional data class for modal input form
confirmMessage""Confirmation message before execution. Empty = no confirm
bulkAllowedfalseWhether the action can run on multiple selected rows
order0Sort position in the action bar
variant"default"Style: "default", "destructive", "outline", "secondary", "ghost"

Implementing a handler

A handler is not an interface — it’s a CDI bean discovered by reflection. The framework looks for validate, execute, and optionally executeBulk methods with matching signatures.

@ApplicationScoped @io.quarkus.arc.Unremovable class ActivateCustomerHandler { val actionName = "activate" suspend fun validate(entity: EntityData, formData: EntityData?): String? { val isActive = entity["isActive"] as? Boolean ?: false return if (isActive) "Customer is already active" else null } suspend fun execute(entity: EntityData, formData: EntityData?): ActionResult { // ✅ Modify entity fields — framework auto-merges after execute() // ❌ Do NOT call entity.persist() — will cause a session error val id = entity["id"] return ActionResult.Success("Customer $id activated.", refreshTable = true) } suspend fun executeBulk(entities: List<EntityData>, formData: EntityData?): ActionResult { return ActionResult.Success("Activated ${entities.size} customers.", refreshTable = true) } }

ActionResult — possible results

TypeDescription
ActionResult.Success(message, data?, refreshTable, links)Success. refreshTable = true refreshes the table
ActionResult.Error(message, details?)Error with optional field details map
ActionResult.Redirect(url)Redirect to URL
ActionResult.Download(fileName, contentType, data)File download
// Success with a navigation link return ActionResult.Success( message = "Task started.", refreshTable = true, links = listOf(ResultLink("Go to TaskRun", "TaskRun", "System", taskRunId)) )

@PortalFormField

Field-level annotation on a data class — describes a field in an action’s input form.

Use @field: on Kotlin properties so the annotation targets the JVM field and is readable by Java reflection.

annotation class PortalFormField( val label: String, val renderer: RendererType = RendererType.TEXT, val required: Boolean = false, val placeholder: String = "", val tooltip: String = "", val selectOptions: Array<String> = [], val selectEnum: KClass<*> = Unit::class, val order: Int = 0 )

Complete example with form

1. Form model:

data class ProcessOrderForm( @field:PortalFormField( label = "Priority", renderer = RendererType.SELECT, selectOptions = ["NORMAL", "HIGH", "URGENT"], required = true, order = 1 ) val priority: String = "NORMAL", @field:PortalFormField( label = "Operator Notes", renderer = RendererType.TEXTAREA, placeholder = "Enter notes...", order = 2 ) val notes: String = "", @field:PortalFormField( label = "Scheduled Date", renderer = RendererType.DATE, required = true, order = 3 ) val scheduledDate: String = "" )

2. Handler:

@ApplicationScoped @io.quarkus.arc.Unremovable class ProcessOrderHandler { val actionName = "processOrder" suspend fun validate(entity: EntityData, formData: EntityData?): String? { val status = entity["status"] as? String return if (status == "CANCELLED") "Cannot process a cancelled order" else null } suspend fun execute(entity: EntityData, formData: EntityData?): ActionResult { val priority = formData?.get("priority") as? String ?: "NORMAL" return ActionResult.Success("Processed with priority $priority.") } }

3. Entity annotation:

@PortalAction( name = "processOrder", label = "Process Order", icon = "play", handler = ProcessOrderHandler::class, formModel = ProcessOrderForm::class, confirmMessage = "Process this order?", order = 1 ) @PortalEntity(label = "Order", module = "CRM") @Entity class Order { ... }

More action examples

Destructive action with confirmation:

@PortalAction( name = "cancelOrder", label = "Cancel", icon = "x-circle", handler = CancelOrderHandler::class, confirmMessage = "Cancel this order? This cannot be undone.", variant = "destructive", order = 2 )

Bulk action:

@PortalAction( name = "sendEmail", label = "Send Email", icon = "mail", handler = SendEmailHandler::class, bulkAllowed = true, variant = "outline", order = 3 )

File download action:

@ApplicationScoped @io.quarkus.arc.Unremovable class ExportInvoiceHandler { val actionName = "exportPdf" suspend fun validate(entity: EntityData, formData: EntityData?) = null suspend fun execute(entity: EntityData, formData: EntityData?): ActionResult { val pdfBytes = generatePdf(entity) return ActionResult.Download("invoice-${entity["id"]}.pdf", "application/pdf", pdfBytes) } }
Last updated on