From f206ee8995e22e7eb13d6fd9465cff7625693e2d Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 4 Jun 2026 07:58:22 +0200 Subject: [PATCH] docs(spec): frontend SPA milestone 3 (publishing workflow) design Segmented Draft->Internal->Public stepper on the object detail; legal one-step moves only; confirm on ->Public; surfaces the 422 publish-gate (generic + Edit link) and 409 illegal-transition; useSetVisibility + adjacentTransitions helper; Vitest+RTL+MSW. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-06-04-frontend-spa-milestone-3-design.md | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-04-frontend-spa-milestone-3-design.md diff --git a/docs/superpowers/specs/2026-06-04-frontend-spa-milestone-3-design.md b/docs/superpowers/specs/2026-06-04-frontend-spa-milestone-3-design.md new file mode 100644 index 0000000..625211a --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-frontend-spa-milestone-3-design.md @@ -0,0 +1,160 @@ +# 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).