Actions
Custom actions with auto-generated input forms, access filtering, and side effects warnings.
Actions
Actions are custom operations defined in your feature files (e.g., approve, void, post, applyPayment). The CMS renders eligible declared or generated actions as clickable buttons with auto-generated input forms — no UI code required. It never invents an action from cms metadata alone.
When an Action Control Exists
An action must first exist in the generated schema registry: author it with
defineAction, or declare a table transition so the compiler generates it.
The CMS then applies the action's access rules for the current membership role,
omits cms.hidden actions, and evaluates record/state conditions. A role that
is not admitted never sees the control. On a record page, an admitted action
whose access.record condition fails is shown disabled with the reason; an
action whose transition cannot fire from the record's current state (no
fromTo entry admits the target, already in the target state, or a literal
guard fails) is hidden — the route would answer 409 ACCESS_ACTION_NOT_ALLOWED_FOR_STATE, so the control is never offered.
The optional cms block changes presentation and placement. It cannot create
the action, add CRUD, or widen access.
For a transition-managed status, give the column an initial schema default,
omit it from guards.createable and guards.updatable, and declare it only in
guards.protected with the allowed transition/action names. The CMS will offer
those eligible lifecycle controls instead of a direct status editor. A
protected field cannot also be createable or updatable: the schema default
sets the initial status, and only the named transitions/actions may write it.
Where Actions Appear
Actions show up in two places:
Row Action Menu (Table Mode)
In Table mode, the three-dot menu on each row includes a section for custom actions. Actions are separated from standard operations (view, edit, delete) by a divider.
A record-bound action has no path: and needs an existing record ID. It cannot
appear as a table-level control, and an empty table has no row menu in which to
show it. If the operation creates the first row—booking a reservation, joining
a waitlist, importing records—author it as a standalone action and place it in
the table toolbar.
Action Bar (Detail View)
In the record detail view, an "Actions" card displays all available actions as buttons. Each button shows the action name, and hovering reveals the description as a tooltip.
Action Dialog
Clicking an action opens a modal dialog with:
- Header — Action name with an icon (lightning bolt for standard, warning triangle for destructive, download for file responses)
- Description — The action's description text
- Side effects warning — If the action has
sideEffects: "sync", an amber warning banner appears: "This action has synchronous side effects (e.g., GL entries)." - Confirmation step — If the action has
cms.confirm, a confirmation message appears before the input form - Input fields — Auto-generated from the action's
inputFieldsschema - Execute button — Submits the action. Shows "Executing..." while in progress.
- Cancel button — Closes the dialog without executing
Input Field Types
The dialog generates the appropriate input control for each field:
| Zod Type | Input Control | Notes |
|---|---|---|
string | Text input or FK typeahead | Free text unless the field resolves to an emitted fkTarget |
number | Number input | Step 0.01, supports decimals |
boolean | Checkbox | With label text |
array<string> | Text input | Comma-separated values, split on save |
Required fields are marked with a red asterisk. Default values from the schema are pre-filled.
Field names are humanized for their labels, so sessionId appears as
Session rather than exposing the storage-oriented suffix. When that input
name corresponds to a parent-table column with an emitted fkTarget, the
dialog resolves the lookup through that column metadata, then renders a
selector for the FK target table and shows its displayColumn labels. Declare
the feature-table relationship in the schema with
.references(() => sessions.id) so plural or otherwise non-conventional target
names resolve reliably. Without fkTarget, the action input remains a plain
text field.
If the target has named views, the selector uses only a role-accessible authored
read.defaultView and calls its named-view endpoint. It never falls back to a
bare list or silently chooses another projection. The default view must include
the target primary key and displayColumn; otherwise the dialog disables the
lookup with an actionable explanation. Put the displayColumn in the view's
effective query.searchable allowlist for typeahead search. If that allowlist
is empty, the dialog instead shows the first 50 options in a non-searching
chooser and sends no ?search request.
Example
Given this action definition:
actions: {
applyPayment: {
description: "Apply a payment to this invoice",
input: 'z.object({ amount: z.number(), reference: z.string().optional() })',
access: {
roles: ['admin', 'owner'],
record: { status: { equals: 'posted' } },
},
sideEffects: "sync",
cms: {
label: "Apply Payment",
icon: "dollar-sign",
confirm: "This will create GL entries. Continue?",
category: "payments",
successMessage: "Payment applied successfully",
order: 1,
},
},
}The CMS renders a dialog with:
- A number input for
amount(required) - A text input for
reference(optional) - A sync side effects warning
- A confirmation step with the custom message
- Only visible to admins and owners
- Only visible on records where
status === "posted"
Access Filtering
Actions are filtered based on two criteria:
Role Check
The action's access.roles array is compared against the current user's role. If the user's role is not in the list, the action is hidden.
Record Condition
The action's access.record conditions are evaluated against the current record's data. Supported conditions:
| Operator | Example | Meaning |
|---|---|---|
equals | { status: { equals: 'pending' } } | Field must equal value |
notEquals | { status: { notEquals: 'void' } } | Field must not equal value |
in | { status: { in: ['pending', 'draft'] } } | Field must be one of values |
All conditions must pass before the action can execute. In record menus and the detail action bar, a role-admitted action whose record condition fails is disabled with an explanation. This means an "approve" control cannot run after the record is approved, and "void" cannot run on a non-voidable record.
Transition State
A record action with a transition policy (Transitions)
is hidden when the record's current state cannot take it. The registry carries
the policy's field, fromTo, to/via, literal guard, and idempotent;
the CMS evaluates them against the row exactly as the route does:
fromTo[current]must list the target (to), or be non-empty when the target comes from input (via).- A record already in the
tostate never sees the control. - Literal guards (
{ col: value },{ null: true },{ notNull: true }) must hold. Acustomfunction guard is opaque to the CMS and does not hide.
So an application in applied with fromTo: { interview: ["offer"] } shows no
"To Offer" control; once it reaches interview, the control appears.
CMS Metadata
Actions can include an optional cms property for enhanced CMS rendering. Actions without cms render with default behavior.
| Property | Type | Default | Purpose |
|---|---|---|---|
label | string | camelCase split | Display name override |
icon | string | "zap" | Lucide icon name |
confirm | string | boolean | false | true = generic confirm, string = custom message |
destructive | boolean | false | Red styling + requires confirmation |
category | string | none | Group actions under a header |
hidden | boolean | false | Hide from CMS entirely (API-only actions) |
placement | "feature" | "tables" | none | Where standalone actions appear in multi-table features |
tables | string[] | none | Explicit table list when placement is "tables" |
successMessage | string | none | Toast message after success |
onSuccess | "refresh" | "redirect:list" | "close" | "refresh" | Behavior after successful execution |
order | number | 0 | Sort priority (lower = first) |
Example with CMS metadata
// features/invoices/actions/post.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
export default defineAction({
description: "Post this invoice to accounts receivable",
input: z.object({}),
access: { roles: ["owner", "admin"], record: { status: { equals: "draft" } } },
sideEffects: "sync",
cms: {
label: "Post Invoice",
icon: "send",
confirm: "This will post the invoice to AR and create GL entries. Continue?",
category: "lifecycle",
successMessage: "Invoice posted successfully",
onSuccess: "refresh",
order: 1,
},
async execute({ db, record }) {
// ... post invoice
return { ok: true };
},
});Hiding API-only actions
// features/invoices/actions/internalRecalc.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
export default defineAction({
description: "Recalculate internal totals",
input: z.object({}),
access: { roles: ["owner"] },
cms: { hidden: true }, // Never shown in CMS
async execute({ db, record }) {
// ... recalc totals
return { ok: true };
},
});Destructive Actions
Actions are styled as destructive when either:
- The action's
cms.destructiveistrue - The action is named
voidordelete(legacy fallback)
Destructive actions receive:
- Red text in the row action menu
- Warning triangle icon (unless overridden by
cms.icon) - Red execute button in the dialog (using
bg-destructivestyling) - Automatic confirmation step (unless
cms.confirmis explicitlyfalse)
This provides a visual cue that the action has permanent consequences.
Standalone Actions
Actions with path: set are standalone — not tied to a specific record. They appear in a separate section and don't receive a recordId when executed. The CMS passes null as the record ID for standalone actions.
This is the right shape for a business-controlled create flow when generic CRUD create would expose too much. For example, a reservation action can enforce capacity, uniqueness, and status defaults while still giving an empty reservations table a visible New reservation toolbar action:
export default defineAction({
path: "/reservations/make-reservation",
method: "POST",
input: z.object({ sessionId: z.string().min(1) }),
access: { roles: ["member+"] },
cms: {
label: "New reservation",
placement: "tables",
tables: ["reservations"],
},
async execute({ db, ctx, input }) {
// Check capacity/duplicates, then insert the protected default status.
},
});By contrast, define table create.access and guards.createable when the
generic New form is intentionally safe. Do not add raw create merely to get
a button if the action is where the business invariants live.
Placement in Multi-Table Features
When a standalone action belongs to a feature with multiple tables, the CMS needs to know where to render it.
- Use
cms.placement: "feature"to show the action in the table toolbar for every table in that feature. - Use
cms.placement: "tables"withcms.tablesto show it only on specific tables. - Use
cms.hidden: trueto keep it API-only.
Single-table features and clearly table-scoped standalone paths are inferred automatically. Feature-root standalone actions in multi-table features should set placement explicitly.
// quickback/features/chat/chatChannels.ts
import { feature, q } from "@quickback/compiler";
export default feature("chatChannels", {
columns: {
id: q.id(),
name: q.text().required(),
organizationId: q.scope("organization"),
...q.audit(),
...q.softDelete(),
},
firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
});
// quickback/features/chat/chatMessages.ts
import { feature, q } from "@quickback/compiler";
export default feature("chatMessages", {
columns: {
id: q.id(),
channelId: q.text().required(),
body: q.text().required(),
organizationId: q.scope("organization"),
...q.audit(),
...q.softDelete(),
},
firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
});
// quickback/features/chat/actions/postMessage.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
export default defineAction({
description: "Post a message to a chat channel on behalf of the caller.",
path: "/chat/post-message",
method: "POST",
input: z.object({ channelId: z.string(), body: z.string().min(1).max(4000) }),
access: { roles: ["member"] },
cms: {
label: "Post Message",
placement: "tables",
tables: ["chatChannels", "chatMessages"],
},
async execute() {
return { ok: true };
},
});File Response Actions
Actions with responseType: "file" show a download icon instead of the lightning bolt. When executed, the response is treated as a file download rather than a JSON result.
Execution Flow
- User clicks action in row menu or action bar
- Dialog opens with description, inputs, and warnings
- If
cms.confirmis set, user sees confirmation step first - User fills in required fields
- User clicks "Execute" (or "Confirm" after confirmation step)
- CMS calls
client.executeAction(table, actionName, recordId, input) - On success:
cms.successMessageshown as toast (if set), thencms.onSuccessbehavior - On error: error message displayed in dialog
Next Steps
- Security — Access conditions and role-based access
- Table Views — Where actions appear in the UI
- Schema Format Reference — ActionMeta type definition