WaydGrid
WaydGrid is Wayd's house data-grid component, built on TanStack Table. It's the single grid used across the app — flat lists, grouped headers, and hierarchical (tree) data — with a consistent toolbar, Excel-style column filters, sorting, column sizing/visibility/pinning/reordering, always-on row virtualization, CSV export, and (opt-in) inline editing and drag-and-drop.
Import it from the barrel:
import { WaydGrid, createActionsColumn } from '@/src/components/common/wayd-grid'
import type { ColumnDef } from '@/src/components/common/wayd-grid-core'
wayd-grid-coreTanStack Table v9 puts a features generic first on every public type (ColumnDef<TFeatures, TData, TValue>), so a ColumnDef<Widget> imported from @tanstack/react-table binds the row type to the wrong slot and fails to compile. @/src/components/common/wayd-grid-core re-exports ColumnDef, Row, Column, Cell, Header, Table and the rest with the grid's feature set already bound — import them from there.
WaydGridNever reach for Ant Design's Table or raw TanStack Table directly. WaydGrid is the seam that keeps grids consistent and lets us swap the engine once. Columns are plain TanStack ColumnDefs, extended with a small meta object (see Column meta).
Minimal example
A read-only grid needs only columns and data:
const columns: ColumnDef<Widget, any>[] = [
{ id: 'name', accessorKey: 'name', header: 'Name', size: 250 },
{ id: 'status', accessorKey: 'status', header: 'Status' },
]
<WaydGrid columns={columns} data={widgets} isLoading={isLoading} />
data may be undefined while a query is loading — the grid treats it as empty (no crash). Provide isLoading to show the spinner.
Variants
Start by picking a variant — it presets the toolbar and filtering flags below for the two situations grids are actually used in.
simple | advanced (default) | |
|---|---|---|
| Toolbar | Hidden | Search, row count, refresh, clear, export, help |
| Column filters | Off | Popup + floating filter row |
| Column menu (sort/pin/autosize) | On | On |
| Height | Fits its rows | Fills the remaining viewport |
| Layout persistence | Don't set persistStateKey | Opt-in via persistStateKey |
| Row virtualization | On | On |
| Use for | A record's own data, under ~25 rows | List pages and exploration |
Choose by the question the user is asking. "What is on this record?" → simple. "Which of these do I want?" → advanced.
<WaydGrid variant="simple" columns={columns} data={projects} />
Individual flags still override the variant, so variant="simple" plus includeColumnFilters is valid for the rare section that needs one thing back. A simple grid never renders the toolbar, so the toolbar's own props — includeGlobalSearch, includeExportButton, onRefresh, leftSlot, rightSlot, actionsSlot, helpContent — have no effect on it. Prefer the plain variant where you can — a section grid carrying a full toolbar competes with the page's own chrome.
See Record Pages for how this fits the record page structure.
On small screens
The grid has no breakpoint behaviour of its own: both variants render the same table at every width, and a table wider than its container scrolls horizontally inside the grid's body.
Two traps either way: a wide grid must scroll inside its own container, never the page body; and the grid needs a measurable height — it renders zero rows in an unconstrained <Flex vertical> parent. Keep it a block-level child, as src/app/account/profile/claims-grid.tsx does.
Feature flags at a glance
Most functionality is on by default; you turn things off with a prop, or turn opt-in behaviour on by supplying its handler. This table is the quick reference for "how do I enable/disable X".
Where a variant sets a flag, that is the starting point — the defaults below describe variant="advanced".
| Capability | Default | How to change it |
|---|---|---|
| Global quick-search box | On | includeGlobalSearch={false} to hide |
| CSV export button | On | includeExportButton={false} to hide; csvFileName="..." names the file |
| Per-column filters (popup) | On | includeColumnFilters={false} to disable all column filtering UI |
| Floating filter row (under headers) | On | includeFloatingFilters={false} (ignored when includeColumnFilters={false}) |
| Column sizing (resize handles) | On | Per column: enableResizing: false in the def |
| Column sorting | On | Per column: enableSorting: false in the def |
| Column show/hide (Choose Columns) | On | Per column: enableHiding: false; withhold reactively with meta.unavailable; start hidden with meta.hiddenByDefault |
| Column pinning (⋮ menu → Pin) | On | Per column: enablePinning: false |
| Column reordering (drag headers) | On | Auto-disabled on grouped-header grids; per column: meta.enableReordering: false |
| Column layout persistence | Off | Provide persistStateKey="..." to turn it on |
| Row virtualization | On (always) | Not configurable — the visible window plus overscan is mounted |
| Initial sort on mount | Off | initialSorting={[{ id: 'name', desc: false }]} |
| Toolbar refresh button | Off | Provide onRefresh={() => refetch()} |
Hidden Id column (for import files) | On when rows carry an id | includeIdColumn={false} when id is a client-side row key |
| Row activation (open a row) | Off | Provide onRowActivate |
| Row selection | Off | Provide rowSelection + onRowSelectionChange |
| Row grouping | Off | Provide grouping |
| Tree mode | Off | Provide getSubRows |
| Row-reorder drag-and-drop (flat) | Off | Provide onRowReorder + getRowId, and render a DragHandleCell |
| Reparenting drag-and-drop (tree) | Off | Provide enableDragAndDrop + onNodeMove |
| Inline editing | Off | Provide editingConfig |
Core props
<WaydGrid<Widget>
columns={columns} // ColumnDef[] or (ctx) => ColumnDef[]
data={widgets} // Widget[] | undefined
isLoading={isLoading}
onRefresh={() => refetch()} // shows the toolbar refresh button
emptyMessage="No widgets found" // default 'No records found'
csvFileName="widgets" // CSV download name prefix; default 'grid-export'
persistStateKey="ppm-widgets" // opt-in column persistence (see below)
initialSorting={[{ id: 'name', desc: false }]}
height={480} // fixed px; omit to auto-size (see below)
rightSlot={<MyToolbarControls />} // custom toolbar content (far right)
leftSlot={<CreateButton />} // custom toolbar content (left)
/>
columnscan be a function(context) => ColumnDef[]when the defs need editing/DnD/draft state (see Tree mode & editing).heightomitted → anadvancedgrid auto-sizes to fill the remaining viewport height (ag-grid style); asimplegrid sizes to its rows. Pass a number for a fixed height.leftSlot/rightSlotinject custom toolbar content;actionsSlotsits just before the export button, for a grid-specific toggle;helpContentfills a help popover.leftSlotmay also be a function of theGridColumnContext, for a Create button that adds a draft row.getRowIdsupplies a stable row id (required foronRowReorder); it falls back torow.original.id, then a TanStack index id.onDisplayedRowsChangefires with the post-filter/sort rows whenever that set changes — for deriving external UI (e.g. a chart) from the grid.refexposes aWaydGridHandle: the TanStacktable, the editingselectedRowId, andgetDisplayedRows().includeIdColumn(defaulttrue) appends a hiddenIdcolumn when the rows carry anidand no column claims it, so a user can show it through Choose Columns and export the ids an import file needs. Passfalsewhenidis a client-side row key rather than a Wayd record id. Its column id is exported asID_COLUMN_ID.
Opening a row
onRowActivate makes whole rows openable — click, or Enter/Space on the focused row — so a list is keyboard-navigable without a link in every primary cell. Clicks on links, buttons, inputs, and anything marked data-row-activate="ignore" are left alone, so an actions dropdown inside the row still just opens. Pass activatedRowId (matched against getRowId) to highlight the open row, and getRowActivateLabel to give each row a short accessible name. Flat mode only: tree mode spends the row click on inline editing. A config list's panel is the reference consumer (see Record Pages).
Selecting rows
rowSelection and onRowSelectionChange are controlled TanStack row-selection state. Supplying them turns on row.getIsSelected(), row.toggleSelected(), and the table's select-all handlers, so a consumer renders its own checkbox column against the table rather than tracking selection itself. Select-all covers the filtered rows. getIsSomeRowsSelected() means "at least one", so an indeterminate header checkbox needs !getIsAllRowsSelected() && getIsSomeRowsSelected().
Columns
Columns are TanStack ColumnDefs. The most common shape:
{
id: 'status',
accessorKey: 'status', // supports dotted paths: 'status.name'
header: 'Status', // keep this a plain string — CSV export and the
// column chooser read it
size: 120, // px; also resizable at runtime
meta: { /* WaydGrid extras — see below */ },
}
Column meta
meta is TanStack's per-column bag, augmented with WaydGrid fields (WaydGridColumnMeta, strongly typed everywhere):
meta field | Purpose |
|---|---|
columnType | Declarative type preset: 'yesNo', 'dateOnly', 'dateTime' (see Column types) |
unavailable | Withholds the column from this user — typically !canUpdate. Hidden, absent from Choose Columns, and beats any saved choice, so a permission can't be worked around by unhiding. Reactive: flip the expression and the column returns with its size, pinning and order |
hiddenByDefault | Starts the column hidden but leaves it in Choose Columns, so the user owns it from there. A user's choice wins; Reset Columns returns it to hidden |
filterType | Filter/data type: 'text' | 'number' | 'date' | 'dateTime' | 'set' (aliases 'select'→set, 'numericRange'→number). Defaults to 'text' |
filterOptions | { label, value }[] for a set filter (stable order + labels) |
filterEnableSet | On a text column, also offer the Excel-style checkbox set filter alongside the text filter |
multiValueSplit | Marks a column whose accessor joins several values; the set filter lists the individual values. createCsvColumn sets it (see Multi-value columns) |
filterPlaceholder | Placeholder for text and number filter inputs |
align | 'left' | 'right' — override numeric right-alignment of body cells |
headerTooltip | Tooltip on the header label (keep header a plain string) |
maxFilterConditions | Cap AND/OR conditions in the filter popup (default 5) |
enableReordering | Set false so the column can't be dragged to reorder (and rejects drops). Such a column also gets no ⋮ menu. Used by the actions column |
enableExport | false excludes the column from CSV |
exportHeader / exportFormatter | Override the CSV header text / format each value |
Column types
Instead of hand-wiring cell formatting + filter config, declare a type:
{ id: 'active', accessorKey: 'isActive', header: 'Active', meta: { columnType: 'yesNo' } }
{ id: 'due', accessorKey: 'dueDate', header: 'Due', meta: { columnType: 'dateOnly' } }
{ id: 'created', accessorKey: 'createdOn', header: 'Created', meta: { columnType: 'dateTime' } }
yesNo— boolean → "Yes"/"No" for both display and the set filter.dateOnly/dateTime— formatted for display while the raw value drives chronological sort and the date filter.
Explicit column fields always win over a type's defaults. The formatters behind the date types are exported as formatDateOnly and formatDateTime, and the yes/no labels as YES and NO.
Multi-value columns
A column whose data is several values — a comma-separated string or an array — uses createCsvColumn. It renders each value as a tag (TagListCell), filters with a set panel that matches on individual values (a row matches if it shares any selected value), and builds that panel's options from the live data, so no option list is passed:
createCsvColumn<ProjectListDto>({
id: 'projectManagers',
header: 'PMs',
getValues: (row) => getSortedNameList(row.projectManagers ?? []),
})
Omit getValues for a plain comma-separated string column; the factory reads row[id] and splits it with splitCsv.
Link and tag cells
Cells that link to a record use the shared renderers from the barrel rather than hand-built links: renderTeamLink, renderProjectLink, renderPortfolioLink, renderProgramLink, renderPlanningIntervalLink, renderWorkspaceLink, renderProductLink, renderVersionLink, renderReleaseLink, renderSprintLink, renderUserLink, renderWorkItemLink, and renderAssignedToLink. renderWorkStatusTag and renderDependencyHealthTag render status tags. Each takes the domain object, not the cell context, and returns null for a missing value: cell: ({ row }) => renderTeamLink(row.original.team).
The actions column
Row action menus use the shared createActionsColumn helper — a fixed-width (ACTIONS_COLUMN_SIZE, 50px) ⋯ dropdown that renders nothing for rows with no items, always holds its position (never sorted, filtered, resized, or reordered), and hides itself when the user can't act on any row:
createActionsColumn<Widget>({
unavailable: !canManage, // withhold the column entirely when unusable
ariaLabel: 'Widget actions',
getItems: (widget) => [
canEdit && { key: 'edit', label: 'Edit', onClick: () => edit(widget) },
canDelete && { key: 'delete', label: 'Delete', danger: true, onClick: () => remove(widget) },
].filter(Boolean) as ItemType[],
})
id defaults to 'actions' and ariaLabel to 'Row actions'.
leading renders a node immediately before the ⋯ in the same cell — a row's grab handle, in
practice. Sharing the cell keeps a whole column's width from going to one icon and keeps everything
that acts on a row in one place. Widen the column with size when you pass one; see
Tree mode & editing for the handle itself.
Filtering, sorting & the column menu
These come for free — no wiring beyond the column defs:
- Global quick-search filters across every column with an accessor (opt a column out with
enableGlobalFilter: false). - Per-column filters — an Excel-style popup per column (text/number/date conditions, or a checkbox set list), plus a compact floating-filter row under the headers. The filter UI is inferred from the data or set explicitly via
meta.filterType. - Sorting — click a header to sort; Ctrl/⌘-click adds a column to a multi-sort.
- The
⋮column menu on each header offers: Sort, Pin Column (left/right/none), Autosize (this column / all columns), Choose Columns (when any column can be hidden), and Reset Columns.
Reset Columns restores the columns' default sizing, visibility, pinning, and order — it deliberately does not clear sorting or filters (the toolbar's Clear button does that).
Column reordering
Reordering is on by default. The whole header cell is the drag handle — grab a header and drag it left/right to reorder. A plain click still sorts (a drag only starts after a few pixels of movement); the resize edge, filter icon, and ⋮ menu don't trigger a drag. Columns can also be reordered by dragging rows in the Choose Columns modal.
Rules:
- Reordering happens within a section: unpinned columns reorder among themselves, and each pinned side (left/right) reorders within itself. Dragging never pins or unpins — that's the
⋮menu's job. - Grouped-header grids (defs with a
columnsband) disable reordering, so bands can't be split. - A column can opt out with
meta.enableReordering: false(the actions column does this automatically).
The order is persisted alongside the rest of the column layout when persistStateKey is set (see below).
Column layout persistence
Persistence is opt-in: give the grid a persistStateKey and it saves the user's column sizing, show/hide choices, pinning, and order to this browser's localStorage, restoring them on the next visit.
<WaydGrid columns={columns} data={data} persistStateKey="ppm-projects" />
- Sorting, filters, and search are not persisted — they're session-only.
- A pristine or reset grid stores nothing (the entry is removed), so Reset Columns also clears the saved layout.
- Persisted state that references removed columns is ignored; columns added later appear at their def position.
Choosing a key
persistStateKey is per page context, not per component — a stable, human-readable, route-independent kebab-case string, e.g. 'ppm-projects', 'settings-feature-flags', 'team-backlog'. Two grids that should share a layout use the same key; two that shouldn't must differ.
Shared grid components (used on more than one page) should expose persistStateKey as a pass-through prop so each page supplies its own key, rather than hardcoding one.
Stored under wayd-grid:{persistStateKey}:v2; a v1 entry is migrated on load. Give a visible column a plain-string header (or a meta.exportHeader) so it appears in the Choose Columns list — a column with neither is left out.
The device-wide controls
Two account-level controls (Account → Preferences → Grids) cover every persisted grid automatically — no per-grid wiring:
- Remember column layouts — the kill switch. When off, grids neither save nor restore layouts; existing saved layouts are kept and re-apply when it's turned back on.
- Reset all grid layouts — deletes every saved layout on this device.
These act on this browser only (layouts live in localStorage, not on the server). The barrel exports what the page uses: GRID_PERSISTENCE_ENABLED_KEY (the switch, absent meaning on) and clearAllGridColumnState(). A grid reads the switch when it mounts, so a change applies on the next visit to a grid.
Tree mode & editing
Providing getSubRows turns on tree mode: rows expand/collapse, and a filter match keeps the ancestor chain visible. Every node starts expanded; pass initialExpanded={false} for a list whose parents are rollups of their children. Layered on top of tree mode are optional reparenting drag-and-drop, inline editing, and draft rows.
<WaydGrid<WorkItemTreeNode>
data={rootNodes}
getSubRows={(node) => node.children}
columns={(ctx) => buildColumns(ctx)} // function form: reads editing/DnD state
enableDragAndDrop
onNodeMove={async (nodeId, parentId, order) => reparent(nodeId, parentId, order)}
moveValidator={myMoveValidator}
editingConfig={editingConfig}
/>
- Flat row reorder: instead of
getSubRows, provideonRowReorder(andgetRowId) for a draggable flat list. Dragging auto-disables while the grid is sorted, filtered, searched, loading, or editing — the displayed order isn't the data order then, and a drop would write a sequence the reader never saw.- The grid supplies the mechanics but not the grip: render the shared
DragHandleCellto give a row one, passingcontext.isDragEnabled(so it greys out when dragging is off) and adisabledTooltipnaming the records in your own words. It goes in the actions column'sleadingslot. This needs thecolumnsfunction form, since that is what receives theGridColumnContext. onRowReorderhands youorderedData— every displayed row in post-drop order — so send the whole list to a reorder endpoint rather than one row and a position. Positions are relative, so a partial update leaves the rows it omits overlapping the ones it moved.
- The grid supplies the mechanics but not the grip: render the shared
- Reparenting DnD (tree):
enableDragAndDrop+onNodeMove; supply amoveValidatorfor domain rules andonMoveRejectedto surface a reason. - Inline editing: pass an
editingConfig(GridInlineEditingConfig). Thecolumnsfunction receives aGridColumnContextwith the editing/DnD/draft state so cells can render editors and drag handles reactively.
These features are involved — read the JSDoc on WaydGridProps in types.ts and follow an existing consumer when wiring them — app/ppm/projects/_components/project-plan-table.tsx uses reparenting drag-and-drop, inline editing, and drafts; project-work-items-tree-grid.tsx beside it is a read-only tree.
Chart pane
rightPane (WaydGridRightPane) attaches a chart to the right of the grid that shares the grid's row geometry — one virtualizer and one vertical scroller, so a bar cannot drift from its row. The consumer supplies the pane header (a date axis, typically) and renderRow, which receives each row with its resolved top and height and returns that row's bars; renderBackground draws chart-wide layers such as gridlines. The grid knows nothing about dates. The roadmap items grid and the project plan table use it for their Gantt views.
Row grouping
Providing grouping (column ids, outermost first) groups a flat grid: each distinct value of the column becomes a collapsible heading row spanning the grid, with the matching rows underneath. The grouped column leaves the row cells, since its value is now the heading. Filters and quick search apply to the rows before they are grouped, so a group with no match disappears; sorting applies within each group, and the groups themselves come out in the grouped column's order (give that column a sortFn when its values have an order of their own, such as a health scale). The Projects Dashboard is the reference consumer.
<WaydGrid<ProjectListDto>
data={projects}
columns={columns}
grouping={['portfolio']}
renderGroupHeader={({ value, leafRows }) => `${value} · ${leafRows.length} projects`}
initialGroupsExpanded
/>
- The default heading is the value and a row count;
renderGroupHeaderreplaces it and receives the group'sleafRowsso the heading can say what they add up to. - Headings are not records: row activation, the displayed-rows surfaces (
onDisplayedRowsChange,getDisplayedRows) and the CSV export see only the rows. The grouped column leads the CSV, so each exported row keeps the value its heading carried on screen. Flat row reorder turns off while grouped, since the displayed order is no longer the data order. - Regrouping resets every group to
initialGroupsExpanded, so a grid regrouped by another column opens fully rather than inheriting collapsed ids from the previous grouping. - Grouping is for flat grids; a tree (
getSubRows) already has its hierarchy and ignores it.
CSV export
The toolbar export button downloads the currently visible columns (respecting hide/pinning/order) and the filtered + sorted rows, in display order. Grouped headers export as band rows above the leaf headers. Control it per column with meta.enableExport, meta.exportHeader, and meta.exportFormatter; name the file with the csvFileName prop (default grid-export), to which the download adds a timestamp.
Conventions
- Case-insensitive sorting — never plain-
.sort()user-facing lists; usecaseInsensitiveComparefrom the barrel. For a column'ssortFn, the barrel also carriesdateSortBy(getDate),workItemKeySort(WEB-9beforeWEB-10), andworkStatusCategorySort(workflow order);sortEmptyLastis every column's default. - Context-redundant columns (e.g. a Project column on a single project's page) — omit them from the defs conditionally rather than
meta.unavailable, so they stay out of the chooser and persisted layouts. Don't def-exclude a column named ininitialSorting. - Theme tokens only — the grid styles from Ant Design tokens; don't hardcode colors in cells.
Reference
- Public API & JSDoc:
Wayd.Web/src/wayd.web.reactclient/src/components/common/wayd-grid/(types intypes.ts, component inwayd-grid.tsx). - Shared grid engine (filters, column menu, pinning, persistence, DnD):
components/common/wayd-grid-core/. - The barrel
@/src/components/common/wayd-gridis the import surface for the component, its types, and its helpers; TanStack table types (ColumnDef,Row) come from@/src/components/common/wayd-grid-core.