Saved views, actually saved
Story-1511 built the steering wheel — the ⚙ View popover that reshapes
the Documents Register, plus the Save split and the folder-pill bar. But
its persistence was a fiction: saved views lived in React
useState, so the moment a lawyer reloaded the page their
named views vanished. This Story welds that steering wheel to a real
engine: the document_saved_views API. Views now
persist and load per matter, can be starred
and made the default, and overflow into a searchable
"More" menu — all backed by the firm-tenant backend (FN-SAVEDVIEWS).
🛰️ Satellite — why a saved view that doesn't survive a reload is worse than no saved view
A saved view is a promise: "shape this 4,000-document register the way I think about this case, name it, and it will be here tomorrow." Break that promise and you have built a trap. A lawyer spends ten minutes composing "Financial evidence — grouped by tag, sorted by date," names it, trusts it, and the next morning it is gone. The naive fix — "just keep the views in component state" — is exactly what Story-1511 shipped as a deliberate stub, and it is the door welded shut wearing a "done" badge: it demos green and is broken on the first real run.
The deeper requirement is shared, durable, firm-tenant truth. Saved views are not a personal scratchpad; a firm's "Privileged docs" view should be the same object for every lawyer on the matter, owned by the firm, scoped to the matter, and edited through one authoritative contract. That is a database row, not a closure variable.
✈️ Airplane — the BFF round-trip, the same one every list surface flies
PRAMAAN has exactly one shape for reading and writing a list (the
"forum pattern"): the browser never talks to the backend directly. It
calls a same-origin /api/internal/* proxy; the proxy's
server-side gateway holds the secrets, calls
pramaan-functions, and parses the response against the
generated contract before anyone trusts it. The generated TanStack
hooks (useAppListDocumentSavedViews,
useAppCreate/Update/DeleteDocumentSavedView) drive it; a
successful write invalidates the list so the cache reconciles.
browser ──useAppListDocumentSavedViews(matterId)──▶ /api/internal/matters/{id}/document-saved-views
(kubb-client toBrowserRoute rewrites the absolute /app/... path) │
▼
documentSavedViewsGateway.list(matterId)
→ gateway.get → pramaan-functions
→ assertContract(DocumentSavedViewListResponse) (HR-21)
The one analogy that holds all the way down: the saved view is a
recipe card. The wire row stores the ingredients
(fields, groupBy, sliceBy,
sort, fieldSum) plus a small opaque
filters jam jar for the two things that have no first-class
column — the active slice value and the hierarchy toggle. The
register engine cooks from the card; it never reads the database.
🔬 Microscope — the seams that make it real
The proxy + gateway (the new trust boundary)
src/lib/document-saved-views/gateway.ts is server-only and
per-matter: list / create / update / remove, each parsing
the response with assertContract so a backend drift logs
api_contract_violation rather than corrupting the UI. The
route handlers under
src/app/api/internal/matters/[matterId]/document-saved-views/
mirror the forums CRUD shape (GET/POST on the collection, PATCH/DELETE on
the member), and kubb-client.ts rewrites the generated
absolute path onto them. WHY a regex, placed before the bare
/app/matters rule: the nested saved-view path also
begins with /app/matters/, so it has to be matched first or
the matter-list rule would swallow it.
The contract ↔ view-state mappers
Story-1510 already wrote the bridge; this Story extends it.
fromSavedView now accepts either the single-entity or the
list-row contract (they are structurally identical), and a new
toSavedViewUpdateRequest emits the full view-state PATCH for
"save changes to current view." WHY the mapper never touches
name/starred/pinned: those
are flag-only edits owned by the call site, so a rename never
accidentally overwrites the reshaping and a star never rewrites the
columns.
Lifting persistence out of the controller
RegisterView stops owning the saved-view list. It is now told
savedViews + activeViewId (the server truth) and
handed callbacks for every mutation; the only state it keeps is the
working draft the popover edits and the rename / delete
dialogs. SavedViewsRegister is the new container that fills
those props from the generated hooks. WHY lift it: a
component that mutates its own copy of the list can never reconcile with
the server — the forum pattern demands the cache is the source of truth.
Star vs default — two real fields, not a fiction
The contract carries starred and pinned; this
Story maps the AC's "star/default" onto them honestly. Star
(starred) is the amber favourite that keeps a pill inline.
Default (pinned) is the single view that
loads first when you open Documents — setting a new default clears the
previous one, and the container picks the pinned view (else the first) as
the active view on load. Both are reachable from each pill's
⋯ menu (SavedViewActionsMenu), inline and in the
overflow.
SavedViewActionsMenu,
DocumentsViewPills With per-view actions) and the
full controller in the RegisterView harness story (persist /
load / star / default / rename / delete, including the zero-views first
run). Named seam: mounting SavedViewsRegister
on the live /matters/[id]/documents route — replacing the
legacy DocumentRegister, with an RSC prefetch +
HydrationBoundary for first paint — is the register-integration
follow-up flagged by the DOCS-VIEWPOPOVER PR; this Story ships the wired
data layer + container it will mount.
What shipped
| Acceptance criterion | Where | |
|---|---|---|
| Views persist + load | gateway + proxy routes + useAppListDocumentSavedViews / create, mapped via fromSavedView | done |
| Star / default | SavedViewActionsMenu → updateView {starred} / {pinned} (single default) | done |
| Overflow | DocumentsViewPills searchable "More" + per-row ⋯ actions | done |
| Per-matter scope | matterId in every path; backend authorizes by firm | done |
| Storybook per leaf (story before live wiring) | saved-view-actions-menu.stories, documents-view-pills.stories, register-view.stories | done |
| i18n (en + hi) · tokens · a11y | MatterDetail.documents.* both locales; semantic tokens only; Radix menu/dialog, ARIA-labelled ⋯ | done |
Verify: pnpm lint (0 errors), pnpm test (605
passed), pnpm build ✓, i18n parity ✓, color-literal scan
clean, git diff --check ✓.
Files changed
17 file(s), +965 / -67 lines
| Path | Role | + | - |
|---|---|---|---|
| messages/en.json | Config | +18 | -0 |
| messages/hi.json | Config | +18 | -0 |
| src/app/api/internal/matters/[matterId]/document-saved-views/[viewId]/route.ts | App route | +84 | -0 |
| src/app/api/internal/matters/[matterId]/document-saved-views/route.ts | App route | +63 | -0 |
| src/components/documents/register/register-view.stories.tsx | Component | +63 | -8 |
| src/components/documents/register/register-view.tsx | Component | +134 | -36 |
| src/components/documents/register/saved-views-register.tsx | Component | +194 | -0 |
| src/components/documents/register/view-popover/saved-view-actions-menu.stories.tsx | Component | +33 | -0 |
| src/components/documents/register/view-popover/saved-view-actions-menu.tsx | Component | +90 | -0 |
| src/components/matter-detail/documents/documents-view-pills.stories.tsx | Component | +19 | -0 |
| src/components/matter-detail/documents/documents-view-pills.tsx | Component | +65 | -21 |
| src/lib/document-saved-views/gateway.ts | Library | +96 | -0 |
| src/lib/document-saved-views/types.ts | Library | +15 | -0 |
| src/lib/documents/register/index.ts | Library | +2 | -0 |
| src/lib/documents/register/saved-view.test.ts | Library | +30 | -1 |
| src/lib/documents/register/saved-view.ts | Library | +30 | -1 |
| src/lib/kubb-client.ts | Library | +11 | -0 |