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

161 lines
7.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`:
```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.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).