# Frontend SPA — Milestone 3 (Publishing Workflow) — Design **Date:** 2026-06-04 **Status:** Approved (brainstorming) — ready for implementation planning. ## Context Milestones 1–2 (merged to `main` at `bb05331`) delivered the SPA foundation, the read-only two-pane Objects screen, and full object authoring (create/edit/delete + the dynamic flexible-field form). Milestone 3 adds the **publishing workflow** — driving a record through the stepwise `Draft → Internal → Public` visibility pipeline. This is **pure frontend**: the backend `POST /api/admin/objects/{id}/visibility` endpoint and the stepwise state machine already exist (the publish gate was added under issue #16). Milestone roadmap: M1 foundation → M2 authoring → **M3 publish workflow (this)** → M4 vocabulary/authority management → M5 search. ## Decisions (settled during brainstorming) - **Publish control = a segmented stepper** (Draft → Internal → Public) on the object **detail read view**, with the current stage highlighted; it offers only **legal one-step** moves (forward/back). - **Confirm only on → Public** (an `AlertDialog`, reusing the M2 shadcn one), since publishing makes the record externally visible; all other steps fire immediately. - The **422 publish-gate** failure shows a generic inline message + an Edit link (the backend 422 is bare; per-field detail is deferred to issue #28). - Keep the existing `VisibilityBadge` in the detail header alongside the new stepper. ## Backend contract (already shipped — verify against `web/src/api/schema.d.ts`) - `POST /api/admin/objects/{id}/visibility` body `VisibilityRequest { visibility }` → `204` on success; `404` if the object is missing; `409` on an illegal transition (skipping a step); `422` if publishing to Public with required fields missing (the publish gate). The 422 body is **bare** (no per-field detail). - **State machine** (`domain::Visibility::can_transition_to`): legal moves are `Draft↔Internal` and `Internal↔Public` (one step each). `Draft→Public` and `Public→Draft` are illegal. Setting to the current value is a no-op (allowed). - The publish **gate** (422) fires only on a transition **into Public** (`Internal → Public`). Backward/internal moves have no gate. ## Scope (YAGNI) **In:** the `PublishControl` stepper on the object detail; `useSetVisibility` mutation; the `adjacentTransitions` helper; confirm-on-→Public; inline surfacing of the 422 gate (with an Edit link) and the 409 illegal-transition (defensive); cache invalidation so the badge/stepper refresh after a transition. **Out:** per-field gate detail (bare 422 → generic message, tracked by #28); visibility controls in the edit form (visibility stays separate from core/flexible editing, per the M2 design); vocabulary/authority management (M4); search (M5). ## Architecture ### Transition adjacency (pure helper) `web/src/objects/transitions.ts`: ```ts export type Visibility = "draft" | "internal" | "public"; /** The legal one-step moves from a given visibility, per the backend state machine. */ export function adjacentTransitions(v: Visibility): { forward?: Visibility; back?: Visibility } { switch (v) { case "draft": return { forward: "internal" }; case "internal": return { forward: "public", back: "draft" }; case "public": return { back: "internal" }; } } ``` - `forward` to `public` is the only move that is gated + confirmed. - Unit-tested in isolation (all three states). ### Data layer `web/src/api/queries.ts` + `useSetVisibility`: - `POST /api/admin/objects/{id}/visibility` with `{ visibility }`. - On non-`204`, throw an error that carries the **status** so the UI can distinguish: `409` (illegal), `422` (gate), other. (e.g. `throw Object.assign(new Error("visibility"), { status })`, or a small typed error class.) - `onSuccess`: invalidate `["object", id]` and `["objects"]`. ### Component — `web/src/objects/publish-control.tsx` `PublishControl({ object })`: - Reads `object.visibility` and computes `adjacentTransitions`. - Renders a **3-segment stepper** (Draft / Internal / Public): segments before the current = "done", current = highlighted, after = pending. - Renders the legal step buttons with contextual labels: - forward to Internal → "Advance to internal" (or "→ Internal") - forward to Public → "Publish →" (opens the confirm dialog) - back to Draft → "← Back to draft" - back to Internal → "Unpublish to internal" - **Confirm on → Public:** clicking "Publish →" opens an `AlertDialog` ("This will make the record publicly visible…") with Cancel + a confirm Action that fires `useSetVisibility.mutate({ id, visibility: "public" })`. All other buttons fire the mutation immediately. - **Mutation states:** while pending, disable the buttons. On error, branch on `error.status`: - `422` → inline message "Can't publish — required fields are missing." + a `` Edit link. - `409` → inline "That visibility change isn't allowed." (defensive; the UI only offers legal steps). - other → the generic `form.rejected` message. - On success, query invalidation refreshes the object → the stepper + the header `VisibilityBadge` reflect the new state automatically. Rendered in `object-detail.tsx` as a new section below the header. The existing `VisibilityBadge` remains in the header. ## Error handling | Outcome | UI | |---|---| | `204` | invalidate → stepper + badge update to the new state | | `422` (gate, only on →Public) | inline error + Edit link; state unchanged | | `409` (illegal) | inline error (defensive) | | pending | step buttons disabled | | `404` | (object already absent) generic error; the detail itself would 404 on reload | ## Testing (Vitest + RTL + MSW) - `transitions.test.ts` — `adjacentTransitions` for draft/internal/public. - `publish-control.test.tsx`: - Stepper renders the current stage highlighted. - Draft → only a forward (Internal) button, no back. - Internal → forward (Publish) + back (Draft) buttons. - Public → only a back (Internal) button. - Draft → Internal: clicking forward POSTs `visibility=internal` (204) — assert the request body; success path. - Internal → Public: clicking "Publish →" opens the confirm dialog; confirming POSTs `visibility=public`. - **Gate:** POST `visibility=public` → `422` → inline gate error + Edit link visible; no navigation. - Public → Internal: clicking back POSTs `visibility=internal` immediately (no confirm dialog). - MSW: a configurable `POST /api/admin/objects/:id/visibility` handler (default 204; per-test overrides for 422/409). ## Acceptance criteria (Milestone 3 "done") 1. The object detail shows a Draft→Internal→Public stepper with the current stage highlighted and only legal one-step buttons. 2. Advancing/retracting a step (except →Public) immediately POSTs the new visibility and the badge/stepper update. 3. Publishing to Public requires a confirmation; confirming POSTs `visibility=public`. 4. A publish-gate failure (422) shows an inline "required fields missing" message + an Edit link, leaving the record unchanged. 5. The UI never offers an illegal (skip) transition; a 409 is handled defensively. 6. Web CI green (typecheck, lint, tests, build, bundle ≤150 KB gz). ## Out of scope / follow-ups - Per-field publish-gate detail requires the backend 422 to carry field info (#28). - Audit/history view of visibility changes (a later milestone; the backend already audits transitions). - A public-facing collection site is post-MVP (the public read API exists; no UI here).