@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"
)| Parameter | Default | Description |
|---|---|---|
name | — | Unique action identifier — used as URL segment |
label | — | Button label in the UI |
icon | "play" | Lucide icon for the button |
handler | — | CDI bean class (@ApplicationScoped @Unremovable) |
formModel | Void::class | Optional data class for modal input form |
confirmMessage | "" | Confirmation message before execution. Empty = no confirm |
bulkAllowed | false | Whether the action can run on multiple selected rows |
order | 0 | Sort 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
| Type | Description |
|---|---|
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