Views and the record workspace

One record model, many projections — table, gallery, calendar, Kanban, Gantt and chart share selection, writes, undo and the inspector.

A view shows the grid's displayed records another way: a gallery of cards, a calendar, a Kanban board, a Gantt schedule or a chart. Views are projections, not separate widgets. Every one of them:

  • reads the same records — filters, sort and search keep applying;
  • reads the same field meanings (title, status, owner, schedule…), set once;
  • writes through the same transaction pipeline — validation, capabilities, one undo step per gesture;
  • shares selection, the focused record and the inspector, so switching views keeps your place.

The workspace

workspace wraps the grid in a command bar (view tabs, search, Filter / Sort / Group / Fields, undo, presence), a record inspector, bulk actions and a command palette. Each column says what it means — its role — and every view is ready:

import { useMemo } from 'react';
import {
	Grid,
	dateRangeType,
	galleryView,
	ganttView,
	kanbanView,
	personType,
	progressType,
	selectType,
	tableView,
	type ColumnDef,
	type GridViewsOptions,
	type GridWorkspaceOptions,
} from '@witbybit/wit-grid-react';

interface Task {
	id: string;
	title: string;
	status: string;
	owner: string;
	team: string;
	schedule: { start: string; end: string };
	progress: number;
	budget: number;
	dependsOn: string[];
	parent: string | null;
}
const people = [{ value: 'ava', label: 'Ava' }];

// What each field means to the views: the board groups by the status, the Gantt draws the schedule…
const columns: ColumnDef<Task>[] = [
	{ field: 'title', header: 'Task', role: 'title' },
	{ field: 'status', header: 'Status', role: 'status', type: selectType(['todo', 'doing', 'done'], { done: ['done'] }) },
	{ field: 'owner', header: 'Owner', role: 'owner', type: personType({ people }) },
	{ field: 'team', header: 'Team', role: 'team', type: selectType(['Platform', 'Web']) },
	{ field: 'schedule', header: 'Schedule', role: 'schedule', type: dateRangeType() },
	{ field: 'progress', header: 'Progress', role: 'progress', type: progressType() },
	{ field: 'budget', header: 'Budget', role: 'value' },
	{ field: 'dependsOn', header: 'Depends on', role: 'dependencies' },
	{ field: 'parent', header: 'Parent', role: 'parent' },
];

// The app's own views: each a destination the switcher lists, opened as defined.
const views: GridViewsOptions = {
	defined: [
		{ id: 'table', name: 'Table', presentation: tableView() },
		{ id: 'board', name: 'Board', presentation: kanbanView<Task>({ swimlaneField: 'team', wipLimits: { doing: 12 } }) },
		{ id: 'plan', name: 'Plan', presentation: ganttView<Task>({ zoom: 'week', criticalPath: true }) },
		{ id: 'cards', name: 'Cards', presentation: galleryView<Task>({ layout: 'medium' }) },
	],
};

export function Roadmap({ rows }: { rows: Task[] }) {
	const workspace = useMemo<GridWorkspaceOptions<Task>>(() => ({ title: 'Engineering roadmap' }), []);
	return <Grid rows={rows} columns={columns} getRowId={(row) => row.id} rowSelection='multiple' views={views} workspace={workspace} />;
}

The switcher lists the grid's views (api.views): the app's own (views.defined), then the stored ones people saved. When the app defines none, it starts with Table (the grid's own view) plus each built-in view the records support (a calendar needs a date, a board a status, a schedule a date range), offered as views for the session: one switcher, one way to save. How a view draws is its presentation, { type, settings }, made by each type's factory (tableView(), galleryView({ … }), kanbanView({ … }), calendarView, ganttView, chartView, pivotView, or an app's own type). Imperatively, api.setState({ presentation }) and api.getState().presentation switch views; { type: 'table' } is the table.

Opt-in by cost. The workspace and each view load on first use (code-split), and their stylesheet is injected only then. A grid that stays a table never downloads any of it. Calling api.setState({ presentation }) on a grid without the workspace still works: the view opens over the table with its own command bar.

Quick filters

A row of chips under the command bar turns common filters on and off with one click: Mine, Overdue, Due this week, Blocked and Unassigned, each where the record roles allow it (Mine needs collaboration.me, the date chips a due or end date, Blocked a blocked status). They apply in every view and the table.

  • A chip is a lens: on, its conditions are ANDed with the open view's own (never replacing them), it stays on as views switch, and it is not a change to the view. Two chips on one field both narrow (Mine and Unassigned together match nothing). When a chip and the view narrow the same field and nothing matches, the strip says so.
  • Lenses the app turns on (filter.lenses) show in the strip too, always, each with its ×; one on a field the grid does not have shows inactive, with the reason.
  • Save filters appears when the column filters on aren't already a chip: name them and they become a chip of your own, on, kept in the grid's storage (storage.quickFilters; for the session without one). Its × removes it.
  • Every chip is a command too (filter:quick:<id>): it is in the command palette, and composing its keys gives it a key.
  • A chip with count: true says how many records it matches — every record, whatever else is filtered — and keeps it current: an exception people work down ("Missing price · 3"). tone gives it a colour of its own.

The chips are composed: compose.quickFilters gets the built-in ones by id (mine, overdue, due-this-week, blocked, unassigned, those the roles allow) and returns the chips to show, yours among them. The ones people save follow. To turn chips off, compose the bar without its quickFilters section; to stop people saving them, compose the commands without filter:quick:save. In values, $me stands for the signed-in person and $today for the day the chip is clicked:

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

const workspace: GridWorkspaceOptions = {
	compose: {
		quickFilters: (chips) => [
			...chips.filter((chip) => chip.id !== 'unassigned'),
			{ id: 'big', label: 'Over 50k', icon: 'coins', filter: { budget: { type: 'number', operator: 'gt', value: 50000 } } },
		],
	},
};

Customize, and changes in place

Every view has a Customize panel (the sliders button in the command bar) with its own options — board columns, swimlanes, totals, WIP limits and hidden columns; card size, covers and fields; time scale, row height, layers, auto-scheduling and working days; month or week, weekends — plus Colour by (colorField) for all of them. Settings are the view's configuration, so they are saved with a saved view (and mark it as changed until you save). View tabs that do not fit the bar wait behind a More button that lists them; the active one always shows.

A changed setting re-projects the live view in place: no remount, scroll and selection kept, and records glide to their new places. Pills on cards and schedule rows edit in place too: press a status, priority, team or owner pill for its options (one write, one undo step).

Cards you design

Gallery and board cards share one card layout (card): a headline value (corner), badges under the title, a meta line (the first field left, the rest right), labelled details drawn with their columns' own renderers, and the progress and counts toggles. Each field picks how it reads — pill, dot, labels (outlined), person, date, relative (days left or late), progress or text; auto follows the field's kind. A part you leave out keeps the view's default, so card: { meta: [] } only drops the meta line. In Customize, drag fields between the slots (or use Alt + arrows on a field), change how each one reads, and watch the preview card follow.

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

declare const api: GridApi<unknown>;

api.setState({
	presentation: kanbanView({
		card: {
			badges: [{ field: 'priority', display: 'dot' }, { field: 'labels' }],
			meta: [{ field: 'owner' }, { field: 'due', display: 'relative' }],
			details: [{ field: 'estimate' }],
		},
	}),
});

Highlights

highlights give the records that match a condition a look: tint the card, colour its accent (the stripe, the Gantt bar, the calendar entry), add a badge, or dim it. A condition is the grid's own column filter — the same model, matching and editor as filtering the column — so anything a column can filter by, it can highlight.

highlights: [
	{ field: 'priority', filter: { type: 'select', values: ['urgent'] }, style: 'tint', color: 'red' },
	{ field: 'due', filter: { type: 'date', operator: 'before', dateFrom: '2026-10-11' }, style: 'badge', label: 'Late' },
	{ field: 'status', filter: { type: 'select', values: ['done'] }, style: 'dim' },
];

Rules apply in order: the first tint and the first accent win, badges add up. Highlights and the card layout are view configuration, saved with a saved view.

Motion

Records glide when they move (a drop, a reschedule, a setting, a collapse), new ones settle in, ones that leave fade out, and views slide in from their tab's side. It is the grid's own row-animation policy, applied in two dimensions: rowAnimation (the rowAnimation option) sets the style, duration and easing for the table and every view alike — style: 'none' turns it all off — and users who prefer reduced motion get none. Only transform and opacity animate; scrolling never does.

Touch and assistive technology

On touch screens a press and hold lifts a card, a calendar entry or a Gantt bar (a swipe still scrolls); two fingers pinch the Gantt's time scale and a swipe turns the calendar's page. Moves that nothing visible describes — a card's new position, a new month, a lifted record — are announced to screen readers.

Record roles

A column's role says what it means to the views. A role no column claims is inferred from the columns' schema — column types say what they hold (select with its options and colours, person, progress, dateRange…) — and their names; a column with a role is never inferred for another. role: false keeps a column out of inference altogether: a "Win %" progress column that is not the deal's progress.

RoleUsed forInferred from
titleCard and bar labels, the inspector titlethe first text column
keyThe short id beside the title (T-1172)a key / code column, else row id
statusBoard columns, done / blocked state, coloursa select named status / stage / state
priorityPills on cardsa select named priority / severity
ownerAvatars, assignment, resource loada person column
teamThe natural swimlanea select named team / squad / area
labelsTag pillsa tags / multi-select column
scheduleGantt bars, calendar spans ({ start, end })a date range column
start, endSeparate start and end date fieldsdates named start / end
dueDue chips, calendar daysa date named due / deadline
progressProgress bars and fillsa progress column
valueColumn, lane and section totalsa currency column
dependenciesLinks: T-12, T-12SS+2d, or { id, type, lag }a column named depends / predecessors
parentThe Gantt outline (summary tasks)a column named parent
baselineBaseline bars and schedule variancea date range named baseline
milestoneMilestone diamondsa checkbox named milestone
coverGallery cover imagesa URL column named cover / image
rankManual card order within a board columna column named rank / order

The status column says which of its values mean done and blocked: selectType(options, { done, blocked }), which sets its schema.done and schema.blocked (default: options labelled done / complete / closed, else the last; and options labelled blocked / on hold / stuck).

A view can use another column for a role, for itself: its records ({ kind: 'calendar', records: { schedule: 'due' } } places the calendar by the due date). The calendar's Customize writes the same thing.

Kanban

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

const board = kanbanView({ swimlaneField: 'team', wipLimits: { doing: 12, review: 8 }, wipPolicy: 'block' });

Columns are the status options in order (every option, even empty ones), each with its count, WIP limit and total. Swimlanes split every column by another field. Cards show identity, value, priority, team or labels, owner, due date, progress and counts. Drag cards — or the whole selection — between columns and lanes; with a rank role the drop indicator places them between neighbours (fractional ranks: one write per card). A drop the limit or permissions refuse shows why and writes nothing. The arrows go where the cards are: ↑/↓ down a column (on into the next lane), ←/→ to the nearest column with cards. Alt + ←/→ moves the focused card(s), Alt + ↑/↓ reorders. A card waiting on unfinished predecessors after its planned start is blocked.

OptionMeaning
columnFieldBoard columns. Default: the status role
swimlaneFieldLanes inside each column (null for none)
columnsColumn order and labels; unlisted values follow
wipLimits, wipPolicyLimits per column; warn (default) or block
aggregateThe header number, e.g. { fn: 'sum', field: 'points' }
density, columnWidthcomfortable / compact cards; column width
collapsedColumnsColumns collapsed at first
hiddenColumnsColumn values not shown
hideEmptyColumnsHide columns without cards

A column's menu sets its WIP limit, moves it left or right (columns), collapses or hides it.

OptionMeaning
cardSortOrder cards in each cell by a field ({ field, direction }); manual ranking is off
splitColumnsSplit a column in two by a checkbox ({ doing: { field: 'ready' } }): doing / done
laneWipLimitsA limit for each cell — a column within one swimlane
aging{ field, days }: unfinished cards untouched for days fade and show how long

A split column moves cards between its halves by writing the checkbox; the workflow treats both halves as one column. Split and join columns from a column's menu.

Workflow

transitions turns the board into a workflow, keyed by column value: allowed says where cards from a column may go ('*' is the rule for columns not listed), require lists fields a card must have to enter a column, and set writes values as a card enters ('$today' for today's date, null to clear). A move that needs a field asks for it in a small form, and the move, the answers and the set values are one undo step. While dragging, the columns a card may not enter are dimmed. Customize → Workflow edits it column by column.

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

const transitions: BoardTransitions = {
	allowed: { backlog: ['todo'], todo: ['doing', 'backlog'] },
	require: { doing: ['owner'], review: ['estimate'] },
	set: { doing: { started: '$today' }, done: { progress: 100, completed: '$today' } },
};

The rules live in the board kernel (planBoardMove returns rejected or the needs; canTransition answers one move), so a custom board gets the same workflow.

Gantt

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

const plan = ganttView({
	zoom: 'week',
	criticalPath: true,
	autoSchedule: 'push',
	calendar: { workingDays: [1, 2, 3, 4, 5], holidays: ['2026-12-25'] },
});

The task outline and the timeline are one row projection with one scroll authority — they cannot drift apart. The outline has WBS numbers, summary tasks (spans and duration-weighted progress roll up) and milestones; the timeline draws progress-filled bars, baselines (amber when late), dependency connectors (finish-to-start, start-to-start, finish-to-finish, start-to-finish, with lag), the critical path, weekends and today, from day to year zoom, with a minimap.

Drag a bar to move it, its ends to resize it, the progress handle to set progress, and the round handle onto another bar to link it (links that would make a task wait on itself are refused). Moves keep the working-day duration. With auto-scheduling, successors follow: you see the impact — every task that moves and by how many working days — and choose to reschedule them, move only this task, or cancel. The whole reschedule is one undo step.

By keyboard, on a task's row: Alt + ←/→ moves the task a day, Alt + Shift + ←/→ moves its end, and L on one task then L on another makes the second depend on the first (Escape cancels). Each row tells assistive technology what its bar shows: its dates, how far along it is, and what it waits on.

A task without dates has a row but no bar: drag across the days on its row to draw them in. With createRecord, the lane under the last task adds tasks the same way (drag across the days), or with New task in the outline; the new task opens in the inspector.

The inspector gains Schedule (dates, duration, slack, baseline variance), Dependencies (types, add / remove) and Resources (the owner's concurrent load during the task, over-allocated days).

OptionMeaning
zoomday, week (default), month, quarter, year
calendarWorking days and holidays: durations, lags and moves count them
autoSchedulepush (default), tight (as early as links allow) or off
confirmRescheduleShow the impact before writing (default true)
dependencies, baseline, criticalPathLayers to draw
workloadEach person's load under the schedule, over-allocation flagged
taskPaneWidth, rowHeight, initialDateLayout and the first day shown
progressLineA zig-zag through today: tasks behind bend left, ahead bend right
deadlineFieldA date field of deadlines: a flag per row, overrunning bars marked
baselines, compareToSaved baselines (each task's dates at the time) and the one compared
constraints{ typeField, dateField }: start / finish constraints (below)

Save a baseline (command palette) keeps every task's dates under a name — and, if you like, writes them to the baseline field in one undo step — and Compare with in Customize switches between it and the baseline field; summary rows, variance and "Behind baseline" follow.

Level resources (palette, or Level on the workload panel) delays work until nobody carries more than one task at a time: work is taken in start order, critical work keeps its place, tasks only move later and keep their working-day duration, and successors follow their links. The preview lists every move; it is one undo step. The kernel's levelResources(model, field, { capacity, resource }) returns the same moves for headless use. Click an over-allocated day in the workload to reassign one of that person's tasks, or level just their work.

Constraints hold a task's start or finish to a day. The type field holds one of the following; MS Project's SNET, SNLT, FNET, FNLT, MSO and MFO also work:

  • start-no-earlier
  • start-no-later
  • finish-no-earlier
  • finish-no-later
  • must-start
  • must-finish

Auto-scheduling keeps a task no earlier than its day (or on it, for must-…). "No later than" can't be met by moving a task later, so it is flagged instead. A bracket marks each constraint's day, and a dashed red outline marks a task whose dates break it. Constraint conflicts counts them. The kernel takes ScheduleModelOptions.constraint and reports constraintViolations() for headless use.

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

const cards = galleryView({ layout: 'large', cover: 'image', groupBy: 'status', fields: ['labels'] });

Cards in sections (collapsible, with counts, totals and average progress), four layouts (small, medium, large, compact rows), and covers from an image field, a generated preview or none. Each section starts with a quick-add card when the workspace has createRecord. Cards are virtualized and the column count follows the width.

layout: 'masonry' gives each card its cover's own proportions and fills the shortest column first (images report their size as they load; generated covers vary). Without a cover field, the first link field holding an image becomes the cover. The turn-over button on a card shows its back: every other field of the record, and Open.

Calendar

Records on a month grid — or one week (mode: 'week') — by their schedule (or due date): multi-day spans are packed into lanes so a bar stays on one line, crowded days show +N more, drag an entry to another day (or Alt + arrows), and + on a day adds a record there. The arrows go by day: ←/→ to the nearest day with entries, ↑/↓ through a day's entries and on to the same weekday a week away. showWeekends: false hides Saturday and Sunday (bars clip to the shown days), weekStartsOn picks the first day; months and weeks slide in as you step through them.

Drag either end of an entry to change its start or end (when the record's dates are a range). mode: 'agenda' lists the month's days that have records — each record on its first day of the month, with its range, status and owner.

Records whose dates carry a time (an ISO time or a Date) show it and sort by it within the day.

In week mode, timeGrid lays the week out by the hour. It is on by default when the dates carry times. Timed one-day records sit at their hour, as tall as they last, side by side where they overlap. All-day and multi-day records stay in a band above, and a red line marks the current time.

  • Drag a record to another time or day (15-minute steps). Drag its bottom edge to change when it ends.
  • Double-click an empty hour to add a record there.
  • The time is written back in the shape the cell held. The title opens a month picker (dots on days with records), and legend (default on) lists the colours under the calendar — click one to filter to it, again to clear.

showUnscheduled: true (or No date in the toolbar) opens a tray of the records without dates: drag one onto a day to schedule it, or drop a dated entry on the tray to clear its dates.

Chart

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

const byStatus = chartView({ groupBy: 'status', splitBy: 'team', aggregate: { fn: 'sum', field: 'budget' } });

The records counted or totalled by a field: one mark per status, owner, team, label, yes/no or period of a date.

  • Measure (aggregate): a count of records (the default), records done, average progress, or the sum, average or highest of a number field. Money stays money in labels and tooltips.
  • Dates fall into days, weeks, months (the default), quarters or years (dateBucket). Empty periods between the first and the last stay, so time reads evenly. A date range is read by its start.
  • Split (splitBy): one series per value of a second field, stacked (default) or side by side (stacked: false). The legend hides or shows a series.
  • chart: column (default), bar, line, area, pie or donut. A pie shows each group whole and ignores the split.
  • Groups follow the field's own order (options, then time). sort: 'value' puts the largest first, 'label' sorts A to Z. Past limit (24) groups, the smallest fold into Other.
  • A record with several values (labels, people) counts once in each of its groups, and once in a group's total.

Click a mark to list its records beside the chart (a segment of a split chart lists just that series). The list is records like any other: click to select and inspect, Ctrl/⌘ or Shift to add. Show in table filters the table to that group and opens it; Select selects them all. On the keyboard, Tab to the chart, arrows move between groups, Enter lists their records and Esc closes the list. Bars take their options' colours. The toolbar's Type, By and Measure buttons, the command bar's Group button and Customize change the chart in place. Print puts the chart (drawn for paper) and its numbers in a table on the page.

The kernel is chartRecords(rows, reader, options) (headless): the groups, the series, a number per mark, each group's records and total, and categoryFilter() for the filter that shows a group in the table.

Pivot

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

const revenueByRep = pivotView({
	heatmap: true,
	pivot: { rows: ['owner'], columns: ['stage'], values: [{ field: 'amount', aggFunc: 'sum' }] },
});

A cross-tab over the records. Rows are grouped by some fields, columns come from the values of others, and every cell aggregates the records that meet there. The view is the grid itself in pivot mode, so everything on that page applies: buckets, show-as, top values, totals, sorting by a column, and export with header groups. The workspace's quick filters, filters and search apply first.

  • Fields opens the field list to build the layout. It opens on its own while the pivot has no value. Edits apply at once, and the view tab keeps them, so saved views and links bring the layout back.
  • Swap turns rows into columns. Heatmap (heatmap) shades the innermost cells from the smallest value to the largest, in the theme's accent, with one scale per value. Totals are not shaded.
  • Chart (chart) draws the pivot above the table: columns, bars, a line, an area, a pie or a donut, for one value at a time, by rows or by columns, stacked if you like. The view keeps these settings, and the chart can be downloaded as an image. chart: { type: 'line', value: 'winRate', by: 'columns' } draws each rep's win rate quarter by quarter.
  • Filtered and calculated values (how they work) are built in the field list: a value can count only some records, and Calculated value works one out from the others, such as a win rate or an average deal.
  • Double-click a cell to list its records, led by their title. Choose one to open it in the inspector, or Show these records to open the table filtered to exactly that cell.
  • The summary strip shows each value's total over all the records, and how many rows and columns the pivot has.
  • Over a server-side grid whose datasource has getPivot, the server works the pivot out with the workspace's filters and search — every matching record, not the pages loaded — and is asked again when they change or a record does. A cell's records are the server's: Show these records (or a double-click) opens the table filtered to the cell, counted by the server. Without getPivot the view pivots the records loaded so far and says so (Coverage: loaded only).
  • Customize has switches for the totals and the heatmap. The palette has every toolbar action, plus Export the pivot to Excel and Export the pivot as CSV.
  • Without a pivot, the view starts from the status field down the rows, summing the first number field (or counting records).

Selection, keyboard and the inspector

KeysDoes
ClickSelect and open in the inspector
Ctrl / ⌘ click, Shift clickToggle, or select a range in the view's order
Arrows, Home, EndMove between records
EnterOpen in the inspector · I toggles it
SpaceToggle selection · Ctrl/⌘ A selects all
EscClose the inspector, then clear the selection
Ctrl / ⌘ Z, YUndo, redo
Ctrl / ⌘ KCommand palette: views, records, bulk edits, view commands
/Search

The inspector edits every field with its column's own editor, a React cellEditor included (drawn in place; a grid mounted without an adapter opens such a field in the table), and commits it like an edit in the table. With inspector: { edits: 'draft' } its edits land as the record's draft instead (api.draft): the fields show what is drafted, a bar says what is not applied yet, and Apply commits them together (the rules judge the record as drafted; a refusal is said there) or Discard drops them. A teammate's change to a drafted field is said with Keep mine / Take theirs, and Apply waits for the choice. A field that edits in the table still commits at once. It shows dependencies both ways, and has tabs for comments, activity (every edit made in the grid, with old and new values), files and links. With two or more records selected, the bulk bar moves, assigns, re-prioritises, shifts dates, duplicates or deletes them — each one transaction.

Where the inspector sits

Where it sits and how much room it takes are options of inspector, each defaulting to what it does without them:

OptionDoes
placement: 'bottom'Under the view, as a reading pane (a mail client, an ERP's order lines). Default 'side'.
opens: 'onFocus'Opens as soon as a record is focused, and follows it. Closed by the person, it stays closed until opened again. Default 'onRequest'.
width / heightIts size beside / under the view. Under the view the list keeps at least two fifths of the room.
resizablePeople drag its edge (or use the arrow keys on it) to resize it; the size holds as records change. Default true.
movablePeople move it beside or under the view as it is (its button, the inspector:placement command). Default true.
expandableIt can take the view's whole room and give it back: its button, the inspector:expand command, Escape. The view stays mounted underneath. Default true.
fieldsIn: 'tab'Its fields go in a Details tab (details) the app orders among its own, for a record whose own work comes first. Default 'body'.
import type { GridWorkspaceOptions } from '@witbybit/wit-grid-react';

const workspace: GridWorkspaceOptions = {
	// The selected order under the list, its lines first, its header fields a tab.
	inspector: { placement: 'bottom', opens: 'onFocus', height: 300, fieldsIn: 'tab', edits: 'draft' },
	compose: {
		inspector: (tabs) => [{ id: 'lines', label: 'Lines', render: (el) => void el.append('…') }, ...tabs],
	},
};

A grid mounted inside the inspector (an order's lines in a tab) keeps its keys: the ones it leaves never run the workspace's commands on the workspace's records. A key wanted there too is composed into that grid as well.

Collaboration

collaboration connects the inspector and cards to your backend. Every member is optional; the inspector shows the tabs it can fill.

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

declare const backend: {
	comments(id: string): Promise<{ id: string; author: { id: string; name: string }; body: string; createdAt: number }[]>;
	addComment(id: string, body: string): Promise<void>;
};

const collaboration: RecordCollaboration = {
	me: { id: 'u1', name: 'Ava Chen' },
	listComments: (id) => backend.comments(id),
	addComment: (id, body) => backend.addComment(id, body),
};

Edits arriving from collaborators should be written with origin: 'remote': they apply, notify and redraw like any other change, but stay out of this user's undo history.

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

declare const api: GridApi<{ id: string; status: string }>;
api.transaction({ cells: [{ rowId: 'T-1172', colField: 'status', value: 'review' }], origin: 'remote' });

api.setPresence() peers show on the records their cursors are on, in every view: cards, schedule rows and calendar entries.

A remote change is credited to the teammate whose cursor is on that record. The record flashes in their colour, and the changed event carries origin: 'remote' for your own listeners. The inspector's Activity tab lists each change with who made it. Restore puts an old value back as one undoable step.

If someone changes a field while you are editing it in the inspector, you see their value and who changed it. Keep mine saves yours over it; Take theirs closes the editor and keeps their value.

A place is a view, a record, a group and a day — { view: 'kanban', record: 'T-1065', group: 'review', date: '2026-10-12' }, every part optional, made to round-trip through a URL. location opens the workspace there, onNavigate tells the app where the user goes (settled, only on change), and onReady hands over the workspace to go to places later (navigate) and run commands (runCommand).

import type { GridWorkspaceOptions, WorkspaceController, WorkspaceLocation } from '@witbybit/wit-grid-react';

let workspace: WorkspaceController | null = null;
const place: WorkspaceLocation = Object.fromEntries(new URLSearchParams(location.search)); // ?view=kanban&record=T-1065

const options: GridWorkspaceOptions = {
	location: place,
	onNavigate: (where) => history.replaceState(null, '', `?${new URLSearchParams(where as Record<string, string>)}`),
	onReady: (controller) => (workspace = controller),
};

view is a tab id (table, kanban, your tabs' ids, a saved view's saved:<id>); a group is a board column or gallery section (by value or label) or a Gantt summary task; a day sets the calendar's month or week and the Gantt's centre, and both report theirs as you move.

A record's page

A view can be one record's page: an order's form, a case's investigation. tab: false keeps it out of the view switcher (it is reached from a record, not chosen from a list), and views.open(id, { record }) opens it for that record. The workspace keeps the way back — the view it was opened from and the record focused there — and views.back() returns to it (false when there is none: a page reached by a link). A page opened from a page keeps the first way back; an ordinary open ends it.

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

interface SalesOrder {
	id: string;
	customer: string;
}

const views: GridViewDefinition[] = [{ id: 'order', name: 'Order', presentation: { type: 'order-page' }, tab: false }];

const orderPage = defineViewType<SalesOrder>({
	id: 'order-page',
	label: 'Order',
	create: (host, view) => ({
		// The record it was opened for; Back returns to where that was.
		render: () => void (host.textContent = `Order ${view.record()}`),
		// It places one record, its own: select-all, print and keys mean that order here.
		order: () => (view.record() ? [view.record()!] : []),
		reveal: (id) => (id === view.record() ? host : null),
		destroy: () => host.replaceChildren(),
	}),
});

async function openOrder(api: GridApi<SalesOrder>, id: string) {
	await api.views.open('order', { record: id });
	api.getStatus().views; // { activeId: 'order', record: id, canGoBack: true, … }
}

On the page the record is focused for the workspace without opening the inspector (the page is its surface), and its place is { view: 'order', record }, so a link opens the page for that record.

app carries your app's own place (a workflow stage, the task that led here) through navigate and onNavigate as given: { view: 'list', record: 'PO-1', app: { stage: 'review', task: 't9' } }. The workspace never reads it.

Your own kind of view

A view is a different job over the same records, not a filter of them: a dock schedule, a task inbox, a stage board. defineViewType makes one; workspace.viewTypes gives it to the workspace, and a tab draws with it through its presentation, { type, settings }, like any other type.

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

interface Task {
	id: string;
	title: string;
}

const taskList = defineViewType<Task>({
	id: 'task-list',
	label: 'Task list',
	icon: 'list',
	create: (host, view) => {
		const render = () =>
			host.replaceChildren(
				...view.rows().map((row) => {
					const button = document.createElement('button');
					button.dataset.recordId = row.id;
					button.textContent = view.reader().title(row);
					button.onclick = () => view.focus(row.id, { inspect: true });
					return button;
				})
			);
		return {
			render,
			destroy: () => host.replaceChildren(),
			order: () => view.rows().map((row) => row.id),
			reveal: (id) => host.querySelector<HTMLElement>(`[data-record-id="${id}"]`),
		};
	},
});

// workspace: { viewTypes: [taskList] }, views: { defined: [{ id: 'list', name: 'My list', presentation: { type: 'task-list' } }] }

It is given the records after the filters and the search (rows()), the reader, the selection and focus, the inspector, write (the person's change: rules, undo and saving as any edit), its own JSON settings() and updateSettings (a change to the open view, saved with it), fieldValue(field) (a value drawn as its column draws it), menus and toasts. It answers render, order (keyboard movement, range selection and select-all follow it) and reveal; optionally neighbor (arrow keys over a spatial layout), update (its settings changed), controls (its own bar controls) and commands (its palette commands). The tab, the bar, the filters, the inspector, the bulk bar, the palette, the views and links are the workspace's, as for its own views.

What a view type can count on

Its settings are its type's. settings: { defaults, version, migrate } gives every view of the type its defaults under what was saved; settings saved at an older version go through migrate when the view opens (Wit keeps the version beside them as $version, its one reserved key — it never reads any other setting by name: a type may call its settings columns, rows or groups). defineViewType<Row, Settings> types settings() and updateSettings.

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

interface Task {
	id: string;
	due: string | null;
}

const schedule = defineViewType<Task, { dateField: string | null; lanes: string[] }>({
	id: 'schedule',
	label: 'Schedule',
	settings: {
		version: 2,
		defaults: { dateField: null, lanes: [] },
		// Version 1 kept the lanes as one comma-separated string.
		migrate: (old, version) =>
			({
				...old,
				lanes: String(old.lanes ?? '')
					.split(',')
					.filter(Boolean),
			}) as never,
	},
	requires: [{ setting: 'dateField', kinds: ['date', 'dateRange'], label: 'a date field' }],
	// Drawn as in the example above; the settings and requirements are what this one adds.
	create: (host, view) => ({
		render: () => {},
		order: () => view.rows().map((row) => row.id),
		reveal: () => null,
		destroy: () => host.replaceChildren(),
	}),
});

What it needs is said. requires names the fields a view needs through its settings. Until each names a field of a kind that serves, the workspace shows "Schedule needs a date field" with a list of the fields that would serve, and creates the view once one is chosen — configuration is never dropped silently, and the view never draws from a field that cannot mean what it needs.

A write's outcome is whole. write(cells) answers { status: 'applied' | 'refused' | 'pending', rejected, settled }: a write waiting on an async setter or rule is pending (show it so on the item), and settled resolves to how it ended; a refusal names each cell and its reason (and is told to the person, as any refused edit is). settled is never delivered after the view is destroyed; the write itself still lands — it is the record's, not the view's.

It owns nothing past its life. view.signal aborts when the view is destroyed (hand it to fetch, addEventListener(…, { signal }) and timers), and view.own(cleanup) runs then too.

React draws it as well as the DOM does. view.mount(content) draws a DOM node, or the adapter's content (a React element), into the view's host: drawn again in place on each call, so a component keeps its state across renders, and taken down with the view. A React view type is a create whose render mounts its component:

import { defineViewType, type GridViewTypeContext } from '@witbybit/wit-grid-react';

interface Task {
	id: string;
	due: string | null;
}
// Your component, and your own moves for the keys.
declare function Schedule(props: { view: GridViewTypeContext<Task> }): JSX.Element;
declare function move(id: string, key: string): void;

const schedule = defineViewType<Task>({
	id: 'schedule',
	label: 'Schedule',
	create: (host, view) => ({
		render: () => view.mount(<Schedule view={view} />),
		destroy: () => {},
		order: () => view.rows().map((row) => row.id),
		reveal: (id) => host.querySelector(`[data-record-id="${id}"]`),
		// Alt+arrows on a focused record: the view's own moves.
		onKey: (event, id) => (event.altKey ? (move(id, event.key), true) : false),
	}),
});

It styles from the workspace's marks, never bakes them in. The workspace marks every [data-record-id] element in a view with data-selected / aria-selected and data-focused (and its tab stop) as the selection and focus change, without redrawing it. og-view-card is the record-card style built on those marks, from the theme's tokens: hover, selected, focused, data-pending (a write waiting on its rule: dashed, with a spinner) and data-warning (refused or in conflict). onKey(event, id) hears a key on a focused record the workspace does not handle.

data-record-id names the workspace's records only. A view that draws things of its own (another collection's cards, a schedule's tasks) marks them its own way: the workspace leaves a click, a key or a menu on an id it does not hold to the view, and does not select or inspect it.

It works as the built-ins do. changes() says what changed since it last drew (the records that joined, left or changed, and which fields; null when anything may have, as on the first draw after it opens); position(id), canEdit(id, field), memory(key, initial) / remember(key, value) (its scroll or day, kept while the person is elsewhere), reportPlace (for links), accent(row), refreshControls(), confirm(dialog) and openInTable(id). A view that counts its records itself (a server's pivot) returns { count, complete } from count(), and the bar says whether that is every record. A view's writes carry its type as their place (GridChanges.place), so an app's activity log can say "moved on the board". completeness() says whether rows() is every matching record or only those loaded (a server-side grid); a view that totals asks serverPivot() for the server's own answer over every matching record (null when the records are all here, or the datasource has no getPivot), as the pivot view does.

The inspector, laid out by you

inspector.layout lays the record's fields out as you choose: fields, and sections ({ id, title, fields, columns, when }), a section with when shown only for a record it applies to, as proposed (a draft's values in). A collection field that names its rows' columns is drawn as a grid of them bound to the record or its draft. The same layout mounts anywhere with createRecordForm (React: <RecordForm>); see Collections and entries.

A field that does not apply to a record — canShow on the grid or a column — is left out of its inspector and entries: canShow: ({ row, colField }) => colField !== 'freightReason' || row?.freightOverride != null.

Your own header

Keep your app's header and let the workspace follow it. Filters, search, sort and views are the grid's state and views (api.setState, api.views.open…); what the workspace adds, onReady's controller tells and drives:

  • state() — its tabs and the active one, the record in the inspector, and the counts the bar shows (with whether they count every record or only those loaded); the same object until it changes;
  • subscribe(listener) — hears it change;
  • part(id) — one of the bar's own parts, live (search, filter, sort, group, tabs…), to put in your header; leave it out of the bar with compose.bar.

The workspace's services, for your own content

Your buttons, dialogs and panels — inside the grid or beside it — use the workspace's own feedback through the same controller, not only inside composers:

  • toast(message, tone?, action?) — the workspace's toast (action: a button on it, such as Undo);
  • announce(message) — read to screen readers through its live region;
  • confirm({ title, body, confirm, cancel?, alternate? }) — its own dialog; answers 'confirm', 'alternate' or 'cancel';
  • busy(work, label?) — shows the workspace busy (and announces the label) while the promise runs; answers what the work answers, its failure included.

In React, useWorkspace() is the controller (null until the workspace mounts), inside a <Grid>, beside one in a <GridScope>, or given the grid's api:

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

function SendButton() {
	const workspace = useWorkspace();
	return <button onClick={() => workspace?.toast('Sent to the supplier', 'success')}>Send</button>;
}

Your dialogs in the workspace's theme

Content drawn outside the workspace (a dialog, a portal, a popover) wears its theme through workspace.theme: bind(element) gives the element the grid's theme variables and the workspace's tokens — --og-ws-raised, --og-ws-surface, --og-ws-text, --og-ws-muted, --og-ws-line, --og-ws-accent — and keeps them current while a person switches theme; tokens() and subscribe() read and follow the theme itself. In React, wrap the content in <WorkspaceTheme>:

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

function SupplierDialog({ api }: { api: GridApi<Order> }) {
	return (
		<WorkspaceTheme api={api} role='dialog' style={{ background: 'var(--og-ws-raised)', color: 'var(--og-ws-text)' }}>
			<p>Send this order to the supplier?</p>
		</WorkspaceTheme>
	);
}

Never read the workspace's DOM or its computed styles for this: the binding follows a theme switch while the dialog is open, a copy does not.

Make it yours

The views are built in, but what they show is yours to decide. Most of it is plain data, so saved views, shared layouts and the Customize panel keep it; where code is unavoidable (drawing your own content), it lives on the workspace options and never in saved state. The Revenue CRM on the home page is the roadmap's grid configured this way.

Summary numbers

Without a summary, each view shows its own numbers, chosen from the roles the records have: a board shows its total and completion, and adds active and blocked records only when the records have progress, dates or dependencies to say so; a gallery shows average progress only when there is a progress field. A schedule, a pivot and a chart add the numbers only they know (the project span, a grand total, the largest group).

A view's summary declares the numbers above it, in place of the view's defaults; false hides the strip. Each number is an aggregate of a field over the displayed records, optionally only those matching the grid's own filter conditions (where): count, sum, avg, min, max, filled and empty, and, by the records' roles, done, progress (average completion), overdue (not done and past due), active (started, not done, not blocked) and blocked. With of, it is a ratio of two aggregates, shown as a percentage with a meter. Formats: number, currency (large amounts written short, $4.2M), percent and compact. tone colours the card's dot and warn colours it while the value is above zero (overdue, blocked); id names the card, to keep or replace it in the command bar's sections.

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

const won = { field: 'stage', filter: { type: 'select' as const, values: ['won'] } };
const summary: ViewMetric[] = [
	{
		label: 'Open pipeline',
		fn: 'sum',
		field: 'amount',
		where: [{ field: 'stage', filter: { type: 'select', values: ['lead', 'demo'] } }],
		format: 'currency',
	},
	{
		label: 'Won · last 90 days',
		fn: 'sum',
		field: 'amount',
		format: 'currency',
		where: [won, { field: 'closeDate', filter: { type: 'date', operator: 'inLast', amount: 90, unit: 'day', dateFrom: '' } }],
	},
	{
		label: 'Win rate',
		fn: 'count',
		where: [won],
		of: { fn: 'count', where: [{ field: 'stage', filter: { type: 'select', values: ['won', 'lost'] } }] },
	},
];

The numbers follow filters, search and edits, and count up to their new values. The strip is the command bar's summary section, there in every view and the table (empty where there are no numbers) unless the view or the person hides it. To show the same numbers everywhere a view declares none of its own, fill it in compose.bar:

import type { GridWorkspaceOptions, ViewMetric } from '@witbybit/wit-grid-react';

declare const pipeline: ViewMetric[];

const workspace: GridWorkspaceOptions = {
	compose: {
		bar: (sections, { presentation }) =>
			sections.map((section) => (section.id === 'summary' && !presentation.settings?.summary ? { ...section, metrics: pipeline } : section)),
	},
};

A number no aggregate can say is a ComputedMetric: an id and a compute() that returns the card (label, value, and optionally meter, tone, title), asked again on each refresh.

Parts

A view's show turns its parts on and off, with the same names in every view: summary, toolbar (the view's own controls), quickFilters, totals (column and section totals), quickAdd, keys (record keys on cards), presence (teammates' dots) and legend (the calendar's). People toggle them too, in Customize → Show, and the choice is kept with the view.

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

const pipeline = kanbanView({ show: { keys: false, quickAdd: false } });

To leave a part of the command bar out for the whole workspace (the palette button, Export, search), compose it away: see Composing the command bar.

Composing the command bar

The command bar is a list of sections, and its own parts are sections too. compose.bar gets the defaults for the active view and returns the ones to show, in order: keep, drop or move any of them, change a default's metrics or controls, or add your own. It is asked again whenever the bar refreshes, so it can follow the view (context.presentation).

import type { GridWorkspaceOptions, WorkspaceSection } from '@witbybit/wit-grid-react';

interface Invoice {
	id: string;
	status: string;
	amount: number;
}

const queue: WorkspaceSection<Invoice> = {
	id: 'queue',
	row: 'summary',
	metrics: [
		{
			id: 'awaiting',
			label: 'Awaiting approval',
			fn: 'sum',
			field: 'amount',
			format: 'currency',
			where: [{ field: 'status', filter: { type: 'select', values: ['review'] } }],
		},
		{ id: 'overdue', label: 'Overdue', fn: 'overdue', tone: '#22c55e', warn: '#ef4444' },
	],
};

const workspace: GridWorkspaceOptions<Invoice> = {
	compose: {
		bar: (defaults) => [
			// The built-in parts, without Export and the default numbers.
			...defaults.filter((section) => section.id !== 'export' && section.id !== 'summary'),
			queue,
			// A toolbar control only on the board.
			{ id: 'density', row: 'toolbar', views: ['kanban'], controls: [{ id: 'compact', label: 'Compact', run: () => {} }] },
		],
	},
};

A section sits in a row (header, toolbar, filters or summary), on its align side (start or end), optionally only in some views ('table' for the table). It holds one kind of content:

ContentWhat it draws
metricsSummary cards: declared numbers and computed ones, drawn like the built-in strip
controlsButtons, toggles, menus: WorkspaceControls, drawn like every view's own controls
renderYour own content, drawn again on each refresh: a DOM node (the same one keeps it), text, or a React element

The defaults, in order, are the bar's own parts (title, tabs, newView, search, presence, history (undo and redo), inspector, palette, count, filter, sort, group, fields, customize, export, quickFilters, marked builtIn: they can be kept, moved or left out, not made) and three declared ones: viewControls (the view's own controls), actions (the commands that act on every matching record) and summary (the view's numbers).

A control is declared: label (or a function of the state, such as Lanes: Team), icon, title, pressed() for a toggle, menu() for a list of choices (checked marks the current one), run(anchor) to do something (or open a popover of your own at the button), disabled(), hidden(), divider, chevron, and variant (label, primary, danger). Every view's own controls are declared the same way. After a press, the whole bar reads its sections again.

A section draws with a context: everything a composer is told (api, view, selected(), recordData(id), navigate, toast…), plus records() (the view's records), reader(), complete() (whether they are every matching record) and refresh(). In React, a component section follows the records as the built-in parts do:

import type { GridWorkspaceOptions, WorkspaceSectionContext } from '@witbybit/wit-grid-react';

interface Invoice {
	id: string;
	amount: number;
}

function Total({ context }: { context: WorkspaceSectionContext<Invoice> }) {
	const total = context.records().reduce((sum, row) => sum + row.data.amount, 0);
	return <strong>{total.toLocaleString()}</strong>;
}

const workspace: GridWorkspaceOptions<Invoice> = {
	compose: { bar: (defaults) => [...defaults, { id: 'total', row: 'summary', render: (context) => <Total context={context} /> }] },
};

Every menu the workspace opens is composed by compose.menu, told apart by context.menu. A record's menu (menu: 'record', right-click or its … button) gets its default entries (open, showInTable, your commands that act on the selection, by their ids, duplicate, delete, and separators) with the records it acts on. Entries are GridMenuItems (id, label, icon, hint, title, checked, disabled, danger, color, run) or 'separator'; separators left leading, trailing or doubled are dropped.

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

const workspace: GridWorkspaceOptions = {
	// Records are voided through the app's own action, never deleted from the menu.
	compose: {
		menu: (entries, context) => {
			if (context.menu !== 'record') return entries;
			const ids = context.ids;
			return [
				...entries.filter((entry) => entry === 'separator' || entry.id !== 'delete'),
				'separator',
				{ id: 'copy-ids', label: `Copy ${ids.length} id(s)`, icon: 'copy', run: () => void navigator.clipboard.writeText(ids.join(', ')) },
			];
		},
	},
};

The bar over a multiple selection is composed too: compose.bulkBar gets its default buttons as declared controls (set:<field> for Move to, Assign, Priority and Team, dates, your commands that act on the selection, by their ids, duplicate, delete) with the selected ids, and returns the ones to show.

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

declare function flag(ids: readonly string[]): void;

const workspace: GridWorkspaceOptions = {
	compose: {
		bulkBar: (defaults, { ids }) => [
			...defaults.filter((control) => control.id !== 'delete' && control.id !== 'duplicate'),
			{ id: 'flag', label: 'Flag', icon: 'flag', run: () => flag(ids) },
		],
	},
};

The approval desk on the home page is the workspace composed this way: its numbers, its aging strip and its "go to" are sections beside the built-in ones.

The inspector's tabs, the command palette and the Customize panel are composed the same way. Every part, its ids and its context are in Composing.

Your content in the views

Board and gallery cards, and their column and section headers, are composed like the bar: you get their parts by id and return the ones to show, in order, with your own among them. Both are composed once per view, not per card.

  • compose.card gets a card's parts below its title: badges, meta, progress, details and foot (a compact board card has meta and details). Yours is a line with a fixed height (default 26px, as the views lay cards out at a fixed pitch) and a render(row, reader): the next step, an SLA timer. A part you drop is gone from every card, and the cards get shorter.
  • compose.groupHeader gets a header's parts: total on a board; count, total and progress in a gallery. The header's own controls (collapse, menu, add, select) stay. Yours has a render(group, reader), which gets the group's field, value, label and records: a weighted value, a conversion rate.

What the person turned off in Customize (a card's meta line, the totals) stays off: compose works on what the view shows.

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

interface Deal {
	amount: number;
	probability: number;
	nextStep: string;
}

const workspace: GridWorkspaceOptions<Deal> = {
	compose: {
		card: (parts) => [...parts, { id: 'nextStep', height: 24, render: (row) => (row.data.nextStep ? `→ ${row.data.nextStep}` : null) }],
		groupHeader: (parts) => [
			...parts,
			{
				id: 'weighted',
				render: (group) =>
					`≈ ${group.rows.reduce((sum, row) => sum + (row.data.amount * row.data.probability) / 100, 0).toFixed(0)} weighted`,
			},
		],
	},
};

A card field draws with one of the grid's displays when it names one ({ field: 'health', display: 'health' }, plain JSON, so saved views keep it), or when its column does. Customize's card editor offers each display for the fields it appliesTo. A display's render is told the surface it draws on (cell, card or inspector), so one definition can size itself for each.

Board cards size themselves to what you draw on them: the board measures real cards once per card layout.

Every workspace icon — quick filters, bar buttons, commands, menu items — takes a built-in name or your own SVG: a whole <svg>, or shapes for a 24×24 box ('<path d="M4 12h16"/>') drawn in the text colour.

For status-board tables, selectType(options, { variant: 'fill' }) fills the whole cell with the option's colour.

Your own additions

Your additions are composers, like every change to a built-in part, and a package of them is a plain compose object: write one per concern, test it on its own, and share it between apps. combineCompose(a, b, c) makes several into the one compose the workspace takes: each part runs through them in order, so a later one sees (and can change) what an earlier one added. A package that belongs to some views only checks context.presentation. Every composer is told the grid's api, the active view's presentation, selected(), focused(), recordData(id), navigate, updateSettings and toast, plus what its part is about (the selected ids in the bulk bar, the record in the inspector).

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

declare let onlyMine: boolean;
declare let strict: boolean;
declare function toggleMine(api: GridApi<unknown>): void;
declare function exportRecords(ids: readonly string[]): void;
declare function renderAudit(container: HTMLElement, id: string): void;
declare function send(ids: readonly string[]): void;

/** The review package: its button on the board and in the table, the rest everywhere. */
const review: WorkspaceCompose = {
	bar: (sections, ctx) =>
		ctx.presentation.type !== 'table' && ctx.presentation.type !== 'kanban'
			? sections
			: [
					...sections,
					{
						id: 'review',
						row: 'toolbar',
						controls: [{ id: 'mine', label: 'Mine', icon: 'user', pressed: () => onlyMine, run: () => toggleMine(ctx.api) }],
					},
				],
	commands: (commands) => [
		...commands,
		{ id: 'review:export', label: 'Export for review', group: 'Review', records: 'selection', run: ({ ids }) => exportRecords(ids) },
	],
	inspector: (tabs, { record }) => [...tabs, { id: 'audit', label: 'Audit', render: (el) => renderAudit(el, record) }],
	bulkBar: (controls, { ids }) => [
		...controls,
		{ id: 'review:export', command: 'review:export' },
		{ id: 'review:send', label: 'Send for review', run: () => send(ids) },
	],
	customize: (sections) => [
		...sections,
		{
			id: 'review',
			title: 'Review',
			settings: [{ kind: 'toggle', id: 'strict', label: 'Strict', value: strict, onChange: (value) => (strict = value) }],
		},
	],
};

const workspace: GridWorkspaceOptions = {
	// The review package, then the app's own composers, which see what it added.
	compose: combineCompose(review, { inspector: (tabs) => [...tabs].reverse() }),
};

A control that names a command ({ id, command }) takes its label, icon and availability from it, and the same command is in the palette (it has a group) and on its keys. A control with its own run is the bar's alone.

Keys and theme

Every key is a command's: composing a command's keys moves it, and dropping the command turns it off. { ...command, keys: ['shift+g'] } on view:gantt opens the Gantt view on Shift+G, and the palette shows it. The workspace's own: palette (mod+k), search (/), inspector:toggle (i), record:inspect (Enter) and records:delete (Delete), with the grid's undo, redo and selectAll. See Commands.

The workspace wears the grid's theme: its accent is the theme's focusRing, its background, text and lines come from bgColor, textColor and borderColor, and its corners and card shadow from radius and shadow. Theme the grid (the theme option: api.setOptions({ theme })) and the workspace follows.

Export in the command bar (and export:print, export:xlsx and export:csv in the palette) prints or downloads the records in the view's own order: column by column on a board, outline order in a Gantt. With a selection, it can cover only the selected records.

Print opens a preview laid out for paper, then the browser's print dialog, where "Save as PDF" makes a PDF.

  • A board prints as sections of cards, one per column.
  • A gallery prints as cards, with covers.
  • A calendar prints as its month sheet, each record on the days it covers.
  • A Gantt prints its outline, dates and bars on one scale fitted to the page.
  • The table prints as rows.

Every record is drawn (nothing is virtualized), up to 2,000 per print. Headers repeat on each page. The grid's capabilities.canExport decides whether it is offered.

Who can change a view

The canManageView capability (asked with params.view) says who may change a saved view's shared layout: save changes to it, rename, delete, and lock or unlock it. Everyone may by default. A manager can Lock layout from the tab's menu; the view's locked setting is saved with it.

Someone who may not change the shared layout (or anyone, while it is locked) can still hide fields, change the density or zoom, and so on. Those changes are their own draft of the view, kept in their record (storage.layout, for the session without one). It comes back each time they open the view, and Use shared layout discards it.

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

declare const currentUser: { isAdmin: boolean };

export const capabilities: GridCapabilitiesConfig = {
	canManageView: ({ view }) => view?.scope === 'personal' || currentUser.isAdmin,
};

Headless

The projections are plain functions in the core, usable without the workspace (custom views, servers, tests): resolveRecordRoles and RecordReader, groupRecords / aggregateRecords, buildBoard / planBoardMove, rankBetween, ScheduleModel (outline, rollups, violations, slack, critical path, autoSchedule), WorkCalendar, TimeScale and resourceLoad.

import { RecordReader, resolveRecordRoles, ScheduleModel, WorkCalendar, type RecordRow } from '@witbybit/wit-grid-react';

declare const columns: import('@witbybit/wit-grid-react').ColumnDef<unknown>[];
declare const rows: RecordRow<unknown>[];

const reader = new RecordReader(columns, resolveRecordRoles(columns));
const schedule = new ScheduleModel(rows, reader, new WorkCalendar());
const late = schedule.violations();
const { changes } = schedule.autoSchedule({ mode: 'push' });
Edit on GitHub

Last updated on

On this page