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:
| Piece | What it is | WHY 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.
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
- new
src/lib/documents/register/view-mutations.ts— pure sort/group/slice/hide/move transforms + visible-order guards, withview-mutations.test.ts(16 cases). - new
RegisterColumnMenu— the per-column ⋯ menu, capability-gated, ✓ on the active sort, on the sharedDropdownMenuprimitive (keyboard-operable, portalled). - reused Fields chooser — the multi-column show/hide list is the View popover's
view-field-list.tsx(Story-1511, merged); this Story adds the per-column Hide field, not a duplicate leaf. - wired
RegisterColumnHeaderembeds the menu;RegisterTableownsview+onViewChangeand funnels every intent through the mutations (alongside main's pagination / empty-state / retry). - i18n
table.moveLeft/table.moveRightadded toen.json+hi.json. - stories
RegisterColumnMenu(all gating states) and an interactiveRegisterTablethat exercises the full menu flow.
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.json | Config | +2 | -0 |
| messages/hi.json | Config | +2 | -0 |
| src/components/documents/register/register-column-header.tsx | Component | +39 | -17 |
| src/components/documents/register/register-column-menu.stories.tsx | Component | +53 | -0 |
| src/components/documents/register/register-column-menu.tsx | Component | +149 | -0 |
| src/components/documents/register/register-table.stories.tsx | Component | +22 | -0 |
| src/components/documents/register/register-table.tsx | Component | +44 | -11 |
| src/components/documents/register/register-view.tsx | Component | +3 | -7 |
| src/lib/documents/register/index.ts | Library | +13 | -0 |
| src/lib/documents/register/view-mutations.test.ts | Library | +112 | -0 |
| src/lib/documents/register/view-mutations.ts | Library | +125 | -0 |