---
url: /guide/typescript/view-engine-actions.md
description: >-
  How commands on records are declared — availability and its reason, placement,
  confirmation and forms, bulk, outcomes (unknown results and idempotency
  included), record.rowFields, and testing with actionHarness.
---

# Declared Actions

This page answers: **how does a command on a record — ship, cancel, change warehouse — go to the view engine, without drawing buttons, dialogs and progress on every page yourself?**

A command on a record is declared, not drawn. The host says **what**: which commands there are, when each is available, why not when it is not, whether to ask first, what the reader fills in. The engine does **where and how**:

| The host writes (business) | The engine does (mechanism) |
|---|---|
| Which actions there are and the command each sends (`run`) | Where they go: the row's button, the row's ⋯ menu, the selection bar, the record's detail, a board's record panel |
| When one is available and what a refusal says (`available` returns `true` or the reason), and when that flips on its own (`changesAt`) | Disabled with the reason; over a selection only partly able, the refused listed with why and one press to keep only the able; asked again when due, with no timer of the host's |
| Its words, its danger, whether to ask first (`confirm`), what to fill in (`form`) | The question and the form, focus, keyboard, screen-reader announcements |
| When the command is done: `run` resolves once the read model reflects it | Several at a time: concurrency, progress, stop, each record's outcome, the view read again after |

The split is there because "can this order ship now" is a business rule only the host knows, while "how does a keyboard user reach the reason a button is disabled", "what happens when two of the selected cannot ship" and "is a timed-out request a success or a failure" are mechanism: written once per host, they come out wrong in different ways.

## Declaring an action {#declare}

Say an order aggregate takes three commands: `ship_order` (only a paid order ships), `cancel_order` (only before it ships, with a reason) and `change_warehouse`. First write the commands as a client that sends by aggregate id:

```ts
// src/views/orderCommands.ts
import type { Fetcher } from '@ahoo-wang/fetcher';
import {
  CommandClient,
  CommandStage,
  commandHeaders,
  waitStrategy,
} from '@ahoo-wang/wow-client';

/** The commands a person sends on one order. Each resolves once the snapshot reflects it. */
export interface OrderCommands {
  ship(id: string, version: number): Promise<void>;
  cancel(id: string, version: number, reason: string): Promise<void>;
  moveTo(id: string, warehouse: string): Promise<void>;
}

export function orderCommands(fetcher: Fetcher): OrderCommands {
  // A command client generated by wow-generator types each route and body; this one sends by path.
  const client = new CommandClient({ fetcher, basePath: 'example/order' });
  // `Command-Wait-Stage: SNAPSHOT`: answer once the snapshot is written, for the engine reads the view again as `run` resolves.
  const wait = waitStrategy({ stage: CommandStage.SNAPSHOT });
  const send = async (
    command: string,
    id: string,
    body: object,
    version?: number,
  ) => {
    // A refused command rejects: the engine reports the service's reason on that order.
    await client.send({
      path: `{id}/${command}`,
      method: 'POST',
      // The id is a path variable, encoded by the fetcher, never pasted into the path.
      urlParams: { path: { id } },
      // The version the row showed: a command that already took, sent again, is refused as a conflict rather than applied twice.
      headers:
        version === undefined
          ? wait
          : { ...wait, ...commandHeaders({ aggregateVersion: version }) },
      body,
    });
  };
  return {
    ship: (id, version) => send('ship_order', id, {}, version),
    cancel: (id, version, reason) =>
      send('cancel_order', id, { reason }, version),
    moveTo: (id, warehouse) => send('change_warehouse', id, { warehouse }),
  };
}
```

Then declare the actions. `actions([...])` checks that every id is unique and every action has a `run`; a mistake there is the host's code, so it throws at once:

```ts
// src/views/orderActions.ts
import { actions, text, type RecordRow } from '@ahoo-wang/wow-view-engine';
import type { OrderCommands } from './orderCommands';

interface OrderState {
  status?: string;
  warehouse?: string;
}

// Every field read below is in the definition's `record.rowFields` (next section).
const stateOf = (row: RecordRow) => (row.data.state ?? {}) as OrderState;
const versionOf = (row: RecordRow) => Number(row.data.version);
// The row key is the aggregate id (the definition sets no other `rowKey`).
const idOf = (row: RecordRow) => String(row.key);

export const orderActions = (commands: OrderCommands) =>
  actions([
    {
      id: 'ship',
      label: text('orders.ship'),
      // The button in the row; the rest go behind the row's ⋯ menu.
      primary: true,
      // `true`, or why not: a key, said in the reader's language.
      available: row =>
        stateOf(row).status === 'PAID' ? true : text('orders.shipNotPaid'),
      // Routine, but not taken back once sent: asked for one order too, with no danger tone.
      confirm: { title: text('orders.shipTitle') },
      run: row => commands.ship(idOf(row), versionOf(row)),
    },
    {
      id: 'cancel',
      label: text('orders.cancel'),
      // Something is lost: the danger tone, asked for one order too, the consequence said.
      tone: 'danger',
      available: row =>
        ['CREATED', 'PAID'].includes(stateOf(row).status ?? '')
          ? true
          : text('orders.cancelShipped'),
      confirm: {
        title: text('orders.cancelTitle'),
        body: text('orders.cancelBody'),
      },
      form: {
        reason: { label: text('orders.cancelReason'), input: 'text' },
      },
      // Off the selection bar: every cancellation has its own reason.
      on: ['row', 'detail'],
      // No answer in 30 seconds and the engine stops waiting for this order: its outcome is unknown.
      timeout: 30_000,
      run: (row, { reason }) =>
        commands.cancel(idOf(row), versionOf(row), String(reason)),
    },
    {
      id: 'warehouse',
      label: text('orders.moveTo'),
      // One field of options: its options are listed in the menu, with no form.
      form: {
        warehouse: {
          label: text('orders.warehouse'),
          options: [
            { value: 'SH', label: text('orders.shanghai') },
            { value: 'GZ', label: text('orders.guangzhou') },
          ],
        },
      },
      // Asked with the option in hand: the warehouse the order is in is not offered again.
      available: (row, { input }) =>
        input?.warehouse !== undefined &&
        input.warehouse === stateOf(row).warehouse
          ? text('orders.sameWarehouse')
          : true,
      // Undone as easily as done: one order moves at its pick; a selection is counted first.
      confirm: { title: text('orders.moveTitle'), ask: 'bulk' },
      run: (row, { warehouse }) =>
        commands.moveTo(idOf(row), String(warehouse)),
    },
  ]);
```

Bind them on the resource: [`bind('orders', { route, actions: orderActions(orderCommands(fetcher)) })`](../../reference/typescript/wow-view-engine/host.md#api-bind) ([Fitting the View Engine into a Host](./view-engine-host.md#bind)). Wherever the resource shows, its actions come along.

| Member | What it is |
|---|---|
| `id`, `label` | An id unique among one binding's actions; the name, a key or plain words |
| `primary` | The one a reader presses most: the button in the row. At most one per resource |
| `tone` | `default` or `danger` |
| `on` | Where it is offered: `row`, `bulk`, `detail`; every place by default |
| `hidden(row, ctx)` | Not shown on this record at all: this person may not do it |
| `available(row, ctx)` | `true`, or why not now: shown but disabled, with the reason |
| `changesAt(row, ctx)` | When `available` next flips on its own (ms); left out where only a new state changes it |
| `confirm` | What to ask first; or a function of the input |
| `form` | What the command needs besides the record |
| `run(row, input)` | Sends the command for one record |
| `timeout` | How long one record's `run` is waited for (ms); no deadline by default |

Every rule takes a second argument, `{ now, input? }`. The time comes from the engine (from the test in a test), so a rule never reads the clock; `input` comes where it is known — the option picked in a menu, a filled form — so "it already is this value" can refuse that one option alone. A rule that throws is the host's bug: the engine refuses the record rather than offering a command nobody checked.

**When availability flips with time alone**, write `changesAt`: the surface takes the earliest time over every record in view, keeps one timer, and asks again then.

```ts
import { actions, text } from '@ahoo-wang/wow-view-engine';

export const holdActions = actions([
  {
    id: 'release',
    label: text('orders.release'),
    // Not while the hold lasts…
    available: (row, { now }) =>
      holdUntil(row) > now ? text('orders.onHold') : true,
    // …and once it is over: the engine asks again at that moment.
    changesAt: (row, { now }) =>
      holdUntil(row) > now ? holdUntil(row) + 1 : null,
    run: row => release(String(row.key)),
  },
]);
```

**Permission** lives here: what this person may not do, `hidden` leaves off; what they may do but this record cannot take now, `available` disables and explains. A reason says why not in the reader's words, and what to do instead where there is something ("A shipped order cannot be cancelled; start a return") — never a code or a constant. The service keeps the last word: match the condition its command handler checks, and do not invent a stricter one.

## Fields a row must carry: `record.rowFields` {#row-fields}

This is the trap most hosts step in first. **The engine queries only what a view shows**: a page's query projects the row key, the visible columns, the card's fields (where the definition allows a card layout) and the sort fields — not the document. The `row.data` an action is handed holds just that.

So where the "To ship" view shows no status column, `stateOf(row).status` is `undefined`, the ship rule answers "Only a paid order can ship", and every row's button is disabled — while in "All orders", which shows the status, the same order ships.

List the fields the rules read in the definition's [`record.rowFields`](../../reference/typescript/wow-view-engine/definitions.md#api-RecordCapability), and every row carries them whatever the view shows:

```ts
import { defineView, text } from '@ahoo-wang/wow-view-engine';

export const ordersDefinition = defineView(descriptor, {
  id: 'orders',
  source: 'order',
  title: text('orders.title'),
  fields: {
    aggregateId: text('orders.id'),
    'state.status': text('orders.status'),
    'state.warehouse': text('orders.warehouse'),
    'state.amount': text('orders.amount'),
    version: text('orders.version'),
  },
  // The actions read these three, whether or not the view shows them.
  record: { rowFields: ['state.status', 'state.warehouse', 'version'] },
});
```

Each must be a field the definition declares and a row holds, which admission checks (`definition.record.row-field-unknown`). There is no "fetch the whole document when there are actions": the document is what made a page of the compensation console's failed executions weigh 808 KB.

A field left out need not wait for production to show: **in a development build** (`NODE_ENV` of `development`), when a rule reads a field the row did not fetch, the engine reports a warning, `record.action.unfetched`, to `onIssue` — with the field (`field`) and the action (`action`), pointing at `record.rowFields` — once per view and field. A host that passes no `onIssue` sees it on the console with the engine's other findings. It is noticed as the rule runs — a rule is a function, and the engine cannot see which fields it reads — so open each view with actions once while developing, above all those that hide a column a rule reads. In development the rules see each row through a proxy that watches reads: `structuredClone` of such a row throws, and `in` or `Object.keys` checks do not warn. A production build makes no such check: the rules read the rows as they are.

## Where they go {#placement}

| Place | What is drawn |
|---|---|
| The row (and the card) | The `primary` one as a button; the rest in the "Actions for {record}" menu (⋯) |
| The selection bar | The `primary` one as "{action} {count}", the rest by name; "{action} {able}/{count}" when not all can take it, disabled with the most common reason as its hint when none can |
| The record's detail | The same set in the drawer's header (those whose `on` includes `detail`) |
| A board's record panel | As on the workbench: a record's actions in both tiers, a selection's in the interactive tier only; after a command the whole board is read again |

* **A disabled button stays focusable.** It is not native `disabled` but `aria-disabled`: in the Tab order, opening the hint with its reason on focus, the reason also its accessible description; the menu lists the reasons, deduplicated, at its top.
* While a command runs the ⋯ menu still opens with its items disabled; when the question closes, focus goes back to the button pressed.
* [`EmbeddedView`](../../reference/typescript/wow-view-engine/components.md#api-EmbeddedView) draws no declared actions: its row commands are only the host's own `rowActions`.

## Confirmation and forms {#confirm}

When to ask first is the engine's rule; the host declares only the intent:

| Case | Asked? |
|---|---|
| A selection | **Always**: how many, which will not be sent, and why |
| One record, no `confirm` | Runs at once |
| One record, `confirm` (`ask` defaults to `'always'`) | Asked first |
| One record, `confirm: { …, ask: 'bulk' }` | Runs at once; only a selection asks |
| A form | Always filled in a dialog |
| One field of options (a choice) | The options are the menu's items and the pick is the input; with `confirm` it asks once after the pick, not with `ask: 'bulk'` |

How to choose `ask`: something is lost (cancelling, refunding, deleting) — the danger tone, a `confirm` that says the consequence, asked for one record too; routine but not taken back (shipping) — asked for one record too, no danger tone; undone as easily as done (a warehouse, a priority) — `ask: 'bulk'`, a selection counted first.

* A `confirm`'s words: `title`, `body`; `action` is the confirming button's word and the run's name on the outcome line, the action's `label` by default; `tone` is the action's by default.
* The words may hold `{count}` (how many records), `{value}` (the option a choice picked, its `label` said in the reader's language) and `{record}` (the record's key, when it is one record). Where a language says one apart, add a key ending in `-one` (`'orders.shipTitle-one'`).
* `confirm` may be a function of the input: marking something "unrecoverable" says another consequence in the danger tone. If it throws, the engine asks by the action's name rather than skipping the question.
* A selection over an action with no `confirm` is asked the engine's own "Run “{action}” on {count} records?"; one record is named, not counted.
* Only a question that is **dangerous and has no form** is an `alertdialog` (read as urgent); a question with a form and a routine one are a `dialog`. A click outside does not close it, and focus is held inside.
* A form's fields: `input` is `text`, `number` or `boolean` (options where it has `options`), required by default (`required: false` makes one optional), `initial` the value it opens with; `run` gets them by name. The submit button can always be pressed; pressed with a field missing, it marks that field and moves focus to it.

## Bulk {#bulk}

`run` writes one record; the engine schedules a selection:

* **Partly able**: the question says "{able} of {count} can take it", lists the refused grouped by reason (the reason, how many, the first few keys), and "Only the N that can" narrows the selection to the able in one press. Confirmed anyway, the refused are not sent, recorded as **not run** rather than failed, and stay selected.
* **Several at a time**: at most 4 run at once, the status line showing progress ("Running 2 of 5") and **Stop**. The first **Stop** only starts no more, and the button becomes **Stop waiting**; pressed again, what is still in flight is recorded as unknown and the run settles at once.
* **The view is read again after**, with the failed and the not run left selected, to retry once dealt with.
* There is no batch form of a command, so there is no `runMany`; it waits for a batch command on the service.

## Outcomes {#outcomes}

Once `run` settles, each record has an outcome; the status line, drawn under the rows, names it ("Ship · SO-1002 done", "SO-1002 failed: reason"):

| Outcome | When | Then |
|---|---|---|
| Done | `run` resolved | The view is read again |
| Failed | `run` rejected: the service refused the command | The reason is read from the service's error; the record **stays selected** |
| Not run | When its turn came, the action no longer took the record (its state changed after the pick) | The record stays selected, with the reason |
| Unknown | Sent, and no answer came back: a timeout (`TimeoutError`, `FetchTimeoutError`, HTTP 504), an abort, a dropped connection, the action's `timeout` passed, or the reader pressed **Stop waiting** | "N with outcome unknown, refresh to check first", and those records are **deselected** |

**`run` resolves once the read model reflects the command.** The engine reads the view again right after; a refresh that ran ahead of the command reads the old state, and the reader thinks the command did nothing. A Wow command waits with `waitStrategy({ stage: CommandStage.SNAPSHOT })` as its headers (`Command-Wait-Stage`), or the stage the host's projection needs.

**Records with an unknown outcome are deselected**, so they do not invite a blind rerun that might refund twice. A reader who checks may still press again, so **make the command idempotent**:

* send the aggregate version the row showed — `commandHeaders({ aggregateVersion })`, the `Command-Aggregate-Version` header, with `version` in the definition's `record.rowFields` — so a command that already took, sent again, is refused as a conflict rather than applied twice;
* and a request id the service deduplicates by (`commandHeaders({ requestId })`, the `Command-Request-Id` header), so a retry of one send counts once.

The original error of a failure or an unknown outcome also reaches the host's monitoring through the engine environment's `onError` (`kind: 'action'`, with the action's id, the record's key, the definition and the view); an abort is not reported.

## Slots: the escape hatch {#slots}

What a declaration cannot say — a link out, a control of the host's own — is drawn in a slot: `slots: { row, bulk, global }` on `bind`, after the declared actions. A slot that sends a command anyway calls its context's `run`, which goes through the same runner as the declared actions and reports on the same line: `row: ({ row, run, busy }) => …`. Slots are not the main road; declare what can be declared.

## Testing with `actionHarness` {#harness}

[`actionHarness(actions, rows, { now })`](../../reference/typescript/wow-view-engine/testing.md#api-actionHarness) from `/testing` reads the declarations by the engine's own rules, without a screen — the same rules the UI reads. Cover each rule on representative rows, what each place offers, what a press asks, and that `run` sends the right command to the right id:

```ts
import { text, type RecordRow } from '@ahoo-wang/wow-view-engine';
import { actionHarness } from '@ahoo-wang/wow-view-engine/testing';

const row = (id: string, status: string): RecordRow => ({
  key: id,
  data: { version: 3, state: { status, warehouse: 'SH' } },
});
const commands: OrderCommands = {
  ship: vi.fn(() => Promise.resolve()),
  cancel: vi.fn(() => Promise.resolve()),
  moveTo: vi.fn(() => Promise.resolve()),
};
const orders = actionHarness(
  orderActions(commands),
  [row('O-1', 'PAID'), row('O-2', 'SHIPPED')],
  { now: Date.parse('2026-09-30T00:00:00Z') },
);

// What each place offers; why a record does not take one.
expect(orders.at('bulk')).toEqual(['ship', 'warehouse']);
expect(orders.state('ship', 'O-2')).toEqual({
  hidden: false,
  available: false,
  reason: text('orders.shipNotPaid'),
});
// A selection splits into the able and the refused, grouped by reason.
expect(orders.bulk('ship').able).toEqual(['O-1']);
// What a press asks: `{ asks, confirm }`.
expect(orders.asks('ship', 'row').asks).toBe(true);
expect(orders.asks('warehouse', 'row', { warehouse: 'GZ' }).asks).toBe(false);
// A choice's options, a form's blank required fields.
expect(orders.state('warehouse', 'O-1', { warehouse: 'SH' }).reason).toBe(
  text('orders.sameWarehouse'),
);
expect(orders.missing('cancel', {})).toEqual(['reason']);
// `run` sends to the aggregate id, with the row's version.
await orders.run('cancel', 'O-1', { reason: 'Buyer asked' });
expect(commands.cancel).toHaveBeenCalledWith('O-1', 3, 'Buyer asked');
```

`run` on a record the action does not take rejects with `ActionRefusedError` and the reason, as the engine would not send it. Whether every key in the actions has words is checked with the root entry's `withText`, walking the declarations, and `textKeyOf` over the reasons `available` returns; how is in the `wow-view-host` skill's [actions.md](https://github.com/Ahoo-Wang/Wow/blob/main/skills/wow-view-host/references/actions.md), with a test of the command client itself and the configuration that runs these tests in Node.

## The full working version

* Step 4 of Storybook's [integration walk-through](/storybook/?path=/docs/view-engine-接入导览--docs): "Ship" and "Cancel order" on the orders, pressable at the bottom of the page. The source: [`orderActions.ts`](https://github.com/Ahoo-Wang/Wow/blob/main/typescript/storybook/stories/view-engine/integration/orderActions.ts), [`wowCommands.ts`](https://github.com/Ahoo-Wang/Wow/blob/main/typescript/storybook/stories/view-engine/integration/wowCommands.ts), [`ordersDefinition.ts`](https://github.com/Ahoo-Wang/Wow/blob/main/typescript/storybook/stories/view-engine/integration/ordersDefinition.ts), and [`integration.test.ts`](https://github.com/Ahoo-Wang/Wow/blob/main/typescript/storybook/stories/view-engine/integration/integration.test.ts), which checks them with `actionHarness`.
* One interaction test per placement and flow — a row's primary action and its menu, a partly able selection, a form, a dangerous confirmation, a flip on time: [`DeclaredActions.test.stories.tsx`](https://github.com/Ahoo-Wang/Wow/blob/main/typescript/storybook/stories/view-engine/DeclaredActions.test.stories.tsx).
* A real host: the compensation console's [`executionActions.ts`](https://github.com/Ahoo-Wang/Wow/blob/main/compensation/dashboard/src/views/executionActions.ts) (with `changesAt`, a `confirm` that is a function of the input, and a choice) and its [`executionActions.test.ts`](https://github.com/Ahoo-Wang/Wow/blob/main/compensation/dashboard/src/views/executionActions.test.ts).

## Next

| Next | Read |
|---|---|
| [`ViewHost`](../../reference/typescript/wow-view-engine/host.md#api-ViewHost), `bind`, routes and embeds | [Fitting the View Engine into a Host](./view-engine-host.md) |
| How keyboard and screen-reader users walk through declared actions | [Accessibility of the View Engine](./view-engine-accessibility.md) |
| Wire one business object from zero, with one action | [Getting Started with the View Engine](./view-engine-getting-started.md) |
