11 — Employees.tsx decoded: columns, tabs, params
Read this first: this chapter reads the top half of the biggest grid page in the repo — the
700-line Employees.tsx — and discovers that almost none of it is logic: it is constants, one
curried params factory, 25 column definitions, five tabs, and a single giant hook call. This lesson
stops at the JSX skeleton; the drawers, dialogs, and export handlers that fill the bottom half are
lesson 12.
Time: about 40 minutes. Assumes lesson 10.
The shape: declaration, not logic
module scope GRID_STORAGE_KEY · EXPORT_FIELDS · CARD_FIELDS · STATUS_BY_TAB · GRID_TABS
makeBuildEmployeeParams() ← the params factory
component state · rowActions · columns[25] · export handlers
useEnterpriseGrid({ … }) ← the wiring
bulk handlers · card-view query
JSX header · BulkActionBar · TabbedServerGrid | CardGrid · seven drawers/dialogs
Read it in that order and the page stops being 700 lines and becomes six declarations. Everything above the hook call is what the grid is; the hook is how it runs (lesson 09); the JSX below is where it mounts.
Three field lists, three audiences
const GRID_STORAGE_KEY = 'motorph-employees-view-options';
/** Every projectable column, for exports — which must not be narrowed by the View menu. */
const EXPORT_FIELDS = [
'lastName', 'firstName', 'positionName', 'departmentName', 'status', 'dateHired', 'basicSalary',
// … 15 more: IDs, allowances, rates …
'restDayOfWeek', 'separationDate', 'separationReason',
].join(',');
/** What the card view renders — a narrower set than the export, so cards don't over-fetch. */
const CARD_FIELDS = 'lastName,firstName,positionName,departmentName,status,dateHired,basicSalary';
GRID_STORAGE_KEY you know from lesson 09: it namespaces the
persisted view options and the tab-count query keys. The two field lists are the interesting part.
The same list endpoint serves three different fields= projections: the grid computes its own from
whichever columns are visible (lesson 07), the export always asks for the
whole record because — as the comment says — an export "must not be narrowed by the View menu", and
the card view asks for exactly the seven fields EmployeeCard renders. One endpoint, three
projections, each sized to its consumer.
A factory that returns the builder
The hook wants one function per tab with the signature (filterModel, sortModel, page, size) → params. But a tab also has an identity — its fixed status, its projection, whether it is the
archived set — known long before any request fires. So the page curries:
function makeBuildEmployeeParams(fixedStatus?: EmployeeStatus, fields?: string, archived?: boolean) {
return (
fm: Record<string, unknown>,
sortModel: SortModelItem[],
page: number,
size: number,
): ListEmployeesParams => {
const lastNameF = extractTextFilter(fm.lastName);
const salaryR = extractNumberRange(fm.basicSalary);
// Hidden-by-default columns; each is undefined unless its column is visible and filtered.
const sssF = extractTextFilter(fm.sssNumber);
// … twenty more extracts, one per filterable column …
const status = fixedStatus ?? (statusF?.value ? statusF.value.toUpperCase() : undefined);
return {
page,
size,
// Raw grid colIds: the projection endpoint resolves positionName/departmentName to their
// joined columns itself, so they must NOT be rewritten to dot-notation here.
sort: sortModel.length ? sortModel.map(m => `${m.colId},${m.sort}`) : undefined,
lastNameSearch: lastNameF?.value || undefined,
lastNameSearchType: lastNameF?.type || undefined,
salaryMin: salaryR.min,
salaryMax: salaryR.max,
/* … every other filterable column, in the same three shapes … */
status,
archived,
fields,
};
};
}
AG Grid hands you a nested filter model keyed by colId; the backend wants flat query params. The
three filterUtils helpers from lesson 07 do the flattening — text
becomes a Search/SearchType pair, numbers become Min/Max/FilterType, dates become
From/To/FilterType — and every inactive filter collapses to undefined, so the query string
carries only what the user actually set. Notice the wire shape: one value-and-type pair per
field. That single fact explains a filterParams entry you are about to see on all 25 columns.
Twenty-five columns, eight on by default
Eight columns render on first load. Here is one, with its renderer:
const StatusCell = (params: ICellRendererParams<EmployeeRowDto>) =>
params.value ? <StatusBadge status={params.value as EmployeeStatus} /> : null;
{
field: 'status',
headerName: 'Status',
colDef: {
width: 140,
sortable: true,
filter: 'agTextColumnFilter',
filterParams: { filterOptions: ['equals', 'notEqual'], defaultOption: 'equals', maxNumConditions: 1 },
cellRenderer: StatusCell,
},
},
maxNumConditions: 1 appears in every single column's filterParams. AG Grid's default filter
popup lets a user stack two conditions joined by AND/OR — but you just read the params factory: the
backend models exactly one condition per field. The option deletes the second condition row so the
UI cannot promise what the wire cannot carry.
The other seventeen columns sit behind a comment:
// ── Hidden by default: the rest of the employee record. Off until switched on in the
// View menu, so the default table stays readable and `fields=` stays narrow.
{
field: 'sssNumber', headerName: 'SSS Number', searchable: true, defaultVisible: false,
colDef: { width: 150, sortable: true, filter: 'agTextColumnFilter', filterParams: { maxNumConditions: 1 } },
},
{
field: 'riceSubsidy', headerName: 'Rice Subsidy', defaultVisible: false,
colDef: {
width: 150, type: 'numericValue', sortable: true, filter: 'agNumberColumnFilter',
filterParams: { maxNumConditions: 1 },
valueFormatter: params => formatCurrency(params.value as number),
},
},
This is lesson 07's payoff paying off: defaultVisible: false means the
column is not merely hidden — it is never fetched. Ticking SSS Number in the View menu widens
fields= and refetches. searchable: true enrolls a column in the toolbar's search-field dropdown;
type: 'numericValue' right-aligns via the shared column types; formatCurrency is display-only —
the raw number is what sorts and filters.
Two kinds of tab
const GRID_TABS: EnterpriseGridTab[] = [
{ value: 'all', label: <HStack gap={1.5}><LuUsers />All</HStack>, showCount: true },
{ value: 'regular', label: <HStack gap={1.5}><LuUserCheck />Regular</HStack>, fixedFilterValue: 'REGULAR', omitColumnFields: ['status'], showCount: true },
{ value: 'probationary', label: <HStack gap={1.5}><LuClock />Probationary</HStack>, fixedFilterValue: 'PROBATIONARY', omitColumnFields: ['status'], showCount: true },
{ value: 'inactive', label: <HStack gap={1.5}><LuUserX />Inactive</HStack>, fixedFilterValue: 'INACTIVE', omitColumnFields: ['status'], showCount: true },
{ value: 'archived', label: <HStack gap={1.5}><LuArchive />Archived</HStack>, extraParams: { archived: true }, showCount: true },
];
Predict: the three status tabs use fixedFilterValue. The Archived tab uses
extraParams: { archived: true } instead. Why could it not be fixedFilterValue: 'true'? Write
your answer down before reading on.
Resolved: fixedFilterValue exists for the single-scalar common case — a value poured into the same
slot a column filter would fill. The factory receives it as fixedStatus, and it replaces the
status column's filter, which is why those tabs also set omitColumnFields: ['status'] (a column
whose every value reads REGULAR is noise). But archived is not a column. No grid column exists for
it; it is an independent query param the endpoint understands. extraParams is the arbitrary bag
for exactly that case, and the page's buildParams reaches into it by name.
One more module constant closes the loop:
/** Exports and card view must scope to the same rows the active tab's grid shows. */
const buildParamsForTab = (tab: string, fields?: string) =>
makeBuildEmployeeParams(STATUS_BY_TAB[tab], fields, tab === 'archived' ? true : undefined);
The hook drives the grid's own fetches, but exports and the card view fetch outside the hook —
STATUS_BY_TAB lets them rebuild the active tab's scope from just its name.
The hook call is the wiring diagram
const {
toolbar, tabs, tabLabels, activeTab, onTabChange, activeApi, refreshAllTabs, activeSelectionCount,
} = useEnterpriseGrid<EmployeeRowDto, ListEmployeesParams>({
storageKey: GRID_STORAGE_KEY,
columns,
defaultSearchField: 'lastName',
buildParams: ({ fixedFilterValue, fields, extraParams }) =>
makeBuildEmployeeParams(fixedFilterValue as EmployeeStatus | undefined, fields, extraParams?.archived as boolean | undefined),
fetchPage: listEmployeeRows,
getRowId: row => String(row.employeeNumber),
sortableFields: new Set([ /* … all 25 fields … */ ]),
tabs: GRID_TABS,
countQueryFn: ({ fixedFilterValue, extraParams }) => /* … same factory, size 1, read totalElements … */,
pageSize: 25,
pageSizeOptions: [25, 50, 75, 100],
// 100 employees at 25/page is only 4 pages — under the bar's default threshold of 5, so the
// jump-to-page input would never surface here without opting in explicitly.
pageJumpThreshold: 1,
rowActions,
onRowAction: handleRowAction,
selection: { canSelect: canToggleStatus },
savedViewEntityKey: 'employees',
onImport: canCreate ? () => { setImportOpen(true); } : undefined,
onExport: () => { void handleExport(); },
onExportPdf: () => { void handleExportPdf(); },
/* … onRowDoubleClicked, emptyState, tableActions, primaryAction … */
invalidateOnRefresh: [[GRID_STORAGE_KEY, 'count']],
});
Every option is a lesson you already have. columns/buildParams/fetchPage/sortableFields are
the column contract and the server grid (lessons 07 and
08). storageKey/savedViewEntityKey and the toolbar callbacks are
lessons 09 and 10.
buildParams receives the active tab's { fixedFilterValue, fields, extraParams } and hands them
straight to the factory — the currying earns its keep here. countQueryFn fetches one row and reads
totalElements: a count endpoint without a count endpoint. The pageJumpThreshold comment is a
measurement, not taste — with the actual dataset size the jump input would otherwise never appear.
And onImport is undefined without the create permission, so the button does not render at all
(lesson 03).
One toolbar, two mounts
{viewMode === 'table' ? (
<TabbedServerGrid<EmployeeRowDto, ListEmployeesParams>
tabs={tabs}
defaultTab="all"
variant="enclosed"
onTabChange={onTabChange}
toolbarRight={toolbar}
/>
) : (
<Tabs.Root value={activeTab} variant="enclosed" onValueChange={({ value }) => { onTabChange(value); }}>
<HStack justify="space-between" align="flex-end" wrap="wrap" gap={3} mb={3}>
<Tabs.List>
{tabLabels.map(t => <Tabs.Trigger key={t.value} value={t.value}>{t.label}</Tabs.Trigger>)}
</Tabs.List>
<HStack gap={2} wrap="wrap">{toolbar}</HStack>
</HStack>
<CardGrid
items={cardData?.content ?? []}
isLoading={cardLoading}
renderCard={r => <EmployeeCard employee={r} onClick={row => { setViewingId(row.employeeNumber); }} />}
getKey={r => r.employeeNumber}
emptyMessage="No employees found."
/>
</Tabs.Root>
)}
Table mode is convention (a) from lesson 10 verbatim: the
toolbar goes through toolbarRight, never through shared grid props. Card mode mounts the same
toolbar element in a hand-built strip — and this is why tabLabels exists. The tabs array carries
each tab's full grid content for TabbedServerGrid; the card strip needs only value plus a label
(counts already baked in by the hook). A light parallel list beats re-mapping the ref-carrying
tabs array. Card data comes from its own query — buildParamsForTab(activeTab, CARD_FIELDS),
first 100 rows, enabled: viewMode === 'card' so table users never pay for it — and ViewToggle at
the top of the page is a two-button IconButton pair, nothing more.
Everything goes stale at once
/** Archive/restore and status changes all move rows between tabs, so every tab's cache and
* every tab count goes stale — not just the active one's. */
const refreshEverything = () => {
refreshAllTabs();
void queryClient.invalidateQueries({ queryKey: [GRID_STORAGE_KEY, 'count'] });
};
This is the reference implementation of convention (b). The tempting bug is
activeApi()?.purgeInfiniteCache() — refresh the tab you can see. But deactivating an employee on
the Regular tab must also remove them from All's cache, add them to Inactive's, and change three tab
counts. Six call sites route through it: archive/restore, the single status change, both bulk
handlers, form success, and import success. If a mutation can move a row between tabs, it calls
refreshEverything().
The page that proves the pattern
Open JobRequisitions.tsx next to Employees.tsx and you find the same file twice: the same
constants block, the same curried factory (makeBuildJobRequisitionParams), the same
STATUS_BY_TAB/buildParamsForTab pair, an eight-tab GRID_TABS with the identical
extraParams: { archived: true } archived tab, the same hook call shape, the same
table-or-card JSX. It runs 470 lines to Employees' 714 only because it has 8 columns instead of 25.
That mirror is the proof the system generalizes — and because it carries less payroll-specific
freight, it is the cleaner file to copy from, which is lesson 13's
subject.
Where this shows up in MotorPH
- frontend/src/pages/hr/Employees.tsx — the page this lesson decoded
- frontend/src/pages/recruitment/JobRequisitions.tsx — the mirror, and the better template
- frontend/src/ui/ag-grid/filterUtils.ts — the three extract helpers the factory leans on
- frontend/src/ui/ag-grid/useEnterpriseGrid.tsx — where
tabs,tabLabels, andtoolbarare built - frontend/src/ui/ag-grid/ViewToggle.tsx and frontend/src/ui/ag-grid/CardGrid.tsx — the card mode's two generic pieces
- frontend/src/components/employees/EmployeeCard.tsx — renders exactly the seven
CARD_FIELDS - ../frontend/ag-grid.md — the exhaustive option reference; the guided tour walks this page as a user, and Build a module page is the recipe form of what you just read
Recap
- The 700-line page is declarations, not logic — constants, factory, columns, tabs, one hook call, JSX — so you can now read all of it top to bottom.
- One field list per audience — the grid projects visible columns,
EXPORT_FIELDSthe whole record,CARD_FIELDSwhat the card shows — because each consumer pays only for what it renders. - The params factory is curried because tab identity is known before any request, and
maxNumConditions: 1is on every column because the wire carries exactly one condition per field. fixedFilterValuefor a scalar column filter,extraParamsfor everything else — archived is a query param, not a column.- Mutations that move rows between tabs call
refreshEverything()— every tab's cache and count goes stale, not just the visible one's.