Collections and entries

A record's rows (an order's lines) edited in a grid bound to the record, and a record's fields laid out as you choose.

A collection is a field that holds rows of its own: an order's lines, a form's table. The rows live in the record. A grid bound to the field shows them, and every change made there — an edit, a paste, a new or removed row — is one write of the record's field:

  • the record's rules see its rows ("an order needs a priced line"),
  • a value derived from them (the order's total) is worked out in the same change,
  • it is one step of the record's undo (the bound grid's Ctrl+Z is the record's),
  • and the record saves it, header and rows together.
import { Grid, collectionType, type ColumnDef, type GridApi } from '@witbybit/wit-grid-react';

interface Line {
	id: string;
	qty: number;
}
interface PurchaseOrder {
	id: string;
	vendor: string;
	lines: Line[];
}
declare const orders: GridApi<PurchaseOrder>;
declare const orderId: string;
declare const lineColumns: ColumnDef<Line>[];

const orderColumns: ColumnDef<PurchaseOrder>[] = [
	{ field: 'vendor', header: 'Vendor' },
	{ field: 'lines', header: 'Lines', type: collectionType({ label: (count) => `${count} lines` }) },
];

// The order's lines, edited in a grid of their own.
<Grid<Line> collection={orders.collection<Line>(orderId, 'lines')} columns={lineColumns} getRowId={(line) => line.id} />;

Without React: createClientGrid({ collection: api.collection(orderId, 'lines'), columns, getRowId }).

In a draft

Bound to a record's draft (draft.collection('lines')), the rows change only in the draft. Applying it writes the header and its rows as one change and one undo step; cancelling it drops both. Until then the bound grid's undo is the draft's (a draft's own undo): each change in it is one step there, and cancelling discards them, so nothing cancelled comes back. Closing the bound grid loses nothing: a new one shows what was drafted, and its history.

import { Grid, type ColumnDef, type GridApi } from '@witbybit/wit-grid-react';

interface Line {
	id: string;
	qty: number;
}
declare const api: GridApi<{ id: string; vendor: string; lines: Line[] }>;
declare const orderId: string;
declare const lineColumns: ColumnDef<Line>[];

const draft = api.draft(orderId);
draft.set('vendor', 'Globex');
<Grid<Line> collection={draft.collection<Line>('lines')} columns={lineColumns} getRowId={(line) => line.id} />;
// …three lines edited, then
draft.cancel(); // the vendor and the three lines are back as they were

Bound to the record, a write the record refuses — its rules, async ones awaited for a write that may wait, its capabilities — is refused in the bound grid with the record's reason, and never commits there: nothing sees it. Bound to a draft, the record's whole-record rules do not refuse an edit (an order may pass through no lines while one is replaced): the draft's proposal says what would be refused, and Apply waits until it holds. A change to the record from elsewhere (the server, an undo) shows in the bound grid.

The record as proposed

draft.proposed() reads the record as it would be: its committed values, under what is drafted, under the cell being typed in a grid bound to one of its collections — with its calculated fields worked out the way a write works them out. A total beside the lines follows each keystroke, before anything commits. draft.subscribe(listener) is told when it moves; in React, useProposed(rowId, api).

import { useProposed, type GridApi } from '@witbybit/wit-grid-react';

const money = (value: number) => value.toLocaleString(undefined, { style: 'currency', currency: 'USD' });

function Totals({ api, orderId }: { api: GridApi<Order>; orderId: string }) {
	const proposed = useProposed(orderId, api);
	if (proposed?.waiting.includes('lines')) return <span>Waiting on the quantity being typed…</span>;
	return <span>{money(proposed?.record?.total ?? 0)}</span>;
}
  • Reading it changes nothing. It never commits, records history, prepares, runs a value setter or sends anything, and gives the same object while nothing it reads changed. A field with a value setter shows the value drafted; what the setter makes is the draft's proposal (prepare()).
  • Input that is not a value yet (a lone - in a quantity) is input: { text: '-', parses: false }: the field keeps its last valid value and is in waiting, with every calculated field that depends on it — never worked out from 0.
  • revision moves with everything it reads: the committed record, the drafted values, the input, the rules and capabilities. A dialog keeps the revision it opened on and passes it with its submit: api.runCommand('confirm-send', { rowIds: [id], proposed: { [id]: revision } }). If the record moved since — a drafted header field, a changed line, a change from the server — the command does not run and answers { status: 'changed', reason, rowIds }; show the dialog again on the record as it is now.
  • Issues: issues are the record's rules (cell and row, warnings too) judged on the proposed record, live — what Apply would be refused for, shown while it is typed. An async rule leaves checking: true until it answers; its issue then lands on the same revision. Reading never records them as the record's issues.
  • An operation's availability, its dialog, the dialog's preview and its submit should all read this one record.

Apply and save, without losing the edits

draft.apply(proposal, { save: true }) commits the draft as one change, then saves the record (data.save({ records: [id] })) and answers with what saving did (saved). When the server refuses the change, the record goes back as the server has it and the draft holds the edits again, with the server's reason in getStatus().drafts[id].refused (cleared by the next change): nothing the person typed is lost. A conflict stays the record's to settle (getStatus().data.conflicts), as any refused save's does.

A bound grid belongs to its record

  • api.collection(id, field) and draft.collection(field) give the same handle every call, like api.draft.
  • Changing the rows is changing the record's field: the field's canEdit (as proposed, a draft's values in) applies to every edit, paste, fill, and to adding and removing rows, on top of the bound grid's own rules, and follows their changes live. In an entry layout or the inspector, a field its canShow denies is hidden. (collection.allows('show' | 'edit') answers the same.)
  • It follows its owner's theme and theme tokens (options.theme) as they change, unless given a theme of its own.
  • In an entry layout or the inspector it is drawn by the adapter that drew its owner: React cells draw.
  • What happens to the record, it follows:
The recordBound to the recordBound to a draft
DeletedEmpties; writes refused (The record is gone.)The draft is gone; its rows stay readable, writes refused
Out of a server-side grid's cacheKeeps its rows; writes refused until it is loaded againUnaffected: the draft holds its values
Refreshed, nothing newNothing changesNothing changes
A newer version arrivesIts rows followFields drafted here become conflicts, as any draft's do (rows join by row)

Work that waits

An app's async step over the rows — reprice an order's lines from a price service — takes a basis when it starts and writes its rows against it: collection.basis() gives the rows as they are, and its write(rows) writes only what the step changed, joined row by row and field by field with what changed meanwhile. A qty typed while the prices were being fetched stands and the prices land; a line removed meanwhile is not brought back; a price edited by hand meanwhile stands, and the step's price for it is reported in conflicts ({ id, field }, field: null for a removed line). Bound to the record, the write is the record's, refused by its rules as any edit is.

import type { GridCollection } from '@witbybit/wit-grid-react';

interface Line {
	id: string;
	item: string;
	price: number | null;
}
declare const lines: GridCollection<Line>;
declare const prices: { quote(items: readonly string[]): Promise<Map<string, number>> };

async function reprice() {
	const basis = lines.basis();
	const quoted = await prices.quote(basis.rows.map((line) => line.item));
	const outcome = basis.write(
		basis.rows.map((line) => ({ ...line, price: quoted.get(line.item) ?? line.price })),
		'reprice'
	);
	return outcome.conflicts.length ? `${outcome.conflicts.length} lines changed while repricing: kept as they are.` : 'Repriced.';
}

A draft and the grid's records take a basis the same way: draft.basis() (its fields as drafted, write(values) drafts the step's values as one undo step) and api.basis(ids) (write(rows) writes them as the person's edit).

Changes made apart

Rows changed here and by a teammate join row by row, by id (getRowId, default row.id), and field by field: a teammate's change to line 20 and yours to line 10 both stand, as do rows added on either side. Only the same field of the same row changed both ways — or a row removed on one side and changed on the other — is a conflict. This holds under a draft, and when a save rebases on the server's change.

What that conflict is, exactly: the field is written whole, so the conflict is on the field (lines), and it names the rows and fields that collided — parts on the conflict (getStatus().data.conflicts), collisions.lines on a draft's state ([{ id: 'l10', field: 'qty' }]; field: null for a row removed on one side) — so a person sees which line. Settling it settles the field: keep this side's rows or take the other side's. Writing one line by its path, and settling one line's conflict on its own, are not offered.

What is promised, and by whom

GuaranteeWho gives it
Cancel discards the whole draft, header and rows, and its undo stepsWit
Apply makes one local change and one undo stepWit
Header and rows persist togetherYour server: the push sends them in one change
A concurrent change is refusedYour server, checking the version each change is based on

A record's fields, laid out by you

createRecordForm(api, { bind, layout }) — <RecordForm> in React — lays a record's fields out as you choose: fields, and sections with a heading, each field its own field control bound to the record or its draft. A section with when shows only for a record it applies to, as proposed. A collection field whose type names its rows' columns (collectionType({ columns, getRowId })) is drawn as a grid of them, bound to the same record or draft.

import { RecordForm, type GridApi } from '@witbybit/wit-grid-react';

declare const api: GridApi<{ id: string; stage: string; vendor: string; delivery: string; dock: string }>;
declare const orderId: string;

<RecordForm
	draft={api.draft(orderId)}
	layout={[
		{ id: 'header', title: 'Order', fields: ['vendor', 'delivery'], columns: 2 },
		{ id: 'receiving', title: 'Receiving', fields: ['dock'], when: (order) => order.stage === 'receiving' },
		{ id: 'lines', fields: ['lines'] },
	]}
/>;

The record inspector lays its fields out the same way: workspace.inspector.layout.

Edit on GitHub

Last updated on

On this page