Skip to main content

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'
Import table types from wayd-grid-core

TanStack 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.

Always use WaydGrid

Never 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.

simpleadvanced (default)
ToolbarHiddenSearch, row count, refresh, clear, export, help
Column filtersOffPopup + floating filter row
Column menu (sort/pin/autosize)OnOn
HeightFits its rowsFills the remaining viewport
Layout persistenceDon't set persistStateKeyOpt-in via persistStateKey
Row virtualizationOnOn
Use forA record's own data, under ~25 rowsList 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".

CapabilityDefaultHow to change it
Global quick-search boxOnincludeGlobalSearch={false} to hide
CSV export buttonOnincludeExportButton={false} to hide; csvFileName="..." names the file
Per-column filters (popup)OnincludeColumnFilters={false} to disable all column filtering UI
Floating filter row (under headers)OnincludeFloatingFilters={false} (ignored when includeColumnFilters={false})
Column sizing (resize handles)OnPer column: enableResizing: false in the def
Column sortingOnPer column: enableSorting: false in the def
Column show/hide (Choose Columns)OnPer column: enableHiding: false; withhold reactively with meta.unavailable; start hidden with meta.hiddenByDefault
Column pinning (⋮ menu → Pin)OnPer column: enablePinning: false
Column reordering (drag headers)OnAuto-disabled on grouped-header grids; per column: meta.enableReordering: false
Column layout persistenceOffProvide persistStateKey="..." to turn it on
Row virtualizationOn (always)Not configurable — the visible window plus overscan is mounted
Initial sort on mountOffinitialSorting={[{ id: 'name', desc: false }]}
Toolbar refresh buttonOffProvide onRefresh={() => refetch()}
Hidden Id column (for import files)On when rows carry an idincludeIdColumn={false} when id is a client-side row key
Row activation (open a row)OffProvide onRowActivate
Row selectionOffProvide rowSelection + onRowSelectionChange
Row groupingOffProvide grouping
Tree modeOffProvide getSubRows
Row-reorder drag-and-drop (flat)OffProvide onRowReorder + getRowId, and render a DragHandleCell
Reparenting drag-and-drop (tree)OffProvide enableDragAndDrop + onNodeMove
Inline editingOffProvide 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)
/>
  • columns can be a function (context) => ColumnDef[] when the defs need editing/DnD/draft state (see Tree mode & editing).
  • height omitted → an advanced grid auto-sizes to fill the remaining viewport height (ag-grid style); a simple grid sizes to its rows. Pass a number for a fixed height.
  • leftSlot / rightSlot inject custom toolbar content; actionsSlot sits just before the export button, for a grid-specific toggle; helpContent fills a help popover. leftSlot may also be a function of the GridColumnContext, for a Create button that adds a draft row.
  • getRowId supplies a stable row id (required for onRowReorder); it falls back to row.original.id, then a TanStack index id.
  • onDisplayedRowsChange fires with the post-filter/sort rows whenever that set changes — for deriving external UI (e.g. a chart) from the grid.
  • ref exposes a WaydGridHandle: the TanStack table, the editing selectedRowId, and getDisplayedRows().
  • includeIdColumn (default true) appends a hidden Id column when the rows carry an id and no column claims it, so a user can show it through Choose Columns and export the ids an import file needs. Pass false when id is a client-side row key rather than a Wayd record id. Its column id is exported as ID_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 fieldPurpose
columnTypeDeclarative type preset: 'yesNo', 'dateOnly', 'dateTime' (see Column types)
unavailableWithholds 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
hiddenByDefaultStarts 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
filterTypeFilter/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)
filterEnableSetOn a text column, also offer the Excel-style checkbox set filter alongside the text filter
multiValueSplitMarks a column whose accessor joins several values; the set filter lists the individual values. createCsvColumn sets it (see Multi-value columns)
filterPlaceholderPlaceholder for text and number filter inputs
align'left' | 'right' — override numeric right-alignment of body cells
headerTooltipTooltip on the header label (keep header a plain string)
maxFilterConditionsCap AND/OR conditions in the filter popup (default 5)
enableReorderingSet 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
enableExportfalse excludes the column from CSV
exportHeader / exportFormatterOverride 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.

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 columns band) 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, provide onRowReorder (and getRowId) 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 DragHandleCell to give a row one, passing context.isDragEnabled (so it greys out when dragging is off) and a disabledTooltip naming the records in your own words. It goes in the actions column's leading slot. This needs the columns function form, since that is what receives the GridColumnContext.
    • onRowReorder hands you orderedData — 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.
  • Reparenting DnD (tree): enableDragAndDrop + onNodeMove; supply a moveValidator for domain rules and onMoveRejected to surface a reason.
  • Inline editing: pass an editingConfig (GridInlineEditingConfig). The columns function receives a GridColumnContext with 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; renderGroupHeader replaces it and receives the group's leafRows so 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; use caseInsensitiveCompare from the barrel. For a column's sortFn, the barrel also carries dateSortBy(getDate), workItemKeySort (WEB-9 before WEB-10), and workStatusCategorySort (workflow order); sortEmptyLast is 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 in initialSorting.
  • 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 in types.ts, component in wayd-grid.tsx).
  • Shared grid engine (filters, column menu, pinning, persistence, DnD): components/common/wayd-grid-core/.
  • The barrel @/src/components/common/wayd-grid is the import surface for the component, its types, and its helpers; TanStack table types (ColumnDef, Row) come from @/src/components/common/wayd-grid-core.