PR: #376: feat(documents): wire saved views to the document_saved_views API [Story-1517] by @ankitmaheshwari-com
View PR →
Story 1517 · EPIC-1489 Documents & Intake · DOCS-SAVEDVIEWS-WIRE

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.

System boundary, not component boundary. The proxy, gateway, contract parse, generated hooks and invalidation are the real shipped path — not fixtures. Per "story before live wiring," each leaf is verified in Storybook (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 criterionWhere
Views persist + loadgateway + proxy routes + useAppListDocumentSavedViews / create, mapped via fromSavedViewdone
Star / defaultSavedViewActionsMenu → updateView {starred} / {pinned} (single default)done
OverflowDocumentsViewPills searchable "More" + per-row ⋯ actionsdone
Per-matter scopematterId in every path; backend authorizes by firmdone
Storybook per leaf (story before live wiring)saved-view-actions-menu.stories, documents-view-pills.stories, register-view.storiesdone
i18n (en + hi) · tokens · a11yMatterDetail.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.jsonConfig+18-0
messages/hi.jsonConfig+18-0
src/app/api/internal/matters/[matterId]/document-saved-views/[viewId]/route.tsApp route+84-0
src/app/api/internal/matters/[matterId]/document-saved-views/route.tsApp route+63-0
src/components/documents/register/register-view.stories.tsxComponent+63-8
src/components/documents/register/register-view.tsxComponent+134-36
src/components/documents/register/saved-views-register.tsxComponent+194-0
src/components/documents/register/view-popover/saved-view-actions-menu.stories.tsxComponent+33-0
src/components/documents/register/view-popover/saved-view-actions-menu.tsxComponent+90-0
src/components/matter-detail/documents/documents-view-pills.stories.tsxComponent+19-0
src/components/matter-detail/documents/documents-view-pills.tsxComponent+65-21
src/lib/document-saved-views/gateway.tsLibrary+96-0
src/lib/document-saved-views/types.tsLibrary+15-0
src/lib/documents/register/index.tsLibrary+2-0
src/lib/documents/register/saved-view.test.tsLibrary+30-1
src/lib/documents/register/saved-view.tsLibrary+30-1
src/lib/kubb-client.tsLibrary+11-0