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} />;
}
FieldWhat it does
labelIts text, or a function of its target (Approve 3 invoices)
iconAn icon from the grid's set by name, an <svg>, or text
keysThe keys that run it: 'mod+c', 'Delete', 'shift+F2', '/'. mod is Ctrl, or ⌘ on a Mac. The first is shown beside it
groupIts 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
availabletrue; why it can't run now (shown disabled, with that reason); or false: it doesn't apply here and is left out
checkedDrawn checked: a menu's current choice, a toggle that is on
dangerTints it as destructive
messageWhat the workspace says once it ran and its write applied (Approved 3 invoices); default: its label
runDoes 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 fieldHolds
ids, rowsThe records it acts on
outsideSelected records the view's effective query leaves out
unavailableRecords asked for that are not here (gone, or not loaded); complete is false when any are
notLoadedOf 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

IdKeys
copy, cut, pastemod+c, mod+x, mod+v
clear (the selected cells)Delete, Backspace
selectAllmod+a
undo, redomod+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 run returns 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), fires writeBlocked with source 'action', as a refused edit does. The workspace shows it as a toast.
  • If run, available, checked, label or message throws, 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.
  • runCommand answers ran (the commit's outcome and its change), cancelled, unavailable (with why: the inputs it needs, a review nothing can show) or failed. 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 };
	},
};
requiresBefore 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: trueIts 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 decidedWhat the grid does
acceptedIts records (as the server has them now) show as the server's data: nothing to undo or save
refusedNothing changes; the reason is told
conflictThe records changed on the server: its records show, and the reason is told
unknownThe 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.

Edit on GitHub

Last updated on

On this page