Skip to main content

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:

FileRole
useEnterpriseGrid.tsxConfig-driven hook: one options object in, a complete toolbar + tab set out
ServerDataGrid.tsxThe actual <AgGridReact> wrapper: infinite row model, actions column, pagination, persistence
TabbedServerGrid.tsxRenders 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.ts is 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 backend fields= projection param (projectable / alwaysVisible) — no more hand-syncing parallel arrays.
  • buildParams / fetchPage / getRowId / sortableFields — the server contract. buildParams is a factory: it receives the tab context (fixedFilterValue, the fields projection, per-tab extraParams) and returns the (filterModel, sortModel, page, size) → params function the datasource calls. fetchPage is a plain api/ function returning { content, totalElements }; sortableFields whitelists which colIds are forwarded as sorts.
  • tabs + countQueryFn — status tabs (fixedFilterValue, optional omitColumnFields, extraParams, showCount). Counts are fetched with a React Query useQueries batch (one query per countable tab, 30 s staleTime) and rendered as a "(N)" suffix on the tab label.
  • Row interactionrowActions/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 callbacksonImport, onExport, onExportPdf, onViewAnalytics, extra tableActions, and a primaryAction button.
  • 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() — the GridApi of the currently active tab.
  • refreshAllTabs() — purges every tab's infinite cache.
  • activeSelectionCount — drives BulkActionBar.

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 buildParamsfetchPage, 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 rowActions are configured, an extra 60 px pinned: 'right' column renders RowActionsCell — a Chakra MenuRoot → MenuTrigger → Portal → MenuPositioner → MenuContent composition. The Portal is 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's minWidth is 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; GridPaginationBar renders the row-range text, an ellipsized page list, and (when pageSizeOptions is set) a page-size menu. On page-size change, cacheBlockSize and paginationPageSize must move together and the cache be purged — they are independent at runtime and the datasource's size = endRow - startRow math desyncs otherwise (comment in handlePageSizeChange). The bar also carries a jump-to-page input (Enter, blur, or the Go button; out-of-range values clamp), gated on totalPages > 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 with pageJumpThreshold: 1, as Employees.tsx does (100 rows at 25/page = 4 pages). Leave it unset elsewhere: the default is what keeps the input off small grids, and e2e/CRM/leads-filters.spec.ts asserts that.
  • Grid-state persistence. Column state + filter model are saved to localStorage under the page's gridStateKey on every move/resize/sort/filter change, and re-applied on onGridReady (falling back to initialState). Pluggable via the persistence prop (false disables).
  • Filter changes purge the cache (onFilterChangedpurgeInfiniteCache()), 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 into columnDefs or 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 via setGridOption — tearing down transient UI like an open, half-typed column filter. Both files therefore route latest-value refs through useCallback-wrapped stable functions (reading the ref only when called, never during render, to satisfy the react-hooks v7 refs rule). rowSelection is likewise memoized on the enabled flag alone.
  • getRowId identity 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:

  1. Toolbar goes in toolbarRight, never in shared/per-tab grid props — anything placed in per-tab props renders once per tab.
  2. Cross-tab mutations must call refreshAllTabs(), not activeApi().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 in JobRequisitions.tsx for the pattern, and the doc comment on refreshAllTabs.) Tab-count queries need a matching invalidateQueries/invalidateOnRefresh since counts live in React Query.

View options, density, quick search, saved views

  • View ▾ menu (GridViewOptionsMenu, state in useGridViewOptions): column visibility (which also shrinks the backend fields= 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's storageKey.
  • Quick search: the toolbar box is debounced 300 ms and applied via filterUtils.applyQuickSearch as a contains text 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 via api/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; migrateLegacyGridState copies 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' totalStock is 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 from storageKey.

Supporting files

FileOne-liner
BulkActionBar.tsx"N selected" bar with action buttons; renders nothing at count 0
ImportModal.tsxConfig-driven CSV import: idle → preview → importing → done, template download, per-row create loop with inline errors
ViewToggle.tsx + CardGrid.tsxTable/card view switch + generic responsive card layout
GridPaginationBar.tsxCustom pagination bar + page-size menu (replaces AG Grid's panel)
clientPagedFetch.tsAdapter for unpaged endpoints: fetch all, evaluate AG filter/sort models client-side, slice the page
csv.ts / pdf.tsCSV parse/export helpers + text-based landscape table PDF (jspdf-autotable); both take the same column array
filterUtils.ts + filterUtils.test.tsAG filter-model → backend param extraction (text/number/date) — the one unit-tested module in the frontend (../testing/README.md)
theme.tsmotorphGridThemethemeQuartz.withParams matched to the brand palette (styling.md)
setup.tsOne-time ModuleRegistry registration + always-visible filter icon CSS
columnTypes.tsnumericValue column type — right-aligns cells without AG's rightAligned header icon-flip (see its comment)
defaultColDef.ts / selectionColDef.tsShared column defaults / checkbox-column def
TableActionsMenu.tsxThe Table ▾ menu: refresh, import/export, reset view, analytics link, module extras
ActivityHistoryDrawer.tsxGeneric per-record audit-trail drawer used by row actions
useGridPagination.ts / useGridAutoSize.ts / useTabSelectionCounts.tsPagination state, column auto-sizing, per-tab selection counts
useGridToolbar.tsxOlder standalone toolbar hook, predating useEnterpriseGrid's built-in toolbar