Rules and saving
One `data` option for the rules records must pass, the issues they raise, and saving changes to your server.
Everything about the grid's records beyond showing them lives under one option, one action object and one read:
| You | Use |
|---|---|
| Set the rules, the server, a device copy | the data option: rules, save, offline, quality, diff |
| Check, save, resolve, compare, stream | api.data: check(), save(), discard(), resolve(), setIssues(), compareTo(), stream() |
| Read what is going on | api.getStatus().data: changes, state, issues, blocking, conflicts, diff, lastSavedAt |
Saving needs nothing imported. Only a device copy (offline({ name })) and httpSync come from the offline entry
point, so a grid that does not use them ships none of that code.
Rules
import { Grid, required, type GridDataOptions } from '@witbybit/wit-grid-react';
type Invoice = { id: string; vendor: string; status: string; amount: number; secondApprover?: string };
const data: GridDataOptions<Invoice> = {
rules: {
cell: [required('vendor')],
row: [
{
id: 'approval-limit',
blocking: true,
validate: ({ row }) =>
row.status === 'approved' && row.amount > 50_000 && !row.secondApprover
? { message: 'Over $50,000: needs a second approver.', fields: ['status', 'secondApprover'] }
: null,
},
{
id: 'small',
severity: 'warning',
blocking: false,
validate: ({ row }) => (row.amount < 100 ? { message: 'Check the amount', fields: ['amount'] } : null),
},
],
},
};
export function Invoices({ invoices }: { invoices: Invoice[] }) {
return (
<Grid<Invoice>
rows={invoices}
getRowId={(invoice) => invoice.id}
columns={[
{ field: 'vendor', header: 'Vendor' },
{ field: 'status', header: 'Status' },
{ field: 'amount', header: 'Amount' },
]}
data={data}
/>
);
}The rules run:
- On every edit, paste, fill, board move and bulk action. A blocking rule refuses the write (the
writeBlockedevent says why); any other marks the fields it names. A write is judged on the record as it will be, and a record that already broke a rule is not refused for a change to a field the rule does not name. - On rows that arrive (
setRows, row transactions, a stream): marked, never refused. - On save, for the records it sends.
- When you ask:
await api.data.check()runs them over every record (or{ rows: [...] }, or ascope) and marks what fails on the cells, the rows and the minimap.
Rules are live: api.setOptions({ data: { rules } }) (or a new data prop) drops the issues of rules taken away at
once and checks every record against the new ones.
The newest check wins. A check judges one version of a record (a rule reads every value from the row it is given)
under one set of rules: when the record changes or the rules are replaced while a slow async rule is still running,
what that rule finds is dropped, and the newer check decides. A full check() never erases a result newer than
itself, and checks never clear the server's refusals: a write to the field does. save and offline are read once, when saving starts, so make them once
and keep them apart from rules that change.
A rule can follow the app's state: an approval limit finance changes while people work. Make the rules from it in a memo; a new limit is a new rule, and every record is checked against it at once. Rules kept in a constant or a memo never re-check on a re-render.
import { useMemo, useState } from 'react';
import { Grid, type GridDataRules, type GridSaveOptions } from '@witbybit/wit-grid-react';
type Invoice = { id: string; status: string; amount: number; secondApprover?: string };
const rulesFor = (limit: number): GridDataRules<Invoice> => ({
row: [
{
id: 'approval-limit',
blocking: true,
validate: ({ row }) =>
row.status === 'approved' && row.amount > limit && !row.secondApprover
? { message: `Over $${limit}: needs a second approver.` }
: null,
},
],
});
export function Desk({ invoices, save }: { invoices: Invoice[]; save: GridSaveOptions<Invoice> }) {
const [limit, setLimit] = useState(25_000);
const data = useMemo(() => ({ rules: rulesFor(limit), save }), [limit, save]);
return (
<>
<select aria-label='Approval limit' value={limit} onChange={(event) => setLimit(Number(event.target.value))}>
<option value={10_000}>$10K</option>
<option value={25_000}>$25K</option>
</select>
<Grid<Invoice>
rows={invoices}
getRowId={(invoice) => invoice.id}
columns={[
{ field: 'status', header: 'Status' },
{ field: 'amount', header: 'Amount' },
]}
data={data}
/>
</>
);
}Issues
getStatus().data.issues holds every issue on the records, whatever raised it: the rules, quality checks, the
server's refusals, conflicts, and the app's own. Each has a message, a severity, whether it is blocking, the
record (rowId), the fields it is about (fields) and, for a rule's, the rule's id (rule).
They show wherever the record does, in the issue's colour (an error over a warning):
- The table: each field an issue names is marked on its cell; an issue that names no field marks the record's row.
- The views: a card on the board or in the gallery, an entry in the calendar, a bar and row in the Gantt carry the record's worst issue, and each field the card shows that an issue names is ringed. The message is on hover and for screen readers.
- The inspector: the record's issues are listed under its title, and the fields they name are ringed.
Name the fields a rule is about (fields: ['status', 'amount']) so people see where to fix it.
import type { GridApi } from '@witbybit/wit-grid-react';
declare const api: GridApi<{ id: string; vendor: string }>;
// The server's own report, shown on the cells beside the rules' issues; `[]` clears it.
api.data.setIssues('serverValidation', [
{
id: 'dup-INV-1',
source: 'serverValidation',
type: 'custom',
severity: 'error',
rowId: 'INV-1',
colField: 'vendor',
message: 'This vendor is on hold.',
createdAt: Date.now(),
},
]);getStatus().data.blocking is the part that stops a save: the blocking issues on records with changes not saved yet
(on every record when the grid has no save). The server's refusals and conflicts never block: the server decides
again on the next save, and a person settles a conflict.
Saving
import { Grid, GridScope, useGrid, useGridWatch, type SyncChange, type SyncPushResult } from '@witbybit/wit-grid-react';
type Invoice = { id: string; status: string; amount: number };
async function push(changes: readonly SyncChange<Invoice>[]): Promise<SyncPushResult<Invoice>> {
const response = await fetch('/api/invoices/save', { method: 'POST', body: JSON.stringify(changes) });
return response.json();
}
const data = { save: { push } };
function SaveButton() {
const { api } = useGrid<Invoice>();
const changes = useGridWatch((grid) => grid.status.data.changes.length, { api });
const save = async () => {
const result = await api!.data.save();
if (result.status === 'blocked') alert(result.issues.find((issue) => issue.blocking)?.message);
};
return (
<button disabled={!changes} onClick={() => void save()}>
Save {changes} {changes === 1 ? 'change' : 'changes'}
</button>
);
}
function Desk({ invoices }: { invoices: Invoice[] }) {
return (
<GridScope>
<SaveButton />
<Grid<Invoice>
rows={invoices}
getRowId={(invoice) => invoice.id}
columns={[
{ field: 'status', header: 'Status' },
{ field: 'amount', header: 'Amount' },
]}
data={data}
/>
</GridScope>
);
}A save is the changes queued when it was called, and nothing more. Their records are checked as the save will send
them (an edit made while it checks or sends is not part of it, and takes no part in its check), only they are sent,
and its result lists exactly them. What people change meanwhile waits in getStatus().data.changes for the next save
(with when: 'auto', for the next save on its own).
Saving some records. api.data.save({ records: ids }) saves only those records' changes, as queued when it is
called: other records' changes stay queued, and their issues never stop it. A queued change that also touches a
record it does not save (one write over several records) is never split: the save is refused (blocked, with a
reason) and nothing is sent; save the records together. A save already running is waited for, and no change is
sent twice. An operation that requires: { saved: true } saves its records this way.
api.data.save() says what happened to each of its changes:
status | What happened |
|---|---|
saved | every change sent was accepted (saved) |
nothing | there was nothing to save |
blocked | a record it would send breaks a blocking rule (issues says which), or a change spans the records it saves and others (reason): nothing was sent |
rejected | the server refused some: they are rolled back and their fields keep its reason; the accepted ones are in saved |
failed | the request did not go through (error: network, auth): every change stays queued |
Every result but nothing carries issues: every issue on the records it sent, warnings too.
rows are the server's records: they are shown, never sent back. After that the grid owns them, so a new rows
array does not replace records with changes still pending. Saving is manual by default, and manual means manual:
coming back online, the tab becoming visible, a server poke (subscribe), polling and retries only bring the
server's changes in (with a pull); queued changes go out in api.data.save() and nowhere else, and a failed save
waits for the next one. With save: { push, when: 'auto' } changes go out shortly
after they are made, with no save button; a record that breaks a blocking rule holds every change back until it is
fixed. api.data.discard() reverts what is not saved yet.
What your endpoint receives and answers
Each change is one commit (an edit, a paste, a board move, an approved bulk action), with an id that stays the same
across retries: apply it once, and answer a retried one as accepted. Typing in a cell and undoing a change not saved
yet fold into the change before; once a change is in a save under way, a later edit is a change of its own. One
request never carries two changes to the same record: the later one goes out based on your answer to the first.
The commit says which change it is: transaction() and commit() return its id as change, and so does the
changed event, so the app can follow a decision to your answer: the saved event and save()'s result name it.
import type { GridApi } from '@witbybit/wit-grid-react';
type Invoice = { id: string; status: string };
async function approve(api: GridApi<Invoice>, ids: string[]) {
const { change } = api.transaction({ cells: ids.map((rowId) => ({ rowId, colField: 'status', value: 'approved' })), action: 'approve' });
const result = await api.data.save();
const refused = result.status === 'rejected' ? result.rejected.find((rejection) => rejection.change.id === change) : undefined;
return refused ? `Some were refused: ${refused.reason}` : 'Approved';
}Undo is local, and saving keeps it honest: undoing a change not saved yet takes it out of the queue; undoing one
that is out or accepted is a new change, saved like any other, never an edit of what was sent. A write the app named (transaction({ …, action: 'approve' })) arrives with its action, so the server records the decision, not only the fields.
import type { SyncChange, SyncPushResult } from '@witbybit/wit-grid-react';
type Invoice = { id: string; status: string; amount: number };
const change: SyncChange<Invoice> = {
id: 'c1',
at: 1735689600000,
// Per field: the value the server last had, and the new one.
updated: [{ id: 'INV-24001', fields: { status: { base: 'review', value: 'approved' } } }],
};
const answer: SyncPushResult<Invoice> = {
accepted: [],
rejected: [{ id: 'c1', code: 'validation', reason: 'Over the approval limit: needs a second approver.' }],
};A change can carry many records (a bulk approval is one change). To refuse only some of them, name them in rows:
those roll back with the reason, and the rest of the change is saved.
import type { SyncPushResult } from '@witbybit/wit-grid-react';
type Invoice = { id: string; status: string };
const partly: SyncPushResult<Invoice> = {
accepted: [],
rejected: [{ id: 'c1', code: 'validation', reason: 'Vendor bank details changed: verify first.', rows: ['INV-24007'] }],
};Codes: validation, permission, not-found and other roll the change (or its refused rows) back; conflict with the server's
current rows keeps both sides for a person to resolve with api.data.resolve(conflict.id, 'local' | 'remote' | { value }). httpSync({ url }) from the offline entry point is a ready push and pull over one endpoint.
The record as you saved it, and its version
An accepted change can carry the records as your server saved them (accepted: [{ id, current: [{ id, data }] }]):
a value you normalised, a total you worked out, a new modifiedOn. They show as the server's data.
When your records carry their version (a modifiedOn, an etag), name it with save.version: each change is sent
with the version it was based on (baseVersion), and the version a saved record comes back with is the next one's
base. A concurrent change is only caught if your server compares it.
import type { GridSaveOptions, SyncChange, SyncPushResult } from '@witbybit/wit-grid-react';
interface Entry {
id: string;
title: string;
modifiedOn: string;
}
declare function push(batch: readonly SyncChange<Entry>[], signal: AbortSignal): Promise<SyncPushResult<Entry>>;
const save: GridSaveOptions<Entry> = { version: (row) => row.modifiedOn, push };Server-side and infinite grids
A server-side or infinite grid takes the same data — rules, save, resolve, version — all but the device copy
(offline keeps every record; these grids hold only the loaded ones). A change to a loaded record is queued and saved
like a client grid's; your answer patches the loaded records' fields (a record your server adds or removes is the
datasource's to load again), and a refusal rolls back with its reason. Your datasource loads the server's records:
save.pull is not used.
Saving to an API of your own
Your server rarely takes the grid's shape. push is where you translate: build your request body from the changes,
send it, and translate the answer back. Keep each change's id in what you send, so the answer can say which change
each result belongs to; a server that answers per record refuses each one with its own reason, and the rest are saved.
import type { GridSaveOptions, SyncChange, SyncPushResult } from '@witbybit/wit-grid-react';
type Invoice = { id: string; status: string; amount: number };
// Your API's own body: its field names, its codes.
type Decision = { ref: string; invoiceNumber: string; set: Record<string, unknown>; expected: Record<string, unknown> };
type Answer = { results: { ref: string; invoiceNumber: string; ok: boolean; message?: string }[] };
const AP_FIELDS: Record<string, string> = { status: 'state', amount: 'amount_cents' };
const apValue = (field: string, value: unknown) =>
field === 'status' ? String(value).toUpperCase() : field === 'amount' ? Math.round(Number(value) * 100) : value;
function toDecisions(changes: readonly SyncChange<Invoice>[]): Decision[] {
return changes.flatMap((change) =>
(change.updated ?? []).map((row) => ({
ref: change.id,
invoiceNumber: row.id,
set: Object.fromEntries(Object.entries(row.fields).map(([field, { value }]) => [AP_FIELDS[field] ?? field, apValue(field, value)])),
// What the reviewer saw: the server can refuse an item whose invoice moved on since.
expected: Object.fromEntries(Object.entries(row.fields).map(([field, { base }]) => [AP_FIELDS[field] ?? field, apValue(field, base)])),
}))
);
}
function fromAnswer(changes: readonly SyncChange<Invoice>[], answer: Answer): SyncPushResult<Invoice> {
const mine = (change: SyncChange<Invoice>) => answer.results.filter((result) => result.ref === change.id);
return {
// A change none of whose items failed is saved; one the server did not answer stays queued.
accepted: changes
.filter((change) => mine(change).length > 0 && mine(change).every((result) => result.ok))
.map((change) => ({ id: change.id })),
// Each failed item refuses only its invoice, with the server's words.
rejected: changes.flatMap((change) =>
mine(change)
.filter((result) => !result.ok)
.map((result) => ({ id: change.id, code: 'validation' as const, reason: result.message ?? 'Refused', rows: [result.invoiceNumber] }))
),
};
}
const save: GridSaveOptions<Invoice> = {
push: async (changes) => {
const response = await fetch('/ap/v2/invoice-decisions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': changes.map((change) => change.id).join('+') },
body: JSON.stringify({ items: toDecisions(changes) }),
});
// Not through (network, auth, a 5xx): throw, and every change stays queued for the next save.
if (!response.ok) throw new Error(`AP answered ${response.status}`);
return fromAnswer(changes, await response.json());
},
};The approval desk saves this way: its AP request button shows the last body it sent and what the AP answered.
Compare and stream
data: { diff: true }, thenapi.data.compareTo({ base, compare })marks what is added, removed and changed;getStatus().data.difflists it andapi.data.acceptChange(rowId, field)takes the compared value.compareTo(null)stops comparing.api.data.stream()brings the server's changes your app receives into the grid, as the server's, or held for a person to look at first: see Incoming changes.data: { quality: { rules: [duplicateValueRule('email')] } }adds checks across records, run bycheck().
Keep these apart
Saving records is one of four kinds of keeping, each with its own option:
- Records (
data.save): what people change in the data, until the server has it. - Layout (
storage): column widths, sort, filters and saved views, per person. - Undo (
undo/redo): this session's steps back, local and short-lived. - Audit (your server, from what it accepted): who changed what, kept for good. The
changedevent says who and how, for an activity panel.
Last updated on