Persistence

Keep how each person left the grid, the shared views and saved quick filters with one storage option.

What the grid keeps beyond a session is one option, storage, with a place for each thing:

KeyWhat is kept
layoutThe person's record: which view is open, every draft of a view, and what belongs to no view (row heights…)
viewsThe stored views (the switcher and the sidebar's Views panel) and the default one, without a view store
quickFiltersThe quick filter chips people save from their filters

A string keeps all three in this browser's localStorage under that key:

<Grid rows={rows} columns={columns} storage='orders-grid' />

Anything without a place lasts for the session. storage is a create option: a grid keeps the places it was made with.

Your own storage

Each place is the same small adapter, GridStorageAdapter<T>: load() the value, save(value) it whole, and optionally remove() it. Each may be synchronous or return a promise. Mix places freely: the layout on your server, the rest in the browser.

import { localGridStorage, type GridStorage, type GridStorageAdapter, type PersistedGridState } from '@witbybit/wit-grid-react';

const layout: GridStorageAdapter<PersistedGridState> = {
	async load() {
		const res = await fetch('/api/grid-settings');
		return res.ok ? res.json() : null;
	},
	async save(state) {
		await fetch('/api/grid-settings', { method: 'PUT', body: JSON.stringify(state) });
	},
	async remove() {
		await fetch('/api/grid-settings', { method: 'DELETE' });
	},
};

export const storage: GridStorage = { ...localGridStorage('orders-grid'), layout };

localStorageAdapter(key) is one place in localStorage; localGridStorage(key) is all three (what storage: key uses). A load that fails counts as nothing kept; a save that fails is reported.

The person's record

The grid itself is a view. Every grid has one view from the start: the table as the app set it up (its columns and initialState). It has no name and is not listed; it is open while getStatus().views.activeId is null. Every change to what the grid shows (its columns, filters, sort, grouping, how it draws its records) is a draft of the open view, the grid's own included. A grid that only wants a table only ever has this one view, and storage remembers how each person left it.

The record (storage.layout) is { v, state, views }, one per person per grid:

  • views: which view is open, and every draft, each with the definition it is based on. On the next visit, a definition changed since (the app's new set up of the grid, a team view someone saved) joins the kept draft part by part: what the person changed stays, the rest follows the new definition. A draft of a view gone since is offered as Save as new.
  • state: what belongs to no view: row heights, aggregation, pivot mode, the page, the theme, the sidebar panel, the chart window and the search (a custom aggregate is stored as 'custom' and taken back from the grid's own on restore).

Saves are debounced, not made on every keystroke; nothing is saved over a record still being put back. A restore checks everything against today's grid: columns that no longer exist drop out of the order, sort, filters, query, grouping and aggregation; columns added since keep their place; malformed filter conditions are dropped. A record of another schema version (7 now) is refused with a reason. A failed load or save is reported as a runtimeFault event and the grid keeps its current state. In pivot mode the view keeps the grid's own grouping and columns, the ones pivot mode returns to.

Without a record the default view opens, else the grid's own. api.views.discard(null) drops the grid's own draft: the table as the app set it up.

api.setAutoSave(false); // pause debounced saves
api.saveNow(); // force an immediate save, bypassing the debounce
await api.clearPersistedState(); // the layout's remove()
const status = api.getStatus().persistence; // { status, autoSave, lastSavedAt?, error? }
api.watch(
	(grid) => grid.status.persistence,
	(status) => console.log(status.status)
);

Views

A view is a destination people open: how it draws its records (its presentation) and which records, in what order and grouping (its query), with an owner. Opening one shows it as defined, in one change (a server is asked once, with the new query). Its search is not part of it: search is the person's, and stays as views switch.

// The app's own views, defined in code, each with an ordered revision (default 1).
views: {
	defined: [
		{ id: 'orders', name: 'Orders', presentation: tableView() },
		{ id: 'deliveries', name: 'Deliveries', presentation: calendarView({ colorField: 'status' }) },
		{ id: 'mine', name: 'My open POs', presentation: tableView(), query: { filter: { columns: mine }, sort } },
	],
	store, // where stored views live (default: the grid's `storage`; without either, views made here last the session)
	onSave, // optional: the app acknowledges a save of one of its own views (otherwise Save saves it as a new view)
}

await api.views.open('deliveries'); // as drafted, else as defined: its presentation, query and columns
const mine = await api.views.saveAs('Q1 close', { scope: 'team', makeDefault: true }); // what the grid shows, kept
const result = await api.views.save(); // the open view's definition, as its owner acknowledges it
await api.views.discard(); // drop the open view's draft: its latest shared definition again
await api.views.rename(mine.id, 'Q1 close (final)');
await api.views.lock(mine.id, true); // its shared definition is not changed in place
await api.views.duplicate(mine.id, 'Q1 close (copy)'); // a personal copy
await api.views.remove(mine.id);
await api.views.open(null); // the grid's own view, as drafted

const { list, activeId, defaultId, modified, changes, drafts, persistent, error } = api.getStatus().views;

Drafts. Changing the open view makes a draft of it, marked in the switcher, with Save (where allowed), Save as new view and Discard. A draft is kept across switches and in the person's record, and shown again when its view opens. getStatus().views.drafts[id] says what it changes, the revision it is based on, and what came of it:

  • Shared changes join part by part. When the view's shared definition changes (its store says so with subscribe, an app raises a view's revision, a save is acknowledged), a part changed only there flows in; a part changed both ways is a conflict (conflicts): mine is kept, never silently overwritten, until saved or discarded. The parts: the presentation as a whole (its type and settings), the filter, the sort, the grouping, the table's column order, and each column's width, visibility and pin.
  • A save owns the draft as pressed (saving): changes made while it is pending stay drafted, and join the acknowledged definition. A refusal keeps the draft with the reason (refused).
  • A locked view, or one the person may not manage (capabilities.canManageView): their draft is their own (personal), kept in their record, never saved to the view; Discard is "use the shared layout".
  • The shared view deleted under a draft (deleted): Save is refused; Save as new keeps it.

Owners acknowledge saves. A save is done only when the view's owner answers: { saved } (the definition it now holds, with its revision: the view becomes that), { refused: reason }, or { stale: newer } when it holds a newer definition than the one the save was based on (someone saved in between). save, rename and lock resolve to { status: 'saved' | 'refused' | 'stale', … }. A view store numbers what it keeps:

const store: GridViewStore = {
	load: async () => ({ views, defaultId }),
	save: async (view, basedOn) => {
		const held = views.get(view.id);
		if (held && held.revision !== basedOn) return { stale: held };
		const saved = { ...view, revision: (held?.revision ?? 0) + 1 };
		views.set(view.id, saved);
		return { saved };
	},
	remove: async (id) => void views.delete(id),
	setDefault: async (id) => void (defaultId = id),
};
  • Revisions are ordered numbers. An app's own definition given again with a lower revision than the one held is ignored; a higher one replaces it; the same revision with other content is the app's mistake: reported as a runtime fault, and the definition held is kept. Raise the revision when you change a definition.
  • modified is true while the grid no longer shows the open view as defined, and changes says where: presentation, filters, sort, grouping or columns (the table's columns, when the view names them). Where the person is (the open sidebar panel, the chart window, the page) never modifies a view. The sidebar's Views button shows a dot while there are changes, and its panel lists them.
  • Opening a view is not an edit: it adds no undo step (watch hears it with cause: { source: 'restore' }).
  • An unknown presentation type is kept as it is: the view says it is not available, and nothing is converted.
  • The default view opens the grid when there is no kept layout to restore (a kept layout wins).
  • A failed store call rejects, sets error, and is reported as a runtimeFault event.

The sidebar's Views panel and the workspace's view switcher are built on this API.

Edit on GitHub

Last updated on

On this page