---
url: /typescript/view-engine/actions.md
description: >-
  Declare the commands readers can run on records — availability and its reason,
  placement, confirmation and forms, bulk runs, outcomes and idempotency — and
  test them with actionHarness.
---

# Actions

Readers act on records: ship an order, cancel it, move it to another warehouse. In the view engine, a command on a record is **declared**, not drawn. The host says what: which commands there are, when each is available and why not, whether to ask first, what to fill in. The engine does where and how: the buttons, menus, dialogs, keyboard and screen-reader behaviour, bulk runs and their outcomes.

| The host writes | The engine does |
| --- | --- |
| The actions, and the command each sends (`run`) | Places them: the row's button and menu, the selection bar, the record's detail, a board's record panel |
| When one is available, and the reason when it is not (`available`), and when that changes with time alone (`changesAt`) | Disables it with the reason; over a selection only partly able, lists the refused and offers to keep only the able |
| Its words, its danger, whether to ask first (`confirm`), what to fill in (`form`) | The question and the form, focus and announcements |
| When the command is done: `run` resolves once the read model reflects it | Several at a time, with progress and stop; each record's outcome; the view read again |

"Can this order ship now" is a business rule only the host knows. "How does a keyboard user learn why the button is disabled", "what happens when two of the selected cannot ship" and "is a timed-out request a success" are mechanism, written once, by the engine.

## Declare an action {#declare}

First, the commands. The command gateway of `@ahoo-wang/wow-client` sends by route; a client generated by `wow-generator` types each route and body:

```ts
// src/views/orderCommands.ts
import type { Fetcher } from '@ahoo-wang/fetcher';
import { CommandGateway, wait } 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 commands = new CommandGateway({ fetcher });
  // `Wow-Wait: snapshot`: answer once the snapshot holds the commit, for the engine reads the view again as `run` resolves.
  const plan = [wait.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 commands.send(`/example/order/{id}/${command}`, body, {
      // The id fills `{id}`, encoded by the gateway, never pasted into the path.
      id,
      // The version the row showed: a command that already took, sent again, is refused as a conflict rather than applied twice.
      expectedVersion: version,
      wait: { plan },
    });
  };
  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 }),
  };
}
```

Each command waits for `snapshot`, so it resolves once the snapshot holds the commit, and sends the version the row showed as `If-Match`, so a command that already took, sent again, is refused as a conflict instead of applied twice.

Then the actions. `actions([...])` checks that ids are unique and every action has a `run`; a mistake 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)) })` ([Host the engine](./host#bind)), and wherever the resource shows, its actions come along.

| Member | What it is |
| --- | --- |
| `id`, `label` | An id unique among the resource's actions; its name |
| `primary` | The action readers press most: the button in the row. At most one |
| `tone` | `default` or `danger` |
| `on` | Where it is offered: `row`, `bulk`, `detail`; every place by default |
| `hidden(row, ctx)` | Not shown on this record: 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 changes with time alone |
| `confirm` | What to ask first |
| `form` | What the command needs besides the record |
| `moves` | The field it sets, for a kanban card dropped on another lane |
| `run(row, input)` | Sends the command for one record |
| `timeout` | How long one record's `run` is waited for |

Every rule gets `{ now, input? }`: the time comes from the engine, so a rule never reads the clock; `input` is the option picked or the form filled. A rule that throws refuses the record.

**When availability changes with time alone**, write `changesAt`, and the surface asks again then, with one timer for all records in view:

```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 belongs here too.** What this person may not do, `hidden` leaves off. What they may do but this record cannot take now, `available` disables and explains, in the reader's words and with what to do instead. The service keeps the last word: match the rule its command handler checks.

## The fields a rule reads

**The engine queries only what a view shows**, so the row an action is handed holds only the visible fields, the identity and what the sort reads. In a view that hides the status column, a rule that reads the status sees nothing and disables every row. List the fields the rules read in the definition's `record.rowFields`, and every row carries them:

```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'] },
});
```

In a development build, the engine warns, `record.action.unfetched`, when a rule reads a field the row did not fetch. Open each view with actions once while developing.

## Where actions go {#placement}

| Place | What is drawn |
| --- | --- |
| A row or card | The primary action as a button; the rest in its "Actions for…" menu |
| The selection bar | The primary action with the count, then the rest; "{able}/{count}" when not all can take it |
| The record's detail | The actions whose `on` includes `detail` |
| A board's record panel | As on the workbench; after a command, the whole board is read again |

A disabled button stays focusable (`aria-disabled`, not `disabled`) and shows its reason on focus. [`EmbeddedView`](../../reference/typescript/wow-view-engine/components.md#api-EmbeddedView) draws no declared actions.

## Moving kanban cards {#kanban}

In a [kanban layout](./definitions#a-kanban-layout), moving a card to another lane is an action that names the field it sets with `moves`:

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

const STAGES = [
  { value: 'LEAD', label: '线索' },
  { value: 'PROPOSAL', label: '方案报价' },
  { value: 'WON', label: '赢单' },
];

export const dealActions = (
  changeStage: (id: string, stage: string) => Promise<void>,
) =>
  actions([
    {
      id: 'change-stage',
      label: 'Change stage',
      // A card dropped on another lane runs this with `{ stage: <the lane> }`.
      moves: 'stage',
      // And the card's menu offers the same move, a stage at a time.
      form: { stage: { label: 'Stage', options: STAGES } },
      available: (row: RecordRow, { input }) => {
        if (row.data.stage === 'WON') return 'A won deal stays won';
        if (input && input.stage === row.data.stage) return 'Already there';
        return true;
      },
      run: (row, input) => changeStage(String(row.key), String(input.stage)),
    },
  ]);
```

While a card is held, every lane its rule accepts is drawn open, and a lane it refuses shows the reason. The card lands at once and the view is read again afterwards; refused or failed, it goes back. Keyboard and screen-reader users move a card through its menu, which the `form` provides.

## Confirmation and forms {#confirm}

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

| Case | Asked first? |
| --- | --- |
| A selection | Always: how many, which will not be sent, and why |
| One record, no `confirm` | No |
| One record, `confirm` | Yes |
| One record, `confirm` with `ask: 'bulk'` | No; only a selection asks |
| A form | Always, in a dialog |
| One field of options | The options are the menu's items; the pick is the input |

Choose by what a mistake costs. Something lost, such as cancelling or refunding: the danger tone and a `confirm` that says the consequence. Routine but final, such as shipping: a `confirm` without danger. Undone as easily as done, such as a warehouse: `ask: 'bulk'`.

A `confirm`'s words may hold `{count}`, `{value}` and `{record}`. A form's fields are `text`, `number` or `boolean`, or options, required unless `required: false`.

## Bulk runs {#bulk}

`run` writes one record; the engine schedules a selection. Records that cannot take the action are not sent, are recorded as **not run**, and stay selected. At most four run at once, with progress and **Stop**. After the run, the view is read again with the failed and not-run records still selected.

## Outcomes {#outcomes}

| Outcome | When | Then |
| --- | --- | --- |
| Done | `run` resolved | The view is read again |
| Failed | `run` rejected: the service refused the command | The service's reason is shown; the record stays selected |
| Not run | The record no longer qualified when its turn came | The reason is shown; the record stays selected |
| Unknown | No answer: a timeout, an abort, a dropped connection, the action's `timeout`, or **Stop waiting** | "Outcome unknown, refresh to check"; the record is deselected |

**`run` resolves once the read model reflects the command**, or the refresh that follows reads the old state and the reader thinks nothing happened. A Wow command waits for `snapshot`, or for the subscription the view reads.

**Records with an unknown outcome are deselected**, so they do not invite a blind rerun. A reader may still press again, so make the command idempotent: send the version the row showed, with `version` in `record.rowFields`, and the gateway's request id makes a retry of one send count once.

The original error of a failure or an unknown outcome also reaches the environment's `onError`, for monitoring.

## Slots {#slots}

What a declaration cannot say, such as a link out or a control of your own, goes in a slot: `slots: { row, bulk, global }` on `bind`, drawn after the declared actions. A slot that sends a command calls its context's `run`, which reports like the declared actions. Declare what can be declared.

## Test the actions {#harness}

[`actionHarness`](../../reference/typescript/wow-view-engine/testing.md#api-actionHarness), from `/testing`, reads the declarations by the engine's own rules, without a screen:

```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`, as the engine would not send it.

## A complete example

The ops console's [`parkedActions.ts`](https://github.com/Ahoo-Wang/Wow/blob/main/platform/ops/console/src/views/parkedActions.ts) declares resume, skip, mark recoverable and apply a retry spec over parked entries, with forms and confirmations, and [`parkedActions.test.ts`](https://github.com/Ahoo-Wang/Wow/blob/main/platform/ops/console/src/views/parkedActions.test.ts) tests them with `actionHarness`.
