10 — Tabs, toolbars, and the two load-bearing conventions
Read this first: the tab strip on every list page turns out to be the smallest file in the grid
system — and the source of its two most-broken rules. You will read TabbedServerGrid in full,
find both rules written into the code as comments, and watch one page follow a rule in one handler
and miss it in the next.
Time: about 35 minutes. Assumes lesson 09.
The whole component
TabbedServerGrid.tsx fits in one quote. Read all of it — every convention in this lesson falls
out of its shape:
import type { ReactNode } from 'react';
import { HStack, Tabs } from '@chakra-ui/react';
import { ServerDataGrid } from './ServerDataGrid';
import type { ServerDataGridProps } from './ServerDataGrid';
export interface TabGridConfig<TRow, TApiParams> {
value: string;
label: ReactNode;
gridProps: ServerDataGridProps<TRow, TApiParams>;
}
export interface TabbedServerGridProps<TRow, TApiParams> {
tabs: TabGridConfig<TRow, TApiParams>[];
defaultTab?: string;
variant?: 'enclosed' | 'line' | 'plain';
onTabChange?: (value: string) => void;
/** Rendered on the same row as the tab list (right-aligned) — e.g. a page-level "Add" button. */
toolbarRight?: ReactNode;
}
export function TabbedServerGrid<TRow, TApiParams = Record<string, unknown>>({
tabs,
defaultTab,
variant = 'enclosed',
onTabChange,
toolbarRight,
}: TabbedServerGridProps<TRow, TApiParams>) {
if (tabs.length === 1) {
return <ServerDataGrid<TRow, TApiParams> {...tabs[0].gridProps} toolbarRight={toolbarRight} />;
}
return (
<Tabs.Root
defaultValue={defaultTab ?? tabs[0]?.value}
variant={variant}
onValueChange={({ value }) => { onTabChange?.(value); }}
>
<HStack justify="space-between" align="flex-end" wrap="wrap" gap={3} mb={3}>
<Tabs.List>
{tabs.map(tab => (
<Tabs.Trigger key={tab.value} value={tab.value}>
{tab.label}
</Tabs.Trigger>
))}
</Tabs.List>
{toolbarRight && <HStack gap={2} wrap="wrap">{toolbarRight}</HStack>}
</HStack>
{tabs.map(tab => (
<Tabs.Content key={tab.value} value={tab.value} pt={3}>
<ServerDataGrid<TRow, TApiParams> {...tab.gridProps} />
</Tabs.Content>
))}
</Tabs.Root>
);
}
Three shapes to register. One ServerDataGrid per tab — each tab is a full grid instance with
its own row cache and its own gridStateKey (lesson 08). The
toolbarRight slot renders once, in the HStack beside the tab list, outside every panel. And
a single tab short-circuits to a bare grid: no Tabs.Root, no strip — which is how the
View menu's "Show status tabs" switch collapses a tabbed page without a second code path.
That first shape is the expensive one. A page with eight tabs mounts eight grids, and Chakra's
Tabs.Content hides inactive panels rather than unmounting them. Both conventions exist because
of that fact.
Convention one: the toolbar renders once, beside the tabs
The 700-line hook from lesson 09 hands you a toolbar node,
and its result type tells you exactly where it may go:
export interface UseEnterpriseGridResult<TRow, TApiParams> {
/** Search box + View▾ + Table▾ + primary action, as ONE node. Pass to
* <TabbedServerGrid toolbarRight={toolbar}> ONLY — never into sharedGridProps (Chakra Tabs
* keeps every panel mounted, so anything in per-tab gridProps renders once per tab). */
toolbar: ReactNode;
/* … */
}
The older, simpler toolbar hook — still used by pages that never adopted useEnterpriseGrid —
states the same rule with the full reasoning:
/**
* Produces a single combined toolbar node (search + refresh + extra actions + primary action) to
* pass into `TabbedServerGrid`'s `toolbarRight` prop, which renders once next to the tab list.
*
* Deliberately NOT meant for `ServerDataGrid`'s own per-tab `toolbarLeft`/`toolbarRight` props:
* `TabbedServerGrid` mounts one `ServerDataGrid` per tab and Chakra keeps every `Tabs.Content`
* panel in the DOM (hidden, not unmounted), so anything spread into `sharedGridProps` gets
* duplicated once per tab. Render this once at the page level instead.
*/
export function useGridToolbar({ /* … */ })
Two files carry the same warning because the mistake was made twice — once during the recruitment toolbar rollout, once again when the grids were consolidated.
Predict: ignore both comments and spread toolbarRight: toolbar into the shared per-tab grid
props on an eight-tab page. What appears on screen, and what happens to text typed into the search
box? Write your answer down before reading on.
Resolved: eight copies of the toolbar exist, one inside each panel, each a live React subtree with its own state and its own debounce timer. You only see one at a time — the hidden panels are hidden, not unmounted — which is what makes the bug slow to notice. The copy you see changes with the tab, so the search text you typed on Open is simply absent on Draft: it lives in another tab's copy. Menus double-render into portals, and the search state fragments per tab.
The rule: the toolbar goes in TabbedServerGrid's toolbarRight, never into per-tab grid
props. Per-tab toolbarLeft/toolbarRight on ServerDataGrid exist only for controls that
genuinely differ per tab.
Convention two: a moved row invalidates every tab
Each tab's grid caches its own rows. Tabs are status filters, so a status mutation teleports a row from one tab's result set into another's — and only one of those grids saw the mutation happen. The hook ships the fix, with the rule in its JSDoc:
/** Purges EVERY tab's row cache. Use after a mutation that can move a row between tabs
* (e.g. archive/restore, status changes) — purging only the active tab leaves the other
* tabs showing stale data once the user switches to them. */
refreshAllTabs: () => void;
There is a second cache: the "(N)" count on each tab label is a react-query query keyed
[storageKey, 'count', tab.value] with a 30-second staleTime. Invalidating the two-element
prefix [storageKey, 'count'] hits all of them at once — react-query matches keys by prefix.
JobRequisitions.tsx shows the convention followed and missed in the same file. The bulk status
handler does it right, and says why:
await Promise.all(selected.map(row => statusMutation.mutateAsync({ id: row.id, payload: { status } })));
toaster.create({ title: `${selected.length} requisition(s) set to ${status}`, type: 'success' });
api?.deselectAll();
// Status changes move rows between status tabs, so refresh all of them, not just this one.
refreshAllTabs();
void queryClient.invalidateQueries({ queryKey: [GRID_STORAGE_KEY, 'count'] });
The bulk delete handler, a page-scroll above it, does not:
await Promise.all(selected.map(row => deleteMutation.mutateAsync(row.id)));
toaster.create({ title: `${selected.length} requisition(s) deleted`, type: 'success' });
api?.deselectAll();
api?.purgeInfiniteCache();
That purges the active grid only. But a deleted row also lived in the All tab's cache — and in
one status tab's, if you deleted from All — and it is still counted in every "(N)" label.
Delete three drafts from the Draft tab, switch to All: the three rows are still there, and
the counts still include them, until something else forces a refetch. A delete moves rows between
tabs in the only direction people forget: out of all of them. It needs the same
refreshAllTabs() + count-invalidation pair as the status handler.
The rule: if a mutation can change which tab a row belongs to — status change, archive,
restore, delete, create — call refreshAllTabs() and invalidate [storageKey, 'count'].
purgeInfiniteCache() on the active grid is only enough for edits that keep the row where it is.
The two menus, by contents
The toolbar's two dropdowns split along one line, stated in GridViewOptionsMenu's JSDoc: "Kept
separate from TableActionsMenu since these are display/configuration concerns, not one-shot
actions."
View ▾ is how things look: the Saved Views group, a column checklist (with Select All, and a guard that keeps at least one column visible), a "Search In" radio picking which single field the search box targets, a density radio, and two switches — "Show status tabs" and "Show selection checkboxes". One non-obvious note from its JSDoc: the column checkboxes control "which columns are visible (and fetched — column checkboxes drive the backend's field-projection param, not just client-side AG Grid visibility)". Unticking a column shrinks the query.
Table ▾ is what happens once: Import CSV, Export CSV, Export PDF, any module-specific
extraActions (Employees' BIR Alphalist export lives here), then Refresh, Reset View, and a View
Analytics link. Its JSDoc gives the layout reason: grouping them "behind one trigger so they stop
competing with the primary '+New' button on the toolbar row."
The exhaustive prop lists live in the AG Grid reference; this is the map, not the inventory.
Saved views are rows in a table
"Save Current View…" does not write localStorage. The API wrapper is the whole story:
export const listSavedViews = async (entityKey: string): Promise<SavedViewDto[]> => {
const { data } = await apiClient.get<SavedViewDto[]>('/api/saved-views', { params: { entityKey } });
return data;
};
/** Saving under an existing name overwrites that view rather than creating a duplicate. */
export const saveSavedView = async (payload: SavedViewRequest): Promise<SavedViewDto> => {
const { data } = await apiClient.post<SavedViewDto>('/api/saved-views', payload);
return data;
};
export const deleteSavedView = async (id: number): Promise<void> => {
await apiClient.delete(`/api/saved-views/${id}`);
};
Views are scoped by entityKey — the savedViewEntityKey a page passes to the hook, like
'job-requisitions' — and save is an upsert by name, so re-saving "My weekly triage" updates it
instead of breeding duplicates. The backend entity's Javadoc explains why the server stays dumb:
/**
* A user's named grid view (filters + visible columns + sort + density) for a given grid,
* identified by {@code entityKey} (e.g. "job-requisitions"). Grid-state payloads are opaque
* JSON strings owned by the frontend/AG Grid — the backend stores and returns them verbatim
* rather than modelling their shape, so any page can adopt saved views without a schema change.
*/
Four TEXT columns — filter_model, visible_columns, sort_model, density — that the server
never parses. Any grid opts in by picking a key; no migration required.
Applying a view is where order matters. The hook's handler restores in a fixed sequence:
// Restore view options first (columns/density drive colDefs), then grid-level state.
if (view.visibleColumns)
setVisibleColumns(
new Set(view.visibleColumns.split(",").filter(Boolean))
);
if (view.density) setDensity(view.density as typeof density);
const api = activeApi();
if (api) {
api.setFilterModel(/* … parsed view.filterModel … */);
/* … then applyColumnState with the saved sort … */
}
View options first, because visible columns generate the column definitions — and a comment elsewhere in the same hook records that AG Grid silently discards filter-model entries for columns that do not exist. Apply the filter model before the column exists and the saved filter evaporates without an error. Filters before sort, so the sort applies to the filtered set the user saved.
What is remembered where
Three stores, three scopes — confuse them and you will debug the wrong machine:
| Saved views | View-options blob | Per-tab grid state | |
|---|---|---|---|
| Lives | backend saved_view table | localStorage under storageKey | localStorage under `${storageKey}-${tab}-grid-state` |
| Scope | per user, per entityKey — follows your login to any browser | this browser only | this browser, this tab |
| Holds | named bundle: filters + columns + sort + density | visible columns, search field, tab/selection switches, density | column widths and order, filter model |
| Applied | on demand, when picked from View ▾ | every mount | on grid-ready, per tab |
A user who asks "why did my filters disappear on my laptop?" was relying on the right-hand two columns. Only a saved view travels.
Where this shows up in MotorPH
- frontend/src/ui/ag-grid/TabbedServerGrid.tsx — the component quoted in full above
- frontend/src/ui/ag-grid/useGridToolbar.tsx — the pre-enterprise toolbar, carrying convention one's long-form warning
- frontend/src/ui/ag-grid/useEnterpriseGrid.tsx —
toolbar,refreshAllTabs, the saved-view handlers - frontend/src/ui/ag-grid/GridViewOptionsMenu.tsx and TableActionsMenu.tsx — View ▾ and Table ▾
- frontend/src/api/saved-views.ts and backend/src/main/java/com/motorph/payroll/model/SavedView.java — the API and the opaque-payload entity
- frontend/src/pages/recruitment/JobRequisitions.tsx — the pilot page with both bulk handlers
- Building a page on this stack end to end: Build a module page
Recap
- The toolbar goes in
TabbedServerGrid'stoolbarRight, never per-tab grid props — Chakra keeps every panel mounted, so a toolbar insharedGridPropsbecomes N live copies with N separate search states, and typed text vanishes on tab switch. - A mutation that can move a row between tabs calls
refreshAllTabs()and invalidates[storageKey, 'count']— purging only the active grid leaves other tabs showing moved or deleted rows and every "(N)" label wrong;JobRequisitions.tsx's bulk delete is the standing counter-example. - View ▾ configures, Table ▾ acts — display concerns and one-shot actions are separate menus so neither competes with the primary button.
- Saved views are backend rows, upserted by name, opaque to the server — which is why they follow a login across devices while column widths and the view-options blob stay on one machine.
- Apply a saved view options-first — columns and density generate the colDefs, and AG Grid silently drops filter entries for columns that do not exist yet.