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 wereBound 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) isinput: { text: '-', parses: false }: the field keeps its last valid value and is inwaiting, with every calculated field that depends on it — never worked out from 0. revisionmoves 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:
issuesare 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 leaveschecking: trueuntil it answers; its issue then lands on the samerevision. 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)anddraft.collection(field)give the same handle every call, likeapi.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 itscanShowdenies 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 record | Bound to the record | Bound to a draft |
|---|---|---|
| Deleted | Empties; writes refused (The record is gone.) | The draft is gone; its rows stay readable, writes refused |
| Out of a server-side grid's cache | Keeps its rows; writes refused until it is loaded again | Unaffected: the draft holds its values |
| Refreshed, nothing new | Nothing changes | Nothing changes |
| A newer version arrives | Its rows follow | Fields 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
| Guarantee | Who gives it |
|---|---|
| Cancel discards the whole draft, header and rows, and its undo steps | Wit |
| Apply makes one local change and one undo step | Wit |
| Header and rows persist together | Your server: the push sends them in one change |
| A concurrent change is refused | Your 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.
Last updated on