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:
- setSliceBy clears the selection. Changing the slice field abandons the old field's active value, so the navigator falls back to "All". WHY: a value from the old field is meaningless under the new one.
- setPrimarySort is a toggle. Pick a new field → sort descending; pick the field already sorted → flip its direction. WHY: a single-sort surface needs a way to reach ascending, and a second click is the cheapest gesture.
The leaf components (WHY: storied in isolation, before any live wiring)
| Component | Role |
|---|---|
ViewPopoverRow / ViewToggleRow | One L1 summary row — drill (icon · label · value · chevron) or in-place toggle. |
ViewOptionList | A single-select L2 list (Group / Sort / Field sum / Slice). A check marks the active value; the sort list shows the ↑/↓ arrow. |
ViewFieldList | The Fields L2 checklist — toggle column visibility; stays open for multiple toggles. |
ViewSubPanel | Composes the Back header + the right L2 body for a given panel. |
SaveViewSplitButton | Discard + the Save split (to current ⌘S / as new / rename). |
NameViewDialog | Accessible name-a-view dialog backing Rename and Save-as-new (replaces the mockup's prompt()). |
ViewPopover | The orchestrator: the ⚙ trigger (with unsaved dot) + L1 + L2 + footer. |
RegisterView | The 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.
Acceptance criteria — how each is met
| AC | Where |
|---|---|
| All controls re-render the table | done RegisterView holds the view-state; every control flows onChange → buildRegisterModel → RegisterTable. Proven in the RegisterView story. |
| Unsaved dot indicator | done Derived via isViewEdited; on the ⚙ gear and the active pill. |
| Save / save-as / rename flow | done 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 states | done 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.json | Config | +28 | -1 |
| messages/hi.json | Config | +28 | -1 |
| src/components/documents/register/register-view.stories.tsx | Component | +45 | -0 |
| src/components/documents/register/register-view.tsx | Component | +123 | -0 |
| src/components/documents/register/view-popover/icons.ts | Component | +56 | -0 |
| src/components/documents/register/view-popover/name-view-dialog.stories.tsx | Component | +42 | -0 |
| src/components/documents/register/view-popover/name-view-dialog.tsx | Component | +88 | -0 |
| src/components/documents/register/view-popover/save-view-split-button.stories.tsx | Component | +27 | -0 |
| src/components/documents/register/view-popover/save-view-split-button.tsx | Component | +93 | -0 |
| src/components/documents/register/view-popover/view-field-list.stories.tsx | Component | +39 | -0 |
| src/components/documents/register/view-popover/view-field-list.tsx | Component | +58 | -0 |
| src/components/documents/register/view-popover/view-option-list.stories.tsx | Component | +45 | -0 |
| src/components/documents/register/view-popover/view-option-list.tsx | Component | +55 | -0 |
| src/components/documents/register/view-popover/view-popover-row.stories.tsx | Component | +54 | -0 |
| src/components/documents/register/view-popover/view-popover-row.tsx | Component | +94 | -0 |
| src/components/documents/register/view-popover/view-popover.stories.tsx | Component | +41 | -0 |
| src/components/documents/register/view-popover/view-popover.tsx | Component | +141 | -0 |
| src/components/documents/register/view-popover/view-sub-panel.stories.tsx | Component | +29 | -0 |
| src/components/documents/register/view-popover/view-sub-panel.tsx | Component | +122 | -0 |
| src/lib/documents/register/index.ts | Library | +8 | -0 |
| src/lib/documents/register/view-edits.test.ts | Library | +62 | -0 |
| src/lib/documents/register/view-edits.ts | Library | +60 | -0 |