Commands
Every behaviour, built-in or yours, declared once with its keys and availability, and offered by every menu, bar, key and palette.
A command is something the grid can do: copy, sort ascending, pin a column, or one of your own like "Approve". It is declared once, with its label, icon, keys, when it applies and what it does. Keys, the cell and column menus, the workspace's menus, bars and command palette, and your own buttons all run the same command. Change it once and it changes everywhere it is offered.
Your commands go in compose.commands, after the grid's own:
import { Grid, type ColumnDef, type GridCommand, type GridCompose } from '@witbybit/wit-grid-react';
type Invoice = { id: string; status: string; amount: number };
declare const invoices: Invoice[];
declare const columns: ColumnDef<Invoice>[];
declare function sendToAccounting(rows: readonly Invoice[], options: { partial: boolean }): Promise<void>;
const approve: GridCommand<Invoice> = {
id: 'approve',
label: ({ ids }) => (ids.length === 1 ? 'Approve' : `Approve ${ids.length} invoices`),
icon: 'check',
keys: ['mod+shift+a'],
group: 'Invoices',
records: 'selection',
available: ({ rows }) => (rows.every((row) => row.status === 'open') ? true : 'Only open invoices can be approved.'),
run: ({ ids, api }) => api.transaction({ cells: ids.map((rowId) => ({ rowId, colField: 'status', value: 'approved' })) }),
};
const compose: GridCompose<Invoice> = {
commands: (defaults) => [
...defaults,
approve,
{ id: 'send', label: 'Send to accounting', records: 'matching', run: ({ rows, complete }) => sendToAccounting(rows, { partial: !complete }) },
],
};
export function Invoices() {
return <Grid rows={invoices} columns={columns} compose={compose} />;
}| Field | What it does |
|---|---|
label | Its text, or a function of its target (Approve 3 invoices) |
icon | An icon from the grid's set by name, an <svg>, or text |
keys | The keys that run it: 'mod+c', 'Delete', 'shift+F2', '/'. mod is Ctrl, or ⌘ on a Mac. The first is shown beside it |
group | Its heading in the workspace's command palette. Without one it is not listed there, but its keys still work |
records | 'selection': the selected records, or the record a menu was opened on. 'matching': every record that passes the filters |
available | true; why it can't run now (shown disabled, with that reason); or false: it doesn't apply here and is left out |
checked | Drawn checked: a menu's current choice, a toggle that is on |
danger | Tints it as destructive |
message | What the workspace says once it ran and its write applied (Approved 3 invoices); default: its label |
run | Does the work. Return the write's result, and a refusal is reported like a refused edit |
Every function gets the command's target: the records it acts on (ids, rows), the api, the focused cell or
the one a menu opened on, its colField (or the header a column menu opened on), the cell selection, the source
(key, menu, palette, toolbar or api) and, for a menu or a button, the anchor element to open a popover at.
For a matching command on server or infinite data only the loaded records are there, and complete is false.
What a command on the selection acts on
The selection is kept as views switch and filters change, so it can hold records the open view no longer shows. Taken
from the selection (the bulk bar, the palette, keys, api.runCommand(id) without targets), a command acts on the
selected records in the open view's effective query — its filter, the lenses and the search — whether or not the
viewport has drawn them. The others are named, never acted on:
| Target field | Holds |
|---|---|
ids, rows | The records it acts on |
outside | Selected records the view's effective query leaves out |
unavailable | Records asked for that are not here (gone, or not loaded); complete is false when any are |
notLoaded | Of unavailable, the ones a server-side grid has not loaded (they may exist on the server) |
To act on records outside the view, give them explicitly: api.runCommand(id, { rowIds }) is not filtered by the
view — each record is acted on, or named in unavailable. The workspace's bulk bar says 5 selected · 2 outside this view and offers Include those 2; its count and its actions follow that choice until the selection changes.
When the focused record leaves the view (filtered out, removed), keyboard focus moves to the record now nearest its place, in the same column: the first one after it still shown, else the one before.
The grid's commands
| Id | Keys |
|---|---|
copy, cut, paste | mod+c, mod+x, mod+v |
clear (the selected cells) | Delete, Backspace |
selectAll | mod+a |
undo, redo | mod+z; mod+y, mod+shift+z |
edit (the focused cell) | Enter, F2 |
filterByValue, excludeValue, clearColumnFilter, chartRange, chartData | |
exportAll, exportExcel, exportSelected | |
sortAscending, sortDescending, clearSort, pinLeft, pinRight, unpin | |
group, ungroup, pivot, editField, insertLeft, insertRight, deleteField |
A workspace adds its own: view:<tab> for each view, palette (mod+k), search (/), inspector:toggle (i),
record:inspect (Enter), record:table, records:duplicate, records:delete (Delete), filter:quick:<id> for
each quick filter, filter:clear, export:print, export:xlsx, export:csv, view:new, view:save,
select:clear, field:new and the bulk edits (bulk:<field>:<value>). In a view other than the table, selectAll
selects its records.
Arrows, Tab, Home and End, Page Up and Down, and Escape move and close: they aren't commands.
Change one, everywhere
Composing a built-in command changes every place that offers it. Give copy your own run, and Ctrl+C, the
browser's Edit menu and the cell menu's Copy all run it. A native copy, cut or paste hands its data to run as
clipboard, to write or read there:
import type { GridCommand, GridCompose } from '@witbybit/wit-grid-react';
type Invoice = { id: string; amount: number };
const compose: GridCompose<Invoice> = {
commands: (defaults) =>
defaults
// Copy writes the invoice ids, not the cells.
.map((command): GridCommand<Invoice> =>
command.id === 'copy' ? { ...command, run: ({ clipboard, ids }) => void clipboard?.setData('text/plain', ids.join('\n')) } : command
)
// Undo on Ctrl+U as well; no redo at all.
.map((command) => (command.id === 'undo' ? { ...command, keys: ['mod+z', 'mod+u'] } : command))
.filter((command) => command.id !== 'redo'),
};Dropping a command removes it from every menu and bar and turns off its keys. To change keys, compose the command's
keys. A key in a text field someone is typing in stays the field's: plain keys, Backspace, Delete, and Ctrl or ⌘
with A, C, X, V, Z or Y.
In menus and bars
A menu entry or a bar control names a command, and the command fills in the rest: its label, icon, key hint, checked state, availability (disabled, with the reason) and what it does. The entry's own fields win. An entry whose command doesn't apply is left out. Every built-in menu is made this way, so your entries look and behave like the grid's:
import type { GridCompose } from '@witbybit/wit-grid-react';
const compose: GridCompose = {
// Approve first in every cell menu, on the record pressed (or the selection, when it is in it).
menu: (entries, context) => (context.menu === 'cell' ? [{ command: 'approve' }, 'separator', ...entries] : entries),
};In the workspace, { id: 'approve', command: 'approve' } puts it in the bulk bar or a toolbar section. A menu about
some records, like a record's menu or a board column's, aims its commands at those records.
What happens when it runs
- Availability is checked again: the records may have changed since the menu opened.
- A command whose
runreturns a promise doesn't run twice at once. While it runs, it shows as disabled. - A write it returns that was refused, wholly or for some cells (
bestEffort), fireswriteBlockedwith source'action', as a refused edit does. The workspace shows it as a toast. - If
run,available,checked,labelormessagethrows, the grid reports a runtime fault, and the command fails or shows as disabled.
Commands that write: inputs and review
A command that changes records declares its write instead of performing it: write(target, input) returns the
request (cells, rows to add or remove), and the grid does the rest. It prepares the write as the user, named as the
command (action: <id>, unless the request names another), shows it for approval when the command says review: true,
and commits exactly what was shown, as one change and one undo entry. If a record or the rules change while the review
is open, approving prepares it again from the same records (the target is frozen when the command starts; a new
selection never changes it) and shows it again with the reason. Nothing is ever committed different from what was
shown.
What a command asks for before it writes is declared as data, so every caller gives it the same way:
import type { GridCommand } from '@witbybit/wit-grid-react';
type Invoice = { id: string; status: string; approver: string; secondApprover: string };
export const approve: GridCommand<Invoice> = {
id: 'approve',
label: 'Approve',
records: 'selection',
review: true,
// `field` borrows the column's kind and options; without it, `kind`: text, longText, number, date, select, checkbox.
inputs: [{ id: 'secondApprover', label: 'Second approver', field: 'secondApprover' }],
write: ({ ids }, input) => ({
cells: ids.flatMap((rowId) => [
{ rowId, colField: 'status', value: 'approved' },
...(input.secondApprover ? [{ rowId, colField: 'secondApprover', value: input.secondApprover }] : []),
]),
policy: 'bestEffort',
}),
};runCommand('approve', { input: { secondApprover: 'kim' } })gives inputs: what is given is not asked for.- A reviewed command shows its inputs in the review, beside what they make the write do: changed there, the write is
prepared and shown again. Required inputs (
required: true, or the message to show) still missing are asked for first, on their own. - Inputs are yours: they reach
write(put them in cells to keep them on the record) and the result (input); the grid does not invent fields for them or send them by saving on their own. runCommandanswersran(the commit'soutcomeand itschange),cancelled,unavailable(with why: the inputs it needs, a review nothing can show) orfailed. A write refused as prepared is reported (writeBlocked) and never shown for approval.
Who shows it. One presenter shows every writing command's operation: the review grid option, your own dialog.
It is given a GridReview (the command, its title, the frozen target and whether it is complete, the inputs and their
values, the prepared proposal, null while inputs are asked for, and a note saying why it is shown again) and
answers { approve: true, input? } or { approve: false }. It shows and answers; the grid prepares and commits.
Without one, the workspace shows it in its own dialog (each record's changes, setters' effects included, what is added,
removed or refused, and how complete the target is). A grid with neither refuses a command that must ask, saying why.
Fields in your dialog. An input bound to a field is best asked with that field's own control. <FieldControl>
shows the value the way the column draws it (a person's avatar, a status pill) and edits it with the column's own
editor (the searchable person list, the date picker). It holds a value and writes nothing to a record. The workspace's
dialog uses the same control, and createFieldControl(api, field, options) gives it outside React.
import { FieldControl, type GridApi, type GridReview } from '@witbybit/wit-grid-react';
type Invoice = { id: string; secondApprover: string };
declare const api: GridApi<Invoice>;
declare const review: GridReview<Invoice>;
declare const input: Record<string, unknown>;
declare function setInput(id: string, value: unknown): void;
review.inputs.map((declared) => (
<FieldControl
key={declared.id}
api={api} // the surrounding <Grid>'s by default
field={declared.field ?? declared.id}
label={declared.label}
value={input[declared.id] ?? null}
onChange={(value) => setInput(declared.id, value)}
/>
));Commands the server does
Some work is the server's to do and to decide: confirming an order, approving an invoice, submitting a workflow
step. A command declares it with send (in place of run and write). The grid asks for its inputs, makes what it
requires true, sends it, and reports what the server decided.
import type { GridCommand } from '@witbybit/wit-grid-react';
interface PurchaseOrder {
id: string;
version: number;
}
declare const oracle: {
confirm(
id: string,
options: { requestId: string; signal: AbortSignal }
): Promise<{ ok: boolean; conflict?: boolean; timedOut?: boolean; message: string; order: PurchaseOrder }>;
status(
requestId: string,
options: { signal: AbortSignal }
): Promise<{ state: 'done' | 'working' | 'failed' | 'none'; message: string; order: PurchaseOrder }>;
};
const confirm: GridCommand<PurchaseOrder> = {
id: 'po:confirm',
label: 'Confirm in Oracle',
records: 'selection',
// An open draft on the order is applied first; its changes are saved first. It is not sent unless both worked.
requires: { drafts: 'apply', saved: true },
// `id` names this operation: Oracle uses it to do it once, and to answer whether it was done.
send: async ({ id, target, signal }) => {
const answer = await oracle.confirm(target.ids[0], { requestId: id, signal });
if (answer.ok) return { status: 'accepted', records: [answer.order], message: `Confirmed v${answer.order.version}` };
if (answer.conflict) return { status: 'conflict', reason: answer.message, records: [answer.order] };
if (answer.timedOut) return { status: 'unknown', reason: 'Oracle did not answer: check before confirming again.' };
return { status: 'refused', reason: answer.message };
},
// Asks about the same operation, by its id: never confirms it again.
recheck: async ({ id, signal }) => {
const answer = await oracle.status(id, { signal });
if (answer.state === 'done') return { status: 'accepted', records: [answer.order] };
if (answer.state === 'working') return { status: 'pending' };
if (answer.state === 'failed') return { status: 'refused', reason: answer.message };
return { status: 'unknown', reason: answer.message };
},
};requires | Before it is sent |
|---|---|
drafts: 'apply' | An open draft on one of its records is applied; one that will not apply stops it |
drafts: 'refuse' | It is not sent while a draft is open on one of its records |
saved: true | Its records' changes queued now are saved (save({ records })): not other records', and an edit made meanwhile waits for the next save; it is not sent unless they saved |
| What the server decided | What the grid does |
|---|---|
accepted | Its records (as the server has them now) show as the server's data: nothing to undo or save |
refused | Nothing changes; the reason is told |
conflict | The records changed on the server: its records show, and the reason is told |
unknown | The request may or may not have landed (a timeout): said so, never assumed |
send is given the operation: its id (the same for its rechecks, so the server can do it once), its target as it
is when sent (drafts applied, changes saved), its input, and a signal aborted when the grid is destroyed — its
answer then applies nothing, and the result is failed.
The answer is joined with what changed since it was sent. The operation's records are its basis: a field the
answer left as it was sent stays as it is now; a field unchanged here since takes the answer's value; a field edited
here since that the answer changed too is a conflict, with this side's value shown (with data.save, through the
save's own rebase, so an edit queued here stays queued); a field the server changed since (a later operation's answer,
an incoming change) stands. A record removed since is not brought back. Two operations on one record answered in
reverse order: the older answer does not undo the newer one.
Not known to have landed. A command with recheck answers an unknown send with the result's recheck(): it asks
the server about the same operation, by its id, and never sends it again. It answers what the server decided, or
pending while the server is still working on it, and can be asked again; an accepted answer shows its records as
the send's would have. Whether the server can answer (a status endpoint, idempotency by the operation's id) is the
app's and the server's.
api.runCommand answers { status: 'ran', sent } with the decision, or { status: 'blocked', reason } (with the
draft's outcome or the save's saved) when what it requires was not met. On a server-side grid, records: 'matching' gives the command a query — the filter and order its datasource is asked with — so an action on every
matching record can be the server's to do, loaded here or not.
Your own buttons
api.getCommands({ rowIds, cell, colField }) gives every command bound to its target, the same way the built-in
surfaces see them: applies, enabled, reason, checked, hint and run(). api.runCommand(id, target)
runs one:
import { useGridCommands } from '@witbybit/wit-grid-react';
function InvoiceBar() {
const commands = useGridCommands().filter((command) => command.group === 'Invoices' && command.applies);
return commands.map((command) => (
<button key={command.id} disabled={!command.enabled} title={command.reason} onClick={() => void command.run()}>
{command.label}
</button>
));
}useGridCommands() re-renders when the commands may have changed: the records, the selection, the focused cell,
editing, or a new composition. Inside a <Grid> it uses that grid; outside, pass its api:
useGridCommands(undefined, api). run() resolves to { status: 'ran', outcome? },
{ status: 'unavailable', reason } or { status: 'failed', error }. commandsChanged fires when the commands are
composed again.
Last updated on