PR: #369: feat(documents): register view-state engine + leaf components [Story-1510] by @ankitmaheshwari-com
View PR →
Story 1510 Β· EPIC-1489 Documents & Intake Β· DOCS-VIEWENGINE

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
StageWhat it doesWHY it is here
resolve columnsview.fields (ordered + visible) β†’ the visible ColumnDescriptor[]the table renders exactly these, in this order β€” column hide/move is just a fields edit
aggregate slicerover the whole corpus, per slice-field value β†’ count + size, count-descthe slice navigator shows totals for values you have not selected, so it can't be computed from the filtered set
slice filterkeep rows whose slice-field value (or sentinel) matches the selection"Financial" is a filter, not a folder β€” dynamic, not physical
sortordered clauses, numeric vs. locale-text, asc/desc, tiebreaksone comparator the table and every group share
groupbucket rows by the group field; multi-membership for tagsthe key correctness case β€” see Microscope
summarizecount + total bytes per group and grand totalthe 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.

Why this matters: "done" is the system boundary, not the component boundary. The register is born against an empty, unclassified corpus; the processing/empty rendering is the real first run, so it is modeled and storied (the Processing and EmptyRegister stories), not faked with happy fixtures.

What shipped

Engine β€” src/lib/documents/register/ (pure)

FileRole
types.tsRegisterDocument row, ViewState, and the RegisterModel / RenderGroup / SliceModel output shapes
columns.tsthe column registry + field accessors: fieldMembership (group/slice, multi-aware) and sortValue
view-state.tsbuildRegisterModel β€” the pipeline β€” plus defaultViewState and isViewEdited (the unsaved-dot)
adapter.tswire DocumentListItem β†’ Mode-1 RegisterDocument (AI fields nulled, format from MIME)
saved-view.tsViewState ↔ the persisted DocumentSavedView* contract; slice value + hierarchy ride the opaque filters jsonb
fixtures.tsa 14-row sample corpus shared by tests and stories β€” one source of truth

Tests 40 passing

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).

ComponentRenders
FormatBadgethe PDF / DOC / IMG / ZIP format chip
DocumentStatusPillthe processing-status pill (status hues only)
RegisterSliceNavigatorthe left slicer β€” count-sorted, searchable, capped with "Show all N"
RegisterGroupHeadera group row β€” fold caret, localized label, field-sum summary
RegisterColumnHeadera header cell β€” sort caret + per-column menu trigger
RegisterCell / RegisterDocumentRowa document row β€” two names, processing skeletons, the column cells
RegisterTablethe composition: the engine model drawn as a grouped, selectable table
RegisterEmptyStatethe three empty cases β€” new matter, empty saved view, no results

House rules honored

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.jsonConfig+83-0
messages/hi.jsonConfig+83-0
src/components/documents/register/document-status-pill.stories.tsxComponent+37-0
src/components/documents/register/document-status-pill.tsxComponent+38-0
src/components/documents/register/format-badge.stories.tsxComponent+32-0
src/components/documents/register/format-badge.tsxComponent+29-0
src/components/documents/register/presentation.tsComponent+115-0
src/components/documents/register/register-cell.tsxComponent+118-0
src/components/documents/register/register-column-header.stories.tsxComponent+31-0
src/components/documents/register/register-column-header.tsxComponent+71-0
src/components/documents/register/register-document-row.stories.tsxComponent+46-0
src/components/documents/register/register-document-row.tsxComponent+99-0
src/components/documents/register/register-empty-state.stories.tsxComponent+17-0
src/components/documents/register/register-empty-state.tsxComponent+36-0
src/components/documents/register/register-group-header.stories.tsxComponent+40-0
src/components/documents/register/register-group-header.tsxComponent+67-0
src/components/documents/register/register-slice-navigator.stories.tsxComponent+45-0
src/components/documents/register/register-slice-navigator.tsxComponent+150-0
src/components/documents/register/register-table.stories.tsxComponent+59-0
src/components/documents/register/register-table.tsxComponent+181-0
src/lib/documents/register/adapter.test.tsLibrary+64-0
src/lib/documents/register/adapter.tsLibrary+72-0
src/lib/documents/register/columns.tsLibrary+229-0
src/lib/documents/register/fixtures.tsLibrary+197-0
src/lib/documents/register/index.tsLibrary+35-0
src/lib/documents/register/saved-view.test.tsLibrary+116-0
src/lib/documents/register/saved-view.tsLibrary+136-0
src/lib/documents/register/types.tsLibrary+209-0
src/lib/documents/register/view-state.test.tsLibrary+264-0
src/lib/documents/register/view-state.tsLibrary+317-0