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
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:
// 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:
// 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)) }) (Fitting the View Engine into a Host). 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.
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
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, and every row carries them whatever the view shows:
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
| 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
disabledbutaria-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.
EmbeddedViewdraws no declared actions: its row commands are only the host's ownrowActions.
Confirmation and forms
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;actionis the confirming button's word and the run's name on the outcome line, the action'slabelby default;toneis the action's by default. - The words may hold
{count}(how many records),{value}(the option a choice picked, itslabelsaid 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'). confirmmay 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
confirmis 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 adialog. A click outside does not close it, and focus is held inside. - A form's fields:
inputistext,numberorboolean(options where it hasoptions), required by default (required: falsemakes one optional),initialthe value it opens with;rungets 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
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
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 }), theCommand-Aggregate-Versionheader, withversionin the definition'srecord.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 }), theCommand-Request-Idheader), 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
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
actionHarness(actions, rows, { now }) 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:
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, 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: "Ship" and "Cancel order" on the orders, pressable at the bottom of the page. The source:
orderActions.ts,wowCommands.ts,ordersDefinition.ts, andintegration.test.ts, which checks them withactionHarness. - 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. - A real host: the compensation console's
executionActions.ts(withchangesAt, aconfirmthat is a function of the input, and a choice) and itsexecutionActions.test.ts.
Next
| Next | Read |
|---|---|
ViewHost, bind, routes and embeds | Fitting the View Engine into a Host |
| How keyboard and screen-reader users walk through declared actions | Accessibility of the View Engine |
| Wire one business object from zero, with one action | Getting Started with the View Engine |