PR: #373: feat(documents): ⚙ View popover + Save split for the register [Story-1511] by @ankitmaheshwari-com
View PR →
Story 1511 · EPIC-1489 Documents & Intake · DOCS-VIEWPOPOVER

The Documents Register ⚙ View popover + Save split

Story-1510 built a pure brain — a function from a corpus + a view-state to a grouped, counted, sorted render model. Story-1509 built the shell around it. This Story builds the steering wheel: the one ⚙ View popover a lawyer opens to reshape the table — Fields, Group by, Sort by, Field sum, Slice by, Show hierarchy — plus the GitHub-style unsaved dot and the Discard / Save split (Save to current · Save as new · Rename). Every control re-runs the engine, so the table redraws live; and the Save model lets a firm crystallise a reshaping into a named, reusable view.

🛰️ Satellite — why one popover, not a toolbar of controls

The naive build scatters the controls across the header: a Group-by dropdown here, a Sort menu there, six column checkboxes in a row, a hierarchy switch in the corner. It works at three controls and collapses at six — the header becomes a cockpit, the controls fight the saved-view pills for width, and there is no single place that answers "what is this view, and have I changed it?" The deeper failure is state honesty: with controls smeared everywhere, "you have unsaved edits" has no home, so a lawyer reshapes a saved view, navigates away, and silently loses the work — or worse, silently corrupts a shared firm view.

So the controls collapse into one popover that owns the whole view-state, and a single unsaved dot tells the truth about drift. WHY one popover: the view-state is one object; its editor should be one surface, and "is it edited?" should be one boolean (isViewEdited(view, baseline)) — not a guess assembled from six widgets. If WHY is not clear, confusion always remains: the popover exists so the table has exactly one steering wheel and exactly one "you have unsaved changes" light.

✈️ Airplane — the shape of the thing

Think of the popover as a two-drawer cabinet (the GitHub "Table options" model). The front face (L1) is a summary: one row per control showing its current value — Group by · Document type, Sort by · Uploaded ↓. Tap a row and a drawer slides out (L2) with the choices; pick one and the drawer closes, the engine re-runs, the table redraws. The hierarchy toggle needs no drawer — it flips in place. Underneath sits the footer: Discard on the left, a Save split-button on the right whose caret opens Save-to-current · Save-as-new · Rename.

The popover is a controlled component: it never owns the view-state, it only renders view and emits onChange(nextView). WHY controlled: the same view-state drives the table, the slicer, and the unsaved dot — if the popover kept a private copy they would drift. One source of truth lives in the controller above; the popover is a pure editor over it.

ViewPopover (controlled)
  L1 rows ── Fields · Group by · Sort by · Field sum · Slice by · [hierarchy toggle]
  L2 pickers (ViewSubPanel) ── single-select lists + the Fields checklist
  footer ── SaveViewSplitButton (Discard | Save ▾ {to current / as new / rename})

every edit → onChange(transform(view)) → engine re-runs → table redraws

🔬 Microscopic — the pieces, each titled by its WHY

The pure edit transforms — view-edits.ts (WHY: testable, not clickable)

Every control maps to one pure ViewState → ViewState function: setGroupBy, setSliceBy, setFieldSum, toggleHierarchy, setPrimarySort, toggleField. WHY pure: the claim "switching the slice field resets the active value to All" or "re-selecting the sort field flips its direction" is then a value comparison in a unit test — not a Storybook click. Two non-obvious rules live here, pinned by tests:

The leaf components (WHY: storied in isolation, before any live wiring)

ComponentRole
ViewPopoverRow / ViewToggleRowOne L1 summary row — drill (icon · label · value · chevron) or in-place toggle.
ViewOptionListA single-select L2 list (Group / Sort / Field sum / Slice). A check marks the active value; the sort list shows the ↑/↓ arrow.
ViewFieldListThe Fields L2 checklist — toggle column visibility; stays open for multiple toggles.
ViewSubPanelComposes the Back header + the right L2 body for a given panel.
SaveViewSplitButtonDiscard + the Save split (to current ⌘S / as new / rename).
NameViewDialogAccessible name-a-view dialog backing Rename and Save-as-new (replaces the mockup's prompt()).
ViewPopoverThe orchestrator: the ⚙ trigger (with unsaved dot) + L1 + L2 + footer.
RegisterViewThe controller harness: holds the view-state, runs the engine, renders popover + slicer + table — the live proof that controls redraw the table.

The unsaved dot (WHY: one boolean, told honestly)

The dot is not new state. It is isViewEdited(view, baseline) — a structural compare of the working view-state against the active saved view's persisted view-state (column order and visibility count as edits). It rides the ⚙ gear and the active saved-view pill. WHY derive, never store: a stored "dirty" flag drifts out of sync with the data it claims to describe; a derived one cannot lie.

The Save split (WHY: the GitHub crystallise-a-view model)

A reshaping is ephemeral until you name it. The split-button's primary verb saves to the current view; its caret offers Save changes to new view (names a fresh saved view) and Rename view. When there is no editable active view (no saved views yet, or a read-only firm view), canSaveToCurrent=false flips the primary verb to Save-as-new and disables current/rename. WHY a split, not two buttons: the common case (overwrite the view I'm in) is one click; the rarer cases stay one menu away.

Mode-1 discipline. The popover lists every register field — including the Mode-2/3 AI columns (document type, tags, …). In Mode-1 those columns render "processing"/empty rather than being hidden, so the steering wheel a lawyer learns today is the same one EPIC-009 fills in later. No column is conjured or removed by mode.

Acceptance criteria — how each is met

ACWhere
All controls re-render the tabledone RegisterView holds the view-state; every control flows onChange → buildRegisterModel → RegisterTable. Proven in the RegisterView story.
Unsaved dot indicatordone Derived via isViewEdited; on the ⚙ gear and the active pill.
Save / save-as / rename flowdone SaveViewSplitButton + NameViewDialog; wired in RegisterView.
Storybook story per leaf component (story before live wiring)done Eight *.stories.tsx across the leaves + the controller.
i18n (en + hi, no hardcoded strings)done New MatterDetail.documents.viewPopover namespace in both locales; field/mode labels reuse DocumentsRegister.*.
Design tokens (no hex/rgb/hsl)done Semantic tokens only; literal scan is clean.
a11y (keyboard, focus, ARIA)done Radix Popover/Dialog/Menu; role=switch/menuitemradio/menuitemcheckbox, aria-checked, visible focus rings.
Empty / zero statesdone Empty register and no-results-after-slice render the register's empty states; the RegisterView EmptyRegister story exercises the zero-document first run.

What this Story does not do

Durable saved-view persistence (create/update/rename against the backend) is SAVEDVIEWS-WIRE; here the create/rename path is local to the controller, with the seam called out. Replacing the live /matters/[id]/documents route's legacy register with this controller is the integration ticket — per the repo's "story before live wiring" rule, this Story ships the popover and proves it in Storybook against the real engine, not the authed live route. Filter popover and Upload sheet are sibling tickets.

Files changed

22 file(s), +1338 / -2 lines

Path Role + -
messages/en.jsonConfig+28-1
messages/hi.jsonConfig+28-1
src/components/documents/register/register-view.stories.tsxComponent+45-0
src/components/documents/register/register-view.tsxComponent+123-0
src/components/documents/register/view-popover/icons.tsComponent+56-0
src/components/documents/register/view-popover/name-view-dialog.stories.tsxComponent+42-0
src/components/documents/register/view-popover/name-view-dialog.tsxComponent+88-0
src/components/documents/register/view-popover/save-view-split-button.stories.tsxComponent+27-0
src/components/documents/register/view-popover/save-view-split-button.tsxComponent+93-0
src/components/documents/register/view-popover/view-field-list.stories.tsxComponent+39-0
src/components/documents/register/view-popover/view-field-list.tsxComponent+58-0
src/components/documents/register/view-popover/view-option-list.stories.tsxComponent+45-0
src/components/documents/register/view-popover/view-option-list.tsxComponent+55-0
src/components/documents/register/view-popover/view-popover-row.stories.tsxComponent+54-0
src/components/documents/register/view-popover/view-popover-row.tsxComponent+94-0
src/components/documents/register/view-popover/view-popover.stories.tsxComponent+41-0
src/components/documents/register/view-popover/view-popover.tsxComponent+141-0
src/components/documents/register/view-popover/view-sub-panel.stories.tsxComponent+29-0
src/components/documents/register/view-popover/view-sub-panel.tsxComponent+122-0
src/lib/documents/register/index.tsLibrary+8-0
src/lib/documents/register/view-edits.test.tsLibrary+62-0
src/lib/documents/register/view-edits.tsLibrary+60-0