# Actions

Actions are custom operations defined in your feature files (e.g., `approve`, `void`, `post`, `applyPayment`). The CMS renders them as clickable buttons with auto-generated input forms — no UI code required.

## 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.

### 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 Type | Input Control | Notes |
|----------|--------------|-------|
| `string` | Text input | Free-text entry |
| `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.

### Example

Given this action definition:

```typescript
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 for the action to be visible. This means "approve" actions naturally disappear after a record is approved, and "void" actions only appear on voidable records.

## 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

```typescript
// 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

```typescript
// 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.

### 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.

```typescript
// 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

- **[Security](/ui/admin/security)** — Access conditions and role-based access
- **[Table Views](/ui/admin/table-views)** — Where actions appear in the UI
- **[Schema Format Reference](/ui/admin/schema-format)** — ActionMeta type definition
