The Documents Register view-state engine
The Documents Register is one table that a lawyer reshapes endlessly: slice it to "Financial" evidence, group it by document type, sort by size, sum the bytes, fold the annexures under their parent. The PRD calls this a deep module β and the rule for a deep module is that the brains live in a pure, testable core, not smeared across React. This Story builds that core: a single function from a corpus of documents + a view-state to a fully grouped, counted, sorted render model. Every other register Story (the slicer, the View popover, saved views, column menus) renders from it. It ships with the leaf components that draw its output, each storied in isolation, and the unit tests that pin its behavior.
π°οΈ Satellite β why a pure engine at all
The naive build wires grouping, slicing, and counting straight into the
table component: rows.filter(...).sort(...), then a
reduce in the JSX. It renders. It also rots β the
moment a second surface needs the same grouping (a saved-view preview, a
slice navigator, a footer total), the logic is trapped in one
component's render and gets copy-pasted, and the only way to test "does
a tag-grouped document appear in every group?" is to mount a DOM and
read cells. The PRD's testing decision is explicit: assert the
render model β groups, counts, totals, ordering β with
no DOM-internals. So the engine has to be a plain
function with plain data in and plain data out. WHY: if the brains are
pure, the test is a value comparison and the table is just a renderer.
βοΈ Airplane β the shape of the thing
One function, one pipeline:
buildRegisterModel(rows: RegisterDocument[], view: ViewState): RegisterModel
| Stage | What it does | WHY it is here |
|---|---|---|
| resolve columns | view.fields (ordered + visible) β the visible ColumnDescriptor[] | the table renders exactly these, in this order β column hide/move is just a fields edit |
| aggregate slicer | over the whole corpus, per slice-field value β count + size, count-desc | the slice navigator shows totals for values you have not selected, so it can't be computed from the filtered set |
| slice filter | keep rows whose slice-field value (or sentinel) matches the selection | "Financial" is a filter, not a folder β dynamic, not physical |
| sort | ordered clauses, numeric vs. locale-text, asc/desc, tiebreaks | one comparator the table and every group share |
| group | bucket rows by the group field; multi-membership for tags | the key correctness case β see Microscope |
| summarize | count + total bytes per group and grand total | the field-sum footer (Count / Total size / Both / None) is a formatting choice over these two numbers |
The engine is locale-blind and React-blind on purpose. It emits message
keys (e.g. the untagged sentinel), raw data values,
and numbers; the leaf components localize and format. That keeps the
engine trivially unit-testable and keeps i18n where it belongs.
π¬ Microscope β the two details that earn the engine
1. Multi-membership grouping (the AC's "key case")
A document tagged Financial, Vega,
Board must appear under all three tag groups β
"a doc appears under every group it belongs to" (PRD). So a scalar field
(document type) buckets a row once; a multi field (tags) buckets it
once per value, and a value-less row falls into a localized
sentinel bucket (untagged, unclassified,
unfiled, β¦) that sorts after the real values.
const { values, sentinel } = fieldMembership(doc, groupBy);
const targets = sentinel ? [sentinelBucket] : values.map(valueBucket);
for (const target of targets) bucket(target).rows.push(doc);
The consequence the tests pin: group membership counts sum
above the corpus size (a doc is counted in each group it joins),
yet visibleCount β the distinct rows β is unchanged. Both
numbers are true at once, and the engine reports both.
2. Mode-1 truth β nullable AI columns, not absent ones
Today the documents pipeline is Mode-1: deterministic
fields (filename, size, status, format) are real; the AI fields
(clean name, document type, tags, filing) are filled later by EPIC-009.
The wire DTO (DocumentListItem in src/gen)
carries only the Mode-1 fields. The trap is to model only what the wire
has and bolt the AI columns on later β which forks the type. Instead the
RegisterDocument row is the full shape with every
AI field nullable, and the adapter maps a Mode-1 DTO with those fields
set to null/[]. The row component reads the
null and renders a live "processingβ¦" label or a skeleton β the empty
first-run state is the designed state, not an afterthought.
Processing and
EmptyRegister stories), not faked with happy fixtures.
What shipped
Engine β src/lib/documents/register/ (pure)
| File | Role |
|---|---|
types.ts | RegisterDocument row, ViewState, and the RegisterModel / RenderGroup / SliceModel output shapes |
columns.ts | the column registry + field accessors: fieldMembership (group/slice, multi-aware) and sortValue |
view-state.ts | buildRegisterModel β the pipeline β plus defaultViewState and isViewEdited (the unsaved-dot) |
adapter.ts | wire DocumentListItem β Mode-1 RegisterDocument (AI fields nulled, format from MIME) |
saved-view.ts | ViewState β the persisted DocumentSavedView* contract; slice value + hierarchy ride the opaque filters jsonb |
fixtures.ts | a 14-row sample corpus shared by tests and stories β one source of truth |
Tests 40 passing
view-state.test.tsβ columns, ungrouped, single- & multi-membership grouping, slicing (value + sentinel), slice aggregation order, sorting (numeric / text / tiebreak), field-sum, hierarchy nesting, and every empty state.saved-view.test.tsβ wire β view-state round-trip, defensive drop of unknown keys, null field-sum.adapter.test.tsβ MIME β format and Mode-1 field nulling.
Leaf components β src/components/documents/register/ (Storybook-first)
Each is a pure renderer over the engine's output, storied before any live wiring (this Story does not touch a route).
| Component | Renders |
|---|---|
FormatBadge | the PDF / DOC / IMG / ZIP format chip |
DocumentStatusPill | the processing-status pill (status hues only) |
RegisterSliceNavigator | the left slicer β count-sorted, searchable, capped with "Show all N" |
RegisterGroupHeader | a group row β fold caret, localized label, field-sum summary |
RegisterColumnHeader | a header cell β sort caret + per-column menu trigger |
RegisterCell / RegisterDocumentRow | a document row β two names, processing skeletons, the column cells |
RegisterTable | the composition: the engine model drawn as a grouped, selectable table |
RegisterEmptyState | the three empty cases β new matter, empty saved view, no results |
House rules honored
- Mode discipline β every AI column is nullable and renders processing/empty in Mode 1; nothing here fills them (that is EPIC-009).
- i18n β all user-facing copy resolves via
next-intlunder the newDocumentsRegisternamespace, added to bothen.jsonandhi.json. The engine carries keys, never display strings. - Design tokens β no hex/rgb/hsl literals; semantic tokens only, status hues reserved for status.
- a11y β checkboxes, sort buttons, the column-menu trigger, fold carets, and slice rows are real buttons with labels,
aria-pressed/aria-sort/aria-expanded. - Contracts (HR-22) β the adapter and saved-view mapping consume generated
src/gentypes only; no hand-written wire mirrors, andpnpm codegenwas not run (owned by the codegen-sync PR).
Verify green
pnpm typecheck # clean
pnpm lint # clean (1 pre-existing unrelated warning)
pnpm test # 562 passed (40 new)
pnpm build # success
pnpm storybook:build # success
Observability (ADR-124): this slice adds no executable workflow boundary, route, job, or external call β it is a pure client engine + presentational leaves β so it is instrumentation-exempt; spans land when the live register page and its data fetch are wired in a later Story.
Files changed
30 file(s), +3016 / -0 lines
| Path | Role | + | - |
|---|---|---|---|
| messages/en.json | Config | +83 | -0 |
| messages/hi.json | Config | +83 | -0 |
| src/components/documents/register/document-status-pill.stories.tsx | Component | +37 | -0 |
| src/components/documents/register/document-status-pill.tsx | Component | +38 | -0 |
| src/components/documents/register/format-badge.stories.tsx | Component | +32 | -0 |
| src/components/documents/register/format-badge.tsx | Component | +29 | -0 |
| src/components/documents/register/presentation.ts | Component | +115 | -0 |
| src/components/documents/register/register-cell.tsx | Component | +118 | -0 |
| src/components/documents/register/register-column-header.stories.tsx | Component | +31 | -0 |
| src/components/documents/register/register-column-header.tsx | Component | +71 | -0 |
| src/components/documents/register/register-document-row.stories.tsx | Component | +46 | -0 |
| src/components/documents/register/register-document-row.tsx | Component | +99 | -0 |
| src/components/documents/register/register-empty-state.stories.tsx | Component | +17 | -0 |
| src/components/documents/register/register-empty-state.tsx | Component | +36 | -0 |
| src/components/documents/register/register-group-header.stories.tsx | Component | +40 | -0 |
| src/components/documents/register/register-group-header.tsx | Component | +67 | -0 |
| src/components/documents/register/register-slice-navigator.stories.tsx | Component | +45 | -0 |
| src/components/documents/register/register-slice-navigator.tsx | Component | +150 | -0 |
| src/components/documents/register/register-table.stories.tsx | Component | +59 | -0 |
| src/components/documents/register/register-table.tsx | Component | +181 | -0 |
| src/lib/documents/register/adapter.test.ts | Library | +64 | -0 |
| src/lib/documents/register/adapter.ts | Library | +72 | -0 |
| src/lib/documents/register/columns.ts | Library | +229 | -0 |
| src/lib/documents/register/fixtures.ts | Library | +197 | -0 |
| src/lib/documents/register/index.ts | Library | +35 | -0 |
| src/lib/documents/register/saved-view.test.ts | Library | +116 | -0 |
| src/lib/documents/register/saved-view.ts | Library | +136 | -0 |
| src/lib/documents/register/types.ts | Library | +209 | -0 |
| src/lib/documents/register/view-state.test.ts | Library | +264 | -0 |
| src/lib/documents/register/view-state.ts | Library | +317 | -0 |