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
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:
// 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:
// 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), 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:
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:
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
| 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 draws no declared actions.
Moving kanban cards
In a kanban layout, moving a card to another lane is an action that names the field it sets with moves:
// 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
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
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
| 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
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
actionHarness, from /testing, reads the declarations by the engine's own rules, without a screen:
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 declares resume, skip, mark recoverable and apply a retry spec over parked entries, with forms and confirmations, and parkedActions.test.ts tests them with actionHarness.