PR: #604: feat(brief): forum as catalog-backed EditableChoice [Story-2358] by @ankitmaheshwari-com
View PR →

Story-2358: Forum as catalog-backed EditableChoice

The forum is where a matter is actually heard — the Gujarat Electricity Regulatory Commission, the Bombay High Court, NCLT Mumbai. It sits at the top of the Brief's Case particulars rail, and until this change it was a wall: Vega read a tribunal name out of the documents and printed it. If the framer misread the venue, or the matter moved, counsel had no way to say so.

This is the first catalog-backed Editable Surface. Every promotion before it (Key instruments, Governing law, Relief) edited a frame fact — free text counsel owns outright. The forum is different: it is a pointer into a firm-managed catalog. That single difference is the whole Story, and it is what made the obvious implementation wrong.

The gate that had to pass first — and did not

The Brief's FORUM block renders two lines, and they are not the same kind of thing:

src/components/matter-detail/brief/matter-brief-sections.tsx

const tribunal  = brief.canonical.forum?.name || textField(frame, "tribunal_name"); // line 1
const forumType = textField(frame, "forum_type") || matter.forumType || "";         // line 2
The trap. The obvious implementation is to make the forum an EditableChoice that writes the frame's tribunal_name. That is a silent swallow. The rail reads canonical.forum?.name first — so for exactly the matters that HAVE a forum, the frame edit is persisted and never displayed. The optimistic value paints, the invalidate lands, and the old name snaps back. This is the same trap already scarred into this very file for value-at-stake (matter-brief-page.tsx), which is why that field writes through the canonical matter record instead.

The real home of the forum is not the frame at all:

MatterBriefCanonicalView.forum
  → "The Main proceeding's forum (proceedings.forum_id -> forums);
     null when the Main proceeding has no forum set."

So the forum lives on proceedings.forum_id, pointing into the firm's forum catalog — the same catalog Settings already manages. To change it you must PATCH the proceeding, not the frame.

Line 2 has no catalog — and is out of scope

The muted second line (forum_type, e.g. "Domestic arbitration") looks like it wants a picker too. It does not get one here, because no firm catalog enumerates its vocabulary:

CatalogWhat is actually in itFits "Domestic arbitration"?
forumsSupreme Court of India, NCLT Mumbai, Sessions Court DelhiNo — these are venues (line 1)
proceeding-typesWritten submissions, Rejoinder, Affidavit of evidenceNo — these are filing kinds
matter-typesDomestic Arbitration, Writ Petition, Civil SuitYes — it is the matter type
Deliberately deferred. Backing line 2 with the forums or proceeding-types catalog would offer venues or rejoinders as forum types, and write that string into the forum_type frame fact — which feeds agent context and Postgres FTS. That is contract corruption, not a UI nit. The contract agrees the field is confused: MatterDetailView.forumType is documented as "Deprecated display compatibility value. Now derived from the firm matter type joined via matter_type_id." docs/design/review-editable-matter-frame.md §6 already flags this exact conflation as an open data-model cleanup. Line 2 stays read-only and is promoted under that ruling, not this Story.

What shipped

  1. The proxy, first. The generated useAppUpdateMatterProceeding mutation had no browser→server route, so it would have 404'd. Per AGENTS.md ("build the proxy as step one"): a new PATCH handler at /api/internal/matters/[matterId]/proceedings/[proceedingId], its toBrowserRoute mapping, and a mattersGateway.updateProceeding method. Inbound JSON is parsed with the generated request schema and the response is re-asserted against ProceedingView (HR-21) — no as T at the boundary.
  2. The picker. ForumChoice loads the firm's catalog via useAppListForums and writes the Main proceeding's forumId. The Main proceeding is found on the detail payload the page already holds (matter.proceedings.find(p => p.isMain)) — no extra fetch, and no backend change was needed.
  3. Search-first, on its own. Past 7 options EditableChoice grows a search box (ux-guidelines §1: a long blind list is the same trap as a blind text input). The caller never decides — there is no searchable prop to get wrong. Radix's menu typeahead would otherwise eat the keystrokes, so the search input stops propagation for everything except Arrow/Enter/Escape/Tab, which the menu still owns.
  4. Optimistic, with a rollback. On pick, the Brief cache's canonical.forum is patched immediately; the PATCH runs behind it; a rejection restores the snapshot and toasts. The PATCH carries only the id, so the picked name (which is what the rail renders) rides along in a ref.

The empty catalog — the state fixtures hide

A brand-new firm has zero forums. A picker that opens onto a blank menu reads as broken, not as "add one first". So EditableChoice gained an emptyContent slot, and the forum picker fills it with a link into the Settings forum catalog. This is the first-run state a mock-fixture-only pass never sees — AGENTS.md: "done" = system boundary, not component boundary; fixtures lie in your favour. It has its own Storybook story (EmptyCatalog), and the Brief page's own fixture was corrected in the same change: its proceedings array had no isMain flag, which would have silently rendered the picker read-only — the fixture lying, not the UI working.

Files changed

12 file(s), +615 / -35 lines

Path Role + -
messages/en.jsonConfig+3-0
messages/hi.jsonConfig+3-0
src/app/api/internal/matters/[matterId]/proceedings/[proceedingId]/route.tsApp route+64-0
src/components/matter-detail/brief/forum-choice.stories.tsxComponent+200-0
src/components/matter-detail/brief/forum-choice.tsxComponent+81-0
src/components/matter-detail/brief/matter-brief-page.stories.tsxComponent+35-2
src/components/matter-detail/brief/matter-brief-page.tsxComponent+55-1
src/components/matter-detail/brief/matter-brief-sections.tsxComponent+29-29
src/components/shared/editable-choice.stories.tsxComponent+42-0
src/components/shared/editable-choice.tsxComponent+71-3
src/lib/kubb-client.tsLibrary+13-0
src/lib/matters/gateway.tsLibrary+19-0

Storybook

StoryWhat it proves
MatterDetail/ForumChoice → Populated9 forums → the picker is search-first; a selected forum shows its name.
MatterDetail/ForumChoice → EmptyCatalogFirst run: no blank menu — a route out to Settings.
MatterDetail/ForumChoice → AdHocTribunalNo catalog row for "a retired judge sitting alone": Vega's extracted tribunal text survives as the placeholder, catalog still open.
MatterDetail/ForumChoice → ReadOnlyShown, never editable.
MatterDetail/ForumChoice → RollbackOnErrorPick applies instantly, server rejects, value snaps back.
Shared/EditableChoice → Searchable / EmptyCatalogThe two new capabilities on the primitive itself.

Honest scope