The enterprise grid subsystem
Every data table in the app is built from
frontend/src/ui/ag-grid/ — an in-house layer
over AG Grid Community 33 that standardizes server-side paging, tabs, saved
views, bulk actions, and import/export across the app's table pages (as of this
writing, ServerDataGrid appears in ~38 pages/components, and 21 pages have
adopted the full useEnterpriseGrid pattern). Three files carry most of the
weight:
| File | Role |
|---|---|
useEnterpriseGrid.tsx | Config-driven hook: one options object in, a complete toolbar + tab set out |
ServerDataGrid.tsx | The actual <AgGridReact> wrapper: infinite row model, actions column, pagination, persistence |
TabbedServerGrid.tsx | Renders one ServerDataGrid per status tab inside Chakra Tabs |
Building a whole new page around a grid? Follow ../learn/build-a-module-page.md — the end-to-end recipe (DB → backend → this subsystem → tests).
The fastest way to build a new grid page is not to hand-write it: two
Claude Code skills scaffold the whole pattern —
.claude/commands/ag-grid-server-table.md
(a full server-side table page for an entity) and
.claude/commands/add-import-export.md
(CSV import/export modals for an existing page). Registration of AG Grid modules
happens once per page via setup.ts
(import '@/ui/ag-grid/setup'), which also patches the header filter icon to be
always visible.
useEnterpriseGrid — the config-driven API
A page describes its grid declaratively and gets everything back. Real example:
pages/recruitment/JobRequisitions.tsx.
The config (UseEnterpriseGridConfig):
columns: EnterpriseGridColumn[]—EnterpriseGridColumn.tsis the single source of truth per column: it derives the AG Grid colDef, the View-menu column checklist, the "Search In" radio group (searchable), and the backendfields=projection param (projectable/alwaysVisible) — no more hand-syncing parallel arrays.buildParams/fetchPage/getRowId/sortableFields— the server contract.buildParamsis a factory: it receives the tab context (fixedFilterValue, thefieldsprojection, per-tabextraParams) and returns the(filterModel, sortModel, page, size) → paramsfunction the datasource calls.fetchPageis a plainapi/function returning{ content, totalElements };sortableFieldswhitelists which colIds are forwarded as sorts.tabs+countQueryFn— status tabs (fixedFilterValue, optionalomitColumnFields,extraParams,showCount). Counts are fetched with a React QueryuseQueriesbatch (one query per countable tab, 30 s staleTime) and rendered as a "(N)" suffix on the tab label.- Row interaction —
rowActions/onRowAction(the pinned-right kebab menu),onRowDoubleClicked,selection: { canSelect }(ANDed with the View-menu's checkbox toggle). - Saved views — opt in with
savedViewEntityKey. - Table menu callbacks —
onImport,onExport,onExportPdf,onViewAnalytics, extratableActions, and aprimaryActionbutton. emptyState— title/description/CTA for the no-rows overlay (EmptyState.tsx).
It returns (UseEnterpriseGridResult):
toolbar— search box + View ▾ + Table ▾ + primary action as one node. Per the doc comment on the type: pass it to<TabbedServerGrid toolbarRight={toolbar}>only, never into per-tab grid props (see the tab-mounting rule below).tabs/tabLabels/activeTab/onTabChange.activeApi()— theGridApiof the currently active tab.refreshAllTabs()— purges every tab's infinite cache.activeSelectionCount— drivesBulkActionBar.
ServerDataGrid
The wrapper renders <AgGridReact rowModelType="infinite"> with an
IDatasource whose getRows derives page/size from the requested row
window, filters the sort model to sortableFields, calls
buildParams → fetchPage, and reports totalElements back to the grid. Load
failures show a toast (or the page's onError); an empty result shows the
no-rows overlay.
Notable pieces, all with explanatory comments in the source:
- Pinned-right kebab actions. When
rowActionsare configured, an extra 60 pxpinned: 'right'column rendersRowActionsCell— a ChakraMenuRoot → MenuTrigger → Portal → MenuPositioner → MenuContentcomposition. ThePortalis mandatory: the cell lives inside AG Grid's overflow-clipped viewport, so a non-portalled popup would be cut off at the row/column edge (styling.md). Related: the outer container'sminWidthis only a narrow-screen floor — set it too wide and the wrapper scrolls the entire grid, pinned columns included, sliding the actions column off-screen (see the comment above the render). - Custom pagination bar. AG Grid's own panel is suppressed;
GridPaginationBarrenders the row-range text, an ellipsized page list, and (whenpageSizeOptionsis set) a page-size menu. On page-size change,cacheBlockSizeandpaginationPageSizemust move together and the cache be purged — they are independent at runtime and the datasource'ssize = endRow - startRowmath desyncs otherwise (comment inhandlePageSizeChange). The bar also carries a jump-to-page input (Enter, blur, or theGobutton; out-of-range values clamp), gated ontotalPages > pageJumpThreshold. That threshold defaults to 5 — past that the numbered tokens start eliding, so typing beats clicking. Grids that page but never exceed five pages must opt in explicitly withpageJumpThreshold: 1, asEmployees.tsxdoes (100 rows at 25/page = 4 pages). Leave it unset elsewhere: the default is what keeps the input off small grids, ande2e/CRM/leads-filters.spec.tsasserts that. - Grid-state persistence. Column state + filter model are saved to
localStorage under the page's
gridStateKeyon every move/resize/sort/filter change, and re-applied ononGridReady(falling back toinitialState). Pluggable via thepersistenceprop (falsedisables). - Filter changes purge the cache (
onFilterChanged→purgeInfiniteCache()), which is what turns an AG column filter into a fresh server query.
The re-render gotchas (read before touching)
Both ServerDataGrid and useEnterpriseGrid contain deliberate
ref-indirection that looks removable and is not. The inline comments explain it
best; summarized:
- Stable identities protect open filter popups. Pages routinely pass fresh
inline functions every render (
getRowId={r => String(r.id)}, row-action handlers). If those identities flowed intocolumnDefsor the shared grid props, any unrelated parent re-render (e.g. a saved-views query resolving) would hand AG Grid new objects, which it re-applies viasetGridOption— tearing down transient UI like an open, half-typed column filter. Both files therefore route latest-value refs throughuseCallback-wrapped stable functions (reading the ref only when called, never during render, to satisfy the react-hooks v7 refs rule).rowSelectionis likewise memoized on the enabled flag alone. getRowIdidentity matters for the same reason: it participates in the memoized shared props, so it goes through the same stable wrapper.
TabbedServerGrid and the tab-mounting rule
TabbedServerGrid renders
one ServerDataGrid per tab inside Chakra Tabs. All tab panels stay
mounted — Chakra keeps every Tabs.Content in the DOM, and each tab is its
own AG Grid instance with its own datasource, selection, and cache. Two rules
follow directly:
- Toolbar goes in
toolbarRight, never in shared/per-tab grid props — anything placed in per-tab props renders once per tab. - Cross-tab mutations must call
refreshAllTabs(), notactiveApi().purgeInfiniteCache(). A status change or archive/restore moves rows between tabs; purging only the active tab leaves the others serving stale cached blocks when the user switches. (See the bulk-status handler inJobRequisitions.tsxfor the pattern, and the doc comment onrefreshAllTabs.) Tab-count queries need a matchinginvalidateQueries/invalidateOnRefreshsince counts live in React Query.
View options, density, quick search, saved views
- View ▾ menu (
GridViewOptionsMenu, state inuseGridViewOptions): column visibility (which also shrinks the backendfields=projection), search field, tabs on/off, selection checkboxes, and density — compact (44 px rows) / comfortable (56 px) / expanded (76 px +wrapText/autoHeight). Persisted per page in localStorage under the grid'sstorageKey. - Quick search: the toolbar box is debounced 300 ms and applied via
filterUtils.applyQuickSearchas acontainstext filter on one configurable column — there is no backend full-text search param, so each page picks its most useful field (honest limitation, documented in the function's comment). - Saved views (
savedViewEntityKey): named snapshots of filter model + visible columns + sort + density, stored server-side per user viaapi/saved-views.ts(/api/saved-views, saving under an existing name overwrites). Applying one restores view options first (columns/density drive colDefs), then grid filter and sort state. This is distinct from the automatic localStorage grid-state persistence, which is unnamed and per-browser. - Migrating an existing page (
legacyGridStateKey,legacyGridStateTabs): moving a page onto the hook renames its storage key, and the per-grid state key is derived as`${storageKey}-${tab}-grid-state`— so a user's saved column widths/order/visibility/filters would be orphaned. Pass the page's old key and, if it was tabbed, its old tab values;migrateLegacyGridStatecopies the state across once and deletes the old entry. It never overwrites state already present on the new key, and it strips any saved sort — a restored sort can name a column that is no longer sortable (Items'totalStockis the live example: sorting it 500s). Note the old key layout differs per module — inventory suffixed the tab after the base (…-grid-state-all) while payroll/HR put it in the middle (motorph-employees-regular-grid-state) — which is why this is explicit per-page config rather than derived fromstorageKey.
Supporting files
| File | One-liner |
|---|---|
BulkActionBar.tsx | "N selected" bar with action buttons; renders nothing at count 0 |
ImportModal.tsx | Config-driven CSV import: idle → preview → importing → done, template download, per-row create loop with inline errors |
ViewToggle.tsx + CardGrid.tsx | Table/card view switch + generic responsive card layout |
GridPaginationBar.tsx | Custom pagination bar + page-size menu (replaces AG Grid's panel) |
clientPagedFetch.ts | Adapter for unpaged endpoints: fetch all, evaluate AG filter/sort models client-side, slice the page |
csv.ts / pdf.ts | CSV parse/export helpers + text-based landscape table PDF (jspdf-autotable); both take the same column array |
filterUtils.ts + filterUtils.test.ts | AG filter-model → backend param extraction (text/number/date) — the one unit-tested module in the frontend (../testing/README.md) |
theme.ts | motorphGridTheme — themeQuartz.withParams matched to the brand palette (styling.md) |
setup.ts | One-time ModuleRegistry registration + always-visible filter icon CSS |
columnTypes.ts | numericValue column type — right-aligns cells without AG's rightAligned header icon-flip (see its comment) |
defaultColDef.ts / selectionColDef.ts | Shared column defaults / checkbox-column def |
TableActionsMenu.tsx | The Table ▾ menu: refresh, import/export, reset view, analytics link, module extras |
ActivityHistoryDrawer.tsx | Generic per-record audit-trail drawer used by row actions |
useGridPagination.ts / useGridAutoSize.ts / useTabSelectionCounts.ts | Pagination state, column auto-sizing, per-tab selection counts |
useGridToolbar.tsx | Older standalone toolbar hook, predating useEnterpriseGrid's built-in toolbar |