Files
biggus-dickus/docs/superpowers/specs/2026-06-04-frontend-spa-milestone-3-design.md
T
logaritmisk f206ee8995 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) <noreply@anthropic.com>
2026-06-04 07:58:22 +02:00

7.5 KiB
Raw Blame History

Frontend SPA — Milestone 3 (Publishing Workflow) — Design

Date: 2026-06-04 Status: Approved (brainstorming) — ready for implementation planning.

Context

Milestones 12 (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:

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 <Link to={/objects/:id/edit}> 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.tsadjacentTransitions 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=public422 → 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).