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>
This commit is contained in:
@@ -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
|
||||||
|
`<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).
|
||||||
Reference in New Issue
Block a user