Skip to main content

07 — The grid problem, and the column contract

Read this first: Part 2 begins here. You open frontend/src/ui/ag-grid/ expecting a table component and find thirty files — because every list page in MotorPH (employees, payroll runs, CRM leads, requisitions, inventory) sits on the same machinery, and this directory is all of it. This lesson maps the directory, then decodes the 62-line file that every other file answers to: EnterpriseGridColumn.ts.

Time: about 30 minutes. Assumes lesson 06.

Dozens of pages, one system​

Count the list pages in frontend/src/pages/ and you get past forty. Each one needs server-side filtering, sorting, and pagination; a search box; a column-visibility menu; tabs with counts; CSV and PDF export; bulk actions. Building that per page is how frontends rot, so the repo built it once. Forty-two pages import useEnterpriseGrid today.

Here is the directory, grouped by role. Do not read the files yet — lessons 08 to 10 decode the big ones. Your job now is to know which drawer each tool lives in:

RoleFiles
The two big onesServerDataGrid.tsx (472 lines, lesson 08), useEnterpriseGrid.tsx (699 lines, lesson 09)
The column contractEnterpriseGridColumn.ts, columnTypes.ts, defaultColDef.ts, selectionColDef.ts — this lesson
One-time setupsetup.ts, theme.ts
Toolbar and chromeGridViewOptionsMenu.tsx, GridPaginationBar.tsx, useGridToolbar.tsx, useGridViewOptions.ts, BulkActionBar.tsx, TableActionsMenu.tsx, ViewToggle.tsx, CardGrid.tsx, EmptyState.tsx, TabbedServerGrid.tsx, useTabSelectionCounts.ts — lesson 10
Data plumbingclientPagedFetch.ts, filterUtils.ts, useGridPagination.ts, useGridAutoSize.ts, legacyGridState.ts
Import, export, historycsv.ts, pdf.ts, ImportModal.tsx, ActivityHistoryDrawer.tsx
TestsfilterUtils.test.ts, legacyGridState.test.ts

Where the rows actually live​

Lesson 05 left you a trick unresolved: the biggest server state in the app — the list rows themselves — never appears in React Query. Now you can see why. The page hands ServerDataGrid one function:

readonly fetchPage: (params: TApiParams) => Promise<PageResult<TRow>>;

and the component runs AG Grid with rowModelType="infinite" and a datasource that calls it. AG Grid's infinite row model already owns exactly what a query cache would own — which pages are loaded, which are stale after a sort change, when to refetch a block. Wrapping fetchPage in React Query would create a second cache with its own opinion about freshness, and two caches disagreeing is a bug factory. So the rule stands: rows live in the grid's block cache; React Query handles everything around the grid — mutations, detail fetches, tab counts.

The column contract​

Every page on the system describes its table as one array: EnterpriseGridColumn<TRow>[]. Here is the interface in full, comments included — the comments are the documentation of record:

/**
* Single source of truth for a column: drives the AG Grid colDef, the View-menu's column
* checklist, its "Search In" radio group, and the backend `fields=` projection param — instead
* of hand-syncing separate `allColumnDefs`/`VIEW_COLUMNS` arrays per page.
*/
export interface EnterpriseGridColumn<TRow> {
field: Extract<keyof TRow, string>;
headerName: string;
/** Everything else AG Grid needs for this column: width, filter, cellRenderer, valueFormatter, type, etc. */
colDef?: Omit<ColDef<TRow>, 'field' | 'headerName'>;
/** Appears in the View-menu's "Search In" radio group. */
searchable?: boolean;
/** Included in the backend `fields=` param when visible. Default true; set false for
* client-only/computed columns with no backend projection counterpart. */
projectable?: boolean;
/** Excluded from the View-menu checklist and always included in `fields=` (e.g. pinned id). */
alwaysVisible?: boolean;
/**
* Whether the column is on when a user first opens the page (default true). Set false for
* detail columns that belong in the View menu but would crowd the default table — they stay
* unfetched until switched on, since `fields=` follows visibility.
*/
defaultVisible?: boolean;
}

The header comment names the incident: pages used to keep an allColumnDefs array for AG Grid and a VIEW_COLUMNS array for the menu, hand-synced. Add a column to one and forget the other and the menu offers a checkbox that does nothing. One array, four consumers, no drift.

field: Extract<keyof TRow, string> is the quiet enforcement: a column's field must be a real key of the row type, so a typo in a field name is a compile error, not an empty column in production.

Four flags, five derivations​

Each flag answers one question:

  • searchable — does this column appear in the toolbar's "Search In" radio group? Search is a backend parameter, so only fields the backend can match belong here.
  • projectable — does this field exist on the backend row at all? Default true. Set false for client-computed columns (an actions column, a derived badge) so they never leak into fields= and 500 the projection query.
  • alwaysVisible — is this column exempt from the View menu? A pinned id or name column that the page is meaningless without. It is not a checkbox the user can uncheck, and it is always fetched.
  • defaultVisible — is the column on for a first-time visitor? The Employees page carries 25 columns; most default off so the table is readable, and — read the comment again — "they stay unfetched until switched on".

Five small functions derive every consumer from the array. Two of them, so you can see the shape:

export function toViewColumns<TRow>(columns: EnterpriseGridColumn<TRow>[]): GridViewColumn[] {
return columns
.filter(col => !col.alwaysVisible)
.map(col => ({ key: col.field, label: col.headerName }));
}
export function toDefaultColumnKeys<TRow>(columns: EnterpriseGridColumn<TRow>[]): string[] {
return columns
.filter(col => !col.alwaysVisible && col.defaultVisible !== false)
.map(col => col.field);
}

toColumnDefs spreads colDef under the field and header for AG Grid; toSearchableFields filters on searchable for the radio group; toFieldsParam is the one that talks to the server, and it gets its own section.

Visibility is the projection​

Predict: you open the Employees page, uncheck "Birthday" in the View menu, and the grid refetches. Does the JSON response still contain the birthday field, with the column merely hidden? Write your answer down before reading on.

Here is the resolver:

export function toFieldsParam<TRow>(columns: EnterpriseGridColumn<TRow>[], visible: Set<string>): string {
const fields = columns
.filter(col => col.alwaysVisible || (col.projectable !== false && visible.has(col.field)))
.map(col => col.field);
return fields.join(',');
}

No. The hidden field is not in the response, because it was never in the request. The visible-column set is joined into a fields= query parameter, and the backend selects only those columns — hiding a column does not decorate the table, it shrinks the SQL. That is why a 25-column Employees grid with six columns showing costs what a six-column grid costs.

The consequence: a grid row is a projection, not the record. Code that reaches into a row object for a field the user happens to have hidden reads undefined — which is why every drawer and dialog refetches the full record by id instead of trusting the row it was opened from. That rule gets exercised hard in lesson 12.

Read the filter line once more and you can reconstruct any request by hand: alwaysVisible columns are unconditionally in; everything else needs both projectable !== false and membership in the visible set. That is the whole contract between a column list and the wire.

The right-aligned header bug​

columnTypes.ts is thirteen lines, and it exists because of a bug you would absolutely ship. Quoted whole:

import type { ColDef } from 'ag-grid-community';

/**
* AG Grid's own built-in `type: 'rightAligned'` sets `headerClass: 'ag-right-aligned-header'`,
* which its core CSS reacts to with `flex-direction: row-reverse` on the header label container —
* flipping the header text and sort/filter icon's visual order. That's fine for a lone numeric
* column, but next to text/date columns (label-then-icon) it reads as inconsistent header layout.
* `numericValue` right-aligns the cell value only, leaving every column's header laid out the
* same way regardless of type. Use this instead of `type: 'rightAligned'`.
*/
export const GRID_COLUMN_TYPES: Record<string, ColDef> = {
numericValue: { cellClass: 'ag-right-aligned-cell' },
};

The obvious move — AG Grid's built-in rightAligned type — right-aligns the header too, by reversing the flex order of label and sort icon. One numeric column between two text columns and the header row looks broken. numericValue aligns the cell value and leaves the header alone. The rule: numbers get type: 'numericValue', never type: 'rightAligned'.

The scars in the small files​

defaultColDef.ts opens with a comment that is a measurement, not a preference:

// Stable references: an inline object here causes AG Grid to refresh its column model and re-fire paginationChanged on every render.
export const DEFAULT_COL_DEF: ColDef = { resizable: true, sortable: true, filter: true, wrapHeaderText: true, autoHeaderHeight: true, suppressSizeToFit: true };

Pass defaultColDef={{ resizable: true }} inline and every render hands AG Grid a new object, which it treats as a changed column model — refresh, paginationChanged, refetch. Module-level constants make the reference stable, so the grid sees the same config forever.

setup.ts runs once at import time and carries one UX decision:

ModuleRegistry.registerModules([AllCommunityModule]);

// Keep the filter icon permanently visible in column headers (not only on hover)
const s = document.createElement('style');
s.textContent = '.ag-header-cell-menu-button { opacity: 1 !important; }';
document.head.appendChild(s);

AG Grid hides the filter button until hover; MotorPH's users filter constantly, so the affordance stays visible.

Pages that predate the system​

Not everything is migrated: Users, Roles, and AuditLogs under pages/hr/, plus the self-service pages, import AgGridReact directly and hand-roll a columnDefs array — no toolbar, no View menu, no fields=. The tell is the import block: useEnterpriseGrid means the full system; a bare AgGridReact import means a legacy page. For the exhaustive prop and option surface of the system itself, use ../frontend/ag-grid.md — this course teaches the why, the reference holds the what.

Where this shows up in MotorPH​

Recap​

  • One array, four consumers. EnterpriseGridColumn[] drives the colDefs, the View checklist, the Search-In radios, and fields=, because hand-synced parallel arrays drifted.
  • fields= is alwaysVisible columns plus visible columns with projectable !== false — read any page's column array and you can write its request by hand.
  • Hidden columns are never fetched. A grid row is a projection, not the record; anything that needs the whole record refetches by id.
  • Rows never enter React Query — the infinite row model already owns block caching and freshness, and a second cache would disagree with it.
  • Numbers use type: 'numericValue', never rightAligned — the built-in type reverses the header's label/icon order and breaks mixed header rows.

Next: 08 — ServerDataGrid decoded.