Actions
An action is a button in the panel with a handler on the server. It can ask for input first, confirm before it runs, and then invalidate, redirect or just say it worked.
Where a button can go
.actions() declares them. Which builder you call decides where the button
lands, and what the handler is given.
| Builder | Scope | Appears in | Extra on ctx |
|---|---|---|---|
a.headerAction({…}) | header | The list header, beside Create | nothing |
a.bulkAction({…}) | bulk | The selection toolbar | itemIds |
a.action({…}) | single | The edit form's secondary menu | itemId |
a.action({ scope: "row", … }) | row | The row menu, and the edit form too | itemId |
An action on the edit form shows while editing a row. It is never on the create screen, because there is no row yet.
One action
.actions(({ a, c, f }) => ({
custom: [
a.action({
id: "publish",
label: { en: "Publish", sk: "Publikovať" },
icon: c.icon("ph:paper-plane-tilt"),
form: {
title: { en: "Publish this post" },
fields: {
note: f.textarea().label({ en: "Release note" }),
},
},
confirmation: {
title: { en: "Publish now?" },
description: { en: "It goes live for everyone." },
},
handler: async ({ itemId, data, collections, queue }) => {
await collections.posts.updateById({
id: itemId!,
data: { status: "published" },
});
await queue.notifySubscribers.publish({ note: data.note });
return {
type: "success",
toast: { message: "Published" },
effects: { invalidate: ["posts"] },
};
},
}),
],
}))The Publish button now sits in the post's edit form. It asks for confirmation, then for a release note, then runs the handler on the server.
`.actions()` on its own does nothing
Introspection only emits an admin block when the collection also calls
.admin(), .list(), .form() or .preview(). A collection with nothing
but .actions() sends no custom actions to the panel.
The definition
| Key | Required | Holds |
|---|---|---|
id | Yes | Unique within the collection. It is what the route receives. |
label | Yes | Button text |
handler | Yes | The function, run on the server |
icon | No | A c.icon(…) reference |
variant | No | default, destructive, outline, secondary or ghost |
form | No | Ask for input before running |
confirmation | No | Ask "are you sure" before running |
Give both a form and a confirmation and the confirmation comes first. The
reader agrees, then fills the form in. Pick one.
Asking for input
form: {
title: { en: "Send email" },
description: { en: "Goes to every selected reader." },
fields: {
subject: f.text().label({ en: "Subject" }).required(),
body: f.textarea().label({ en: "Body" }),
priority: f.select([
{ value: "low", label: { en: "Low" } },
{ value: "high", label: { en: "High" } },
]),
},
submitLabel: { en: "Send" },
width: "lg",
}fields is a record, not an array, and the keys are what land on data in the
handler. The f here is not the field-name proxy from .list(). It builds a
field, chainably, the way .fields() does. width is sm, md, lg or xl.
Validate inside the handler
The panel builds a schema from these fields and blocks a bad submit. The
server's own check does not currently reject anything. Treat data as
untrusted and check it yourself.
Confirming first
confirmation: {
title: { en: "Delete every draft?" },
description: { en: "This cannot be undone." },
confirmLabel: { en: "Delete" },
destructive: true,
}Only title is required. destructive: true paints the confirm button red.
The handler
It runs on the server, inside the caller's session, in user access mode. So
your .access() rules apply to anything it reads or writes.
On ctx | Is |
|---|---|
data | The submitted form, or {} |
itemId | The row, for a single or row action |
itemIds | The selection, for a bulk action |
collections, globals | The same typed CRUD you use in a route |
db, session, locale | The connection, the caller, their content locale |
auth | The Better Auth API |
t | Translates an admin message key |
queue, email, storage, kv, services | The rest of the app surface |
The handler returns one of three results.
{ type: "success", toast: { message: "Done" }, effects: { … } }
{ type: "error", toast: { message: "Nope" }, errors: { subject: "Too long" } }
{ type: "redirect", url: "/preview/posts/1", external: true }errors on an error result maps a form field to a message. A failed action can
point at the input that caused it. A thrown exception becomes an error toast
carrying the message, and the panel keeps working.
Effects
effects rides along on a success result.
| Effect | Does |
|---|---|
invalidate: true | Refetches everything |
invalidate: ["posts"] | Refetches those collections |
redirect: "/admin/…" | Navigates after the toast |
closeModal: true | Closes the action dialog |
type: "redirect" and effects.redirect both navigate. The difference is that
a redirect result takes external: true and opens a new tab.
The built-in buttons
You declare none of these. They come from the client's own defaults.
| Where | Buttons |
|---|---|
| List header | Create |
| List row menu | nothing |
| Selection toolbar | Delete selected, Restore selected, Duplicate |
| Edit form | Duplicate, Restore, Delete |
Restore and Restore selected only show themselves on a row that has a
deletedAt. Duplicate in the toolbar only shows when exactly one row is
selected.
To change which ones show, use .list({ actions }). It replaces the default for
each section you name. Declaring any custom action also clears the header
default, so name Create again if you still want it. See
The list.
The builtin key on .actions() is a different thing. It is the allowlist the
executeAction route checks before it runs a built-in operation by name. It
does not add or remove a button anywhere.
.actions(({ a }) => ({
builtin: [a.create(), a.save(), a.delete()],
}))Leave it unset and everything is allowed. That is create, save, delete,
deleteMany, restore, restoreMany, duplicate, and the workflow
transition. Naming a shorter list turns the rest into "action not found".
Access
Every action goes through one route, and that route requires
session.user.role === "admin". The handler then runs under the caller's own
session in user mode. A collection they cannot write still refuses them.
Admin config is not a permission boundary. Hiding a button hides a button. See Access control.
Where each topic lives
| Topic | Page |
|---|---|
| The other four methods | Collections and globals |
| Which built-in buttons a list shows | The list |
| The form these buttons sit beside | The form |
| Who may sign in at all | Authentication |
| Work that should not block a click | Jobs |
Next
Preview puts the page a row renders as next to the form that edits it.
The form
One .form() call lays out the create and edit screens. Sections, tabs and a sidebar arrange the fields, and any field can hide, lock or compute itself from a handler that runs on the server.
Preview
A live preview puts the page a row renders as in an iframe beside the form that edits it. One url builder turns it on, and a hook on the front end makes it update as somebody types.