PR: #371: feat(documents): per-column header menus [Story-1513] by @ankitmaheshwari-com
View PR →
Story 1513 · EPIC-1489 Documents & Intake · DOCS-COLMENU

Reshaping the register from the column itself

The Documents Register lets a lawyer reshape one table endlessly — but the view-state engine only gave us the brains and the read-only headers. This Story builds the hands: the per-column ⋯ menu on every header — Sort ascending/descending, Group by, Slice by, Hide field (the per-column half of the Fields chooser), and Move left/right. Each action is a pure edit to the same view-state the engine already consumes, so the table just re-runs and redraws. Built Storybook-first, one story per leaf, before any live wiring.

🛰️ Satellite — why a menu, not a thousand handlers

The naive build hangs a bespoke callback off each header — onSortDocType, onGroupByTags, onHideSize — and threads each one through the table into the page. It renders. It also rots: ten columns × six actions is sixty props, the table grows a giant prop interface, and "what can this column do?" lives nowhere — it's smeared across whoever wired the handler. WHY this fails: a column's capabilities (is it groupable? sliceable? sortable?) are data on the column, and a column action is just a pure edit to the view-state. The instant you treat them as data + pure functions, the sixty handlers collapse to one menu that reads the column's own flags and one onViewChange(nextView).

✈️ Airplane — the shape of the thing

Two leaves and a pure mutation module, all over the existing engine:

PieceWhat it isWHY it is here
view-mutations.ts Pure ViewState → ViewState transforms: setSort, toggleSort, setGroupBy, setSliceBy, hideField, moveField, plus canMoveLeft/Right guards. every column action is a value edit — testable as a value comparison, never a DOM mount, mirroring the engine's own discipline
RegisterColumnMenu The ⋯ dropdown on a header. Items are gated by the column's own capability flags; the active sort direction gets a ✓; the always-on name column shows sort only. the menu offers exactly what the column can do — a non-groupable Size column never says "Group by this"
Fields chooser (reused) The multi-column show/hide list ships as the ⚙ View popover's view-field-list.tsx (sibling Story-1511 / DOCS-VIEWPOPOVER, already merged). The column menu's Hide field is the per-column half of the same edit. show/hide is just a fields[] edit — one canonical chooser, not a second duplicate leaf
RegisterColumnHeader / RegisterTable The header embeds the menu; the table owns view + onViewChange and translates every header intent through the mutation helpers. one funnel: header intent → pure mutation → onViewChange → engine re-runs → redraw
header ⋯ "Group by this"
  → setGroupBy(view, "tags")            // pure, returns a new ViewState
  → onViewChange(next)                  // parent swaps the view
  → buildRegisterModel(rows, next)      // engine re-buckets
  → table redraws, grouped by tags

🔬 Microscope — the two details that carry the correctness

1. Capability gating comes from the column, not the call site

The engine's ColumnDescriptor already carries groupable / sliceable / sortable. The menu reads them directly, and every mutation re-checks them, so a bad intent is a no-op rather than a broken view:

export function setGroupBy(view, field) {
  if (field !== null && !getColumn(field)?.groupable) return view; // refuse
  return { ...view, groupBy: field };
}

WHY: Size and Pages are sortable but not groupable. The Size header's menu therefore shows Sort + Hide + Move and omits Group/Slice — and even if something called setGroupBy(view, "sizeBytes") anyway, the helper returns the view untouched. The capability lives in one place and is enforced in one place.

2. Move skips hidden neighbours, so a move always moves something visible

"Move left/right" reorders the visible sequence the lawyer sees — but hidden columns still sit in the fields[] array between them. A naive adjacent swap would trade places with an invisible column and appear to do nothing. So moveField steps over hidden entries to find the nearest visible neighbour:

let to = from + step;
while (to >= 0 && to < fields.length && !fields[to].visible) to += step;
if (to < 0 || to >= fields.length) return view; // at the visible edge → no-op
[fields[from], fields[to]] = [fields[to], fields[from]];

WHY: the menu's canMoveLeft/Right guards and this skip are the same truth — position in the visible order — computed the same way the table derives a header's index from model.columns. Edge columns disable the item; interior columns always produce a visible change.

Mode-1 honesty. Nothing here invents data. The menu and chooser operate purely on the view-state; the AI columns (clean name, document type, tags) keep rendering processing…/empty until EPIC-009 fills them. Hiding or moving a still-empty column is a legitimate, working action — the column is real, only its values are pending.

One-sentence recall

A column action is a capability-gated, pure edit to the view-state — the menu offers only what the column can do, the mutation refuses what it can't, and the engine redraws the rest.

What shipped

Live-wiring seam: the live Documents page still renders the legacy register; wiring the engine-driven RegisterTable + the ⚙ View popover (which composes the Fields chooser) into the option-J chrome is the sibling DOCS-CHROME / VIEWPOPOVER work, per the Storybook-first sequence.

Files changed

11 file(s), +564 / -35 lines

Path Role + -
messages/en.jsonConfig+2-0
messages/hi.jsonConfig+2-0
src/components/documents/register/register-column-header.tsxComponent+39-17
src/components/documents/register/register-column-menu.stories.tsxComponent+53-0
src/components/documents/register/register-column-menu.tsxComponent+149-0
src/components/documents/register/register-table.stories.tsxComponent+22-0
src/components/documents/register/register-table.tsxComponent+44-11
src/components/documents/register/register-view.tsxComponent+3-7
src/lib/documents/register/index.tsLibrary+13-0
src/lib/documents/register/view-mutations.test.tsLibrary+112-0
src/lib/documents/register/view-mutations.tsLibrary+125-0