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>
7.5 KiB
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
VisibilityBadgein the detail header alongside the new stepper.
Backend contract (already shipped — verify against web/src/api/schema.d.ts)
POST /api/admin/objects/{id}/visibilitybodyVisibilityRequest { visibility }→204on success;404if the object is missing;409on an illegal transition (skipping a step);422if 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 areDraft↔InternalandInternal↔Public(one step each).Draft→PublicandPublic→Draftare 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" };
}
}
forwardtopublicis 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}/visibilitywith{ 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.visibilityand computesadjacentTransitions. - 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 firesuseSetVisibility.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.rejectedmessage.
- On success, query invalidation refreshes the object → the stepper + the header
VisibilityBadgereflect 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—adjacentTransitionsfor 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=internalimmediately (no confirm dialog).
- MSW: a configurable
POST /api/admin/objects/:id/visibilityhandler (default 204; per-test overrides for 422/409).
Acceptance criteria (Milestone 3 "done")
- The object detail shows a Draft→Internal→Public stepper with the current stage highlighted and only legal one-step buttons.
- Advancing/retracting a step (except →Public) immediately POSTs the new visibility and the badge/stepper update.
- Publishing to Public requires a confirmation; confirming POSTs
visibility=public. - A publish-gate failure (422) shows an inline "required fields missing" message + an Edit link, leaving the record unchanged.
- The UI never offers an illegal (skip) transition; a 409 is handled defensively.
- 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).