Quickback Docs

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:

  1. Header — Action name with an icon (lightning bolt for standard, warning triangle for destructive, download for file responses)
  2. Description — The action's description text
  3. Side effects warning — If the action has sideEffects: "sync", an amber warning banner appears: "This action has synchronous side effects (e.g., GL entries)."
  4. Confirmation step — If the action has cms.confirm, a confirmation message appears before the input form
  5. Input fields — Auto-generated from the action's inputFields schema
  6. Execute button — Submits the action. Shows "Executing..." while in progress.
  7. Cancel button — Closes the dialog without executing

Input Field Types

The dialog generates the appropriate input control for each field:

Zod TypeInput ControlNotes
stringText input or FK typeaheadFree text unless the field resolves to an emitted fkTarget
numberNumber inputStep 0.01, supports decimals
booleanCheckboxWith label text
array<string>Text inputComma-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:

OperatorExampleMeaning
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 to state never sees the control.
  • Literal guards ({ col: value }, { null: true }, { notNull: true }) must hold. A custom function 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.

PropertyTypeDefaultPurpose
labelstringcamelCase splitDisplay name override
iconstring"zap"Lucide icon name
confirmstring | booleanfalsetrue = generic confirm, string = custom message
destructivebooleanfalseRed styling + requires confirmation
categorystringnoneGroup actions under a header
hiddenbooleanfalseHide from CMS entirely (API-only actions)
placement"feature" | "tables"noneWhere standalone actions appear in multi-table features
tablesstring[]noneExplicit table list when placement is "tables"
successMessagestringnoneToast message after success
onSuccess"refresh" | "redirect:list" | "close""refresh"Behavior after successful execution
ordernumber0Sort 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.destructive is true
  • The action is named void or delete (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-destructive styling)
  • Automatic confirmation step (unless cms.confirm is explicitly false)

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" with cms.tables to show it only on specific tables.
  • Use cms.hidden: true to 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

  1. User clicks action in row menu or action bar
  2. Dialog opens with description, inputs, and warnings
  3. If cms.confirm is set, user sees confirmation step first
  4. User fills in required fields
  5. User clicks "Execute" (or "Confirm" after confirmation step)
  6. CMS calls client.executeAction(table, actionName, recordId, input)
  7. On success: cms.successMessage shown as toast (if set), then cms.onSuccess behavior
  8. On error: error message displayed in dialog

Next Steps

On this page