diff --git a/docs/superpowers/plans/2026-06-04-frontend-spa-milestone-3.md b/docs/superpowers/plans/2026-06-04-frontend-spa-milestone-3.md new file mode 100644 index 0000000..10a3d78 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-frontend-spa-milestone-3.md @@ -0,0 +1,482 @@ +# Frontend SPA — Milestone 3 (Publishing Workflow) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Drive a record through the stepwise Draft→Internal→Public visibility pipeline from the SPA via a segmented stepper on the object detail, with confirm-on-publish and the publish-gate surfaced. + +**Architecture:** A pure `adjacentTransitions(visibility)` helper encodes the legal one-step moves; a `useSetVisibility` mutation POSTs to the existing `/api/admin/objects/{id}/visibility` endpoint (throwing a status-carrying error so the UI can branch 422-gate vs 409-illegal); a `PublishControl` component renders a 3-segment stepper + the legal step buttons, confirms only on →Public (reusing the M2 AlertDialog), surfaces the gate/illegal errors inline, and relies on query invalidation to refresh. Rendered on the object detail read view. + +**Tech Stack:** React 19, react-router-dom 7, @tanstack/react-query 5, openapi-fetch typed client, shadcn AlertDialog, react-i18next, Vitest + RTL + MSW. + +**Reference spec:** `docs/superpowers/specs/2026-06-04-frontend-spa-milestone-3-design.md` + +**Baseline (M1+M2, merged @ `f206ee8`):** `web/src/api/queries.ts` has the object/authoring hooks (`useObject`, `useObjectsPage`, mutations) and the `api` typed client; `web/src/objects/object-detail.tsx` renders the read view with a `VisibilityBadge` in its header; `web/src/objects/visibility-badge.tsx` maps `draft|internal|public` → an i18n'd badge; `renderApp` helper (MemoryRouter + QueryClient); MSW harness (`web/src/test/{server,handlers,fixtures}.ts`, `onUnhandledRequest:"error"`); i18n `web/src/i18n/{en,sv}.json` with `visibility.{draft,internal,public}`, `form.cancel`, `form.rejected`; shadcn AlertDialog at `@/components/ui/alert-dialog`. 34 tests green; bundle ~140 KB gz (budget 150). Run web commands from `web/`. + +**Conventions:** i18n every user-facing string via `t()`, en/sv key parity; NO `any`/`eslint-disable`/`@ts-ignore` (codebase has none); codename "biggus"/"dickus" NOWHERE; each task ends green (`pnpm test`, `pnpm typecheck`, `pnpm lint`, `pnpm build`). + +**Backend contract (verify against `web/src/api/schema.d.ts`):** +- `POST /api/admin/objects/{id}/visibility` body `VisibilityRequest { visibility }` → `204`; `404` missing; `409` illegal transition; `422` publish-gate (missing required fields, bare body). +- State machine: `Draft↔Internal`, `Internal↔Public` (one step); `Draft→Public`/`Public→Draft` illegal. Gate (422) only on `Internal→Public`. + +--- + +## Task 1: `adjacentTransitions` helper + `useSetVisibility` hook + MSW handler + +**Files:** +- Create: `web/src/objects/transitions.ts`, `web/src/objects/transitions.test.ts` +- Modify: `web/src/api/queries.ts`, `web/src/test/handlers.ts` +- Test: `web/src/api/queries.visibility.test.tsx` + +- [ ] **Step 1: Write the failing transitions test** `web/src/objects/transitions.test.ts` + +```ts +import { expect, test } from "vitest"; +import { adjacentTransitions } from "./transitions"; + +test("draft can only go forward to internal", () => { + expect(adjacentTransitions("draft")).toEqual({ forward: "internal" }); +}); + +test("internal can go forward to public and back to draft", () => { + expect(adjacentTransitions("internal")).toEqual({ forward: "public", back: "draft" }); +}); + +test("public can only go back to internal", () => { + expect(adjacentTransitions("public")).toEqual({ back: "internal" }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** — `pnpm test src/objects/transitions.test.ts` → FAIL (no module). + +- [ ] **Step 3: Implement** `web/src/objects/transitions.ts` + +```ts +export type Visibility = "draft" | "internal" | "public"; + +/** The legal one-step visibility moves from `v`, per the backend state machine + * (Draft<->Internal, Internal<->Public; no skipping). */ +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" }; + } +} +``` + +- [ ] **Step 4: Run to verify it passes** — `pnpm test src/objects/transitions.test.ts` → PASS (3). + +- [ ] **Step 5: Add the MSW handler** — append to the `handlers` array in `web/src/test/handlers.ts`: + +```ts + http.post("/api/admin/objects/:id/visibility", () => new HttpResponse(null, { status: 204 })), +``` + +- [ ] **Step 6: Write the failing hook test** `web/src/api/queries.visibility.test.tsx` + +```tsx +import { describe, expect, test } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { http, HttpResponse } from "msw"; +import type { ReactNode } from "react"; +import { server } from "../test/server"; +import { useSetVisibility } from "./queries"; + +function wrapper({ children }: { children: ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {children}; +} + +describe("useSetVisibility", () => { + test("POSTs the target visibility and resolves on 204", async () => { + let body: unknown; + server.use( + http.post("/api/admin/objects/:id/visibility", async ({ request }) => { + body = await request.json(); + return new HttpResponse(null, { status: 204 }); + }), + ); + const { result } = renderHook(() => useSetVisibility(), { wrapper }); + await result.current.mutateAsync({ id: "o1", visibility: "internal" }); + expect((body as { visibility: string }).visibility).toBe("internal"); + }); + + test("throws a status-carrying error on 422 (publish gate)", async () => { + server.use( + http.post("/api/admin/objects/:id/visibility", () => new HttpResponse(null, { status: 422 })), + ); + const { result } = renderHook(() => useSetVisibility(), { wrapper }); + await expect( + result.current.mutateAsync({ id: "o1", visibility: "public" }), + ).rejects.toMatchObject({ status: 422 }); + }); +}); +``` + +- [ ] **Step 7: Run to verify it fails** — `pnpm test src/api/queries.visibility.test.tsx` → FAIL (no `useSetVisibility`). + +- [ ] **Step 8: Implement** — append to `web/src/api/queries.ts`: + +```ts +import type { Visibility } from "../objects/transitions"; + +/** Error carrying the HTTP status so callers can branch 422-gate vs 409-illegal. */ +export class VisibilityError extends Error { + constructor(public status: number) { + super(`visibility change failed (${status})`); + this.name = "VisibilityError"; + } +} + +export function useSetVisibility() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ id, visibility }: { id: string; visibility: Visibility }) => { + const { response } = await api.POST("/api/admin/objects/{id}/visibility", { + params: { path: { id } }, + body: { visibility }, + }); + if (response.status !== 204) throw new VisibilityError(response.status); + }, + onSuccess: (_result, { id }) => { + void qc.invalidateQueries({ queryKey: ["object", id] }); + void qc.invalidateQueries({ queryKey: ["objects"] }); + }, + }); +} +``` +(Confirm the generated body type for `VisibilityRequest`: if `visibility` is typed as the `Visibility` union the literal works directly; if it's typed as a bare `string`, the union is still assignable. The path key is literally `/api/admin/objects/{id}/visibility`. Reuse the existing `useMutation`/`useQueryClient`/`api`/`components` imports at the top of queries.ts. If importing `Visibility` from `../objects/transitions` creates an undesirable api→objects import direction, instead define the union inline as `"draft" | "internal" | "public"` in queries.ts and keep `transitions.ts`'s `Visibility` separate — pick whichever keeps imports clean; the union value is the contract.) + +- [ ] **Step 9: Run** — `pnpm test src/api/queries.visibility.test.tsx` → PASS (2). Then full `pnpm test`, `pnpm typecheck`, `pnpm lint`, `pnpm build` → clean. + +- [ ] **Step 10: Commit** + +```bash +cd .. +git add web +git commit -m "feat(web): useSetVisibility hook + adjacentTransitions helper + MSW handler" +``` + +--- + +## Task 2: `PublishControl` stepper component + +**Files:** +- Create: `web/src/objects/publish-control.tsx`, `web/src/objects/publish-control.test.tsx` +- Modify: `web/src/i18n/{en,sv}.json` + +- [ ] **Step 1: Add i18n `publish.*` keys** — merge into `web/src/i18n/en.json`: + +```json +"publish": { + "heading": "Visibility", + "advanceInternal": "Advance to internal", + "publish": "Publish →", + "backToDraft": "← Back to draft", + "unpublishInternal": "Unpublish to internal", + "confirmTitle": "Publish to public?", + "confirmBody": "This will make the record visible on the public API.", + "confirm": "Publish", + "gateError": "Can't publish — required fields are missing.", + "editLink": "Edit the record", + "illegalError": "That visibility change isn't allowed." +} +``` +and `web/src/i18n/sv.json`: +```json +"publish": { + "heading": "Synlighet", + "advanceInternal": "Gör intern", + "publish": "Publicera →", + "backToDraft": "← Tillbaka till utkast", + "unpublishInternal": "Avpublicera till intern", + "confirmTitle": "Publicera publikt?", + "confirmBody": "Detta gör posten synlig via det publika API:et.", + "confirm": "Publicera", + "gateError": "Kan inte publicera — obligatoriska fält saknas.", + "editLink": "Redigera posten", + "illegalError": "Den synlighetsändringen är inte tillåten." +} +``` +(Stepper segment labels reuse the existing `visibility.{draft,internal,public}` keys; the dialog Cancel reuses `form.cancel`; the generic error reuses `form.rejected`. Keep en/sv parity.) + +- [ ] **Step 2: Write the failing test** `web/src/objects/publish-control.test.tsx` + +```tsx +import { expect, test } from "vitest"; +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { server } from "../test/server"; +import { renderApp } from "../test/render"; +import { PublishControl } from "./publish-control"; +import type { components } from "../api/schema"; + +type AdminObjectView = components["schemas"]["AdminObjectView"]; + +function objectWith(visibility: string): AdminObjectView { + return { + id: "o-1", object_number: "A-1", object_name: "Amphora", number_of_objects: 1, + brief_description: null, current_location: null, current_owner: null, + recorder: null, recording_date: null, visibility, fields: {}, + } as AdminObjectView; +} + +test("internal: shows publish (forward) and back-to-draft buttons", async () => { + renderApp(); + expect(screen.getByRole("button", { name: /publish/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /back to draft/i })).toBeInTheDocument(); +}); + +test("draft: forward to internal posts immediately (no confirm)", async () => { + let body: unknown; + server.use( + http.post("/api/admin/objects/:id/visibility", async ({ request }) => { + body = await request.json(); + return new HttpResponse(null, { status: 204 }); + }), + ); + renderApp(); + await userEvent.click(screen.getByRole("button", { name: /advance to internal/i })); + await waitFor(() => expect((body as { visibility: string })?.visibility).toBe("internal")); +}); + +test("public: back to internal posts immediately", async () => { + let body: unknown; + server.use( + http.post("/api/admin/objects/:id/visibility", async ({ request }) => { + body = await request.json(); + return new HttpResponse(null, { status: 204 }); + }), + ); + renderApp(); + await userEvent.click(screen.getByRole("button", { name: /unpublish to internal/i })); + await waitFor(() => expect((body as { visibility: string })?.visibility).toBe("internal")); +}); + +test("internal -> public requires confirmation, then posts public", async () => { + let body: unknown; + server.use( + http.post("/api/admin/objects/:id/visibility", async ({ request }) => { + body = await request.json(); + return new HttpResponse(null, { status: 204 }); + }), + ); + renderApp(); + await userEvent.click(screen.getByRole("button", { name: /publish/i })); + const dialog = await screen.findByRole("alertdialog"); + await userEvent.click(within(dialog).getByRole("button", { name: /publish/i })); + await waitFor(() => expect((body as { visibility: string })?.visibility).toBe("public")); +}); + +test("publish gate (422) shows an inline error with an edit link", async () => { + server.use( + http.post("/api/admin/objects/:id/visibility", () => new HttpResponse(null, { status: 422 })), + ); + renderApp(); + await userEvent.click(screen.getByRole("button", { name: /publish/i })); + const dialog = await screen.findByRole("alertdialog"); + await userEvent.click(within(dialog).getByRole("button", { name: /publish/i })); + await waitFor(() => + expect(screen.getByText(/required fields are missing/i)).toBeInTheDocument(), + ); + expect(screen.getByRole("link", { name: /edit the record/i })).toBeInTheDocument(); +}); +``` + +- [ ] **Step 3: Run to verify it fails** — `pnpm test src/objects/publish-control.test.tsx` → FAIL (no component). + +- [ ] **Step 4: Implement** — `web/src/objects/publish-control.tsx` + +```tsx +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import type { components } from "../api/schema"; +import { useSetVisibility, VisibilityError } from "../api/queries"; +import { adjacentTransitions, type Visibility } from "./transitions"; +import { Button } from "@/components/ui/button"; +import { + AlertDialog, AlertDialogTrigger, AlertDialogContent, AlertDialogTitle, + AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, +} from "@/components/ui/alert-dialog"; + +type AdminObjectView = components["schemas"]["AdminObjectView"]; +const STEPS: Visibility[] = ["draft", "internal", "public"]; + +export function PublishControl({ object }: { object: AdminObjectView }) { + const { t } = useTranslation(); + const current = object.visibility as Visibility; + const { forward, back } = adjacentTransitions(current); + const setVisibility = useSetVisibility(); + const [confirmOpen, setConfirmOpen] = useState(false); + const [errorKind, setErrorKind] = useState<"gate" | "illegal" | "other" | null>(null); + + const go = (visibility: Visibility) => { + setErrorKind(null); + setVisibility.mutate( + { id: object.id, visibility }, + { + onError: (err) => { + const status = err instanceof VisibilityError ? err.status : 0; + setErrorKind(status === 422 ? "gate" : status === 409 ? "illegal" : "other"); + }, + }, + ); + }; + + const currentIndex = STEPS.indexOf(current); + + return ( +
+
{t("publish.heading")}
+ +
+ {STEPS.map((step, i) => ( +
+ {t(`visibility.${step}`)} +
+ ))} +
+ +
+ {back && ( + + )} + {forward === "internal" && ( + + )} + {forward === "public" && ( + + {t("publish.publish")} + } + /> + + {t("publish.confirmTitle")} + {t("publish.confirmBody")} + + {t("form.cancel")} + go("public")}>{t("publish.confirm")} + + + + )} +
+ + {errorKind === "gate" && ( +

+ {t("publish.gateError")}{" "} + {t("publish.editLink")} +

+ )} + {errorKind === "illegal" && ( +

{t("publish.illegalError")}

+ )} + {errorKind === "other" && ( +

{t("form.rejected")}

+ )} +
+ ); +} +``` +NOTES: +- The AlertDialog is composed exactly like M2's `delete-object-dialog.tsx` (Base UI "base-nova" registry — `AlertDialogTrigger render={