Files
biggus-dickus/docs/superpowers/plans/2026-06-04-frontend-spa-milestone-3.md
T
2026-06-04 08:25:53 +02:00

483 lines
22 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) 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 <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
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(<PublishControl object={objectWith("internal")} />);
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(<PublishControl object={objectWith("draft")} />);
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(<PublishControl object={objectWith("public")} />);
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(<PublishControl object={objectWith("internal")} />);
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(<PublishControl object={objectWith("internal")} />);
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 (
<section className="border-t p-4">
<div className="mb-2 text-xs font-medium uppercase text-neutral-500">{t("publish.heading")}</div>
<div className="mb-3 flex">
{STEPS.map((step, i) => (
<div key={step}
className={`flex-1 border px-2 py-1 text-center text-xs ${
i === currentIndex ? "bg-neutral-800 font-semibold text-white"
: i < currentIndex ? "bg-neutral-100 text-neutral-600" : "text-neutral-400"}`}>
{t(`visibility.${step}`)}
</div>
))}
</div>
<div className="flex gap-2">
{back && (
<Button variant="ghost" size="sm" disabled={setVisibility.isPending}
onClick={() => go(back)}>
{back === "draft" ? t("publish.backToDraft") : t("publish.unpublishInternal")}
</Button>
)}
{forward === "internal" && (
<Button size="sm" disabled={setVisibility.isPending} onClick={() => go("internal")}>
{t("publish.advanceInternal")}
</Button>
)}
{forward === "public" && (
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogTrigger
render={
<Button size="sm" disabled={setVisibility.isPending}>{t("publish.publish")}</Button>
}
/>
<AlertDialogContent>
<AlertDialogTitle>{t("publish.confirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>{t("publish.confirmBody")}</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>{t("form.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => go("public")}>{t("publish.confirm")}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
{errorKind === "gate" && (
<p role="alert" className="mt-2 text-sm text-red-600">
{t("publish.gateError")}{" "}
<Link to={`/objects/${object.id}/edit`} className="underline">{t("publish.editLink")}</Link>
</p>
)}
{errorKind === "illegal" && (
<p role="alert" className="mt-2 text-sm text-red-600">{t("publish.illegalError")}</p>
)}
{errorKind === "other" && (
<p role="alert" className="mt-2 text-sm text-red-600">{t("form.rejected")}</p>
)}
</section>
);
}
```
NOTES:
- The AlertDialog is composed exactly like M2's `delete-object-dialog.tsx` (Base UI "base-nova" registry — `AlertDialogTrigger render={<Button>}`, controlled `open`/`onOpenChange`). Match that file's working composition; adapt names if the generated exports differ.
- The confirm button text (`publish.confirm` = "Publish") and the trigger (`publish.publish` = "Publish →") both match `/publish/i`; the test scopes the confirm click with `within(dialog)`, same pattern as the delete dialog test.
- `STEPS.indexOf(current)` drives done/current/pending styling.
- The button label for `back` depends on whether it returns to draft or internal.
- `VisibilityError` is imported from `queries.ts` (Task 1).
- [ ] **Step 5: Run**`pnpm test src/objects/publish-control.test.tsx` → PASS (5). Then full `pnpm test`, `pnpm typecheck`, `pnpm lint`, `pnpm build` → clean.
- [ ] **Step 6: Commit**
```bash
cd ..
git add web
git commit -m "feat(web): PublishControl stepper (legal one-step moves, confirm-on-public, gate/illegal errors)"
```
---
## Task 3: Wire into the object detail + full verification
**Files:**
- Modify: `web/src/objects/object-detail.tsx`, `web/src/objects/object-detail.test.tsx`
- [ ] **Step 1: Render `PublishControl` in the detail** — in `web/src/objects/object-detail.tsx`, import it and render it after the inventory-minimum + flexible-field sections (a new section at the bottom of the detail body). Keep the existing `VisibilityBadge` in the header:
```tsx
import { PublishControl } from "./publish-control";
// ... at the end of the detail body, after the flexible-fields block:
<PublishControl object={object} />
```
- [ ] **Step 2: Extend the detail test to assert the control shows** — append to `web/src/objects/object-detail.test.tsx`:
```tsx
test("detail shows the publish control with the current visibility stepper", async () => {
// default GET /api/admin/objects/:id handler returns amphora (visibility "public")
renderApp(tree(), { route: "/objects/11111111-1111-1111-1111-111111111111" });
// the stepper renders all three stages; public => an unpublish (back) button is offered
expect(await screen.findByText(/visibility/i)).toBeInTheDocument();
expect(await screen.findByRole("button", { name: /unpublish to internal/i })).toBeInTheDocument();
});
```
(Use the existing `tree()` / route + the default `amphora` fixture — confirm `amphora.visibility` is `"public"` in `fixtures.ts`; it is. If the detail test file's structure differs, adapt to render `ObjectDetail` at the amphora id and assert the stepper heading + the public→back button. The default MSW `POST .../visibility` handler returns 204 so no unhandled-request error even if not clicked.)
- [ ] **Step 3: Run**`pnpm test src/objects/object-detail.test.tsx` → PASS (existing + new). Then full `pnpm test`, `pnpm typecheck`, `pnpm lint`, `pnpm build`.
- [ ] **Step 4: i18n parity + bundle check**
```bash
cd web
node -e "const a=require('./src/i18n/en.json'),b=require('./src/i18n/sv.json');const keys=o=>Object.entries(o).flatMap(([k,v])=>typeof v==='object'?keys(v).map(s=>k+'.'+s):[k]);const ka=keys(a).sort(),kb=keys(b).sort();console.log(JSON.stringify(ka)===JSON.stringify(kb)?'PARITY OK':'MISMATCH:'+JSON.stringify({onlyEn:ka.filter(k=>!kb.includes(k)),onlySv:kb.filter(k=>!ka.includes(k))}))"
pnpm build && pnpm check:size
```
Expected: `PARITY OK`; bundle ≤150 KB gz (report the number; PublishControl is small — should stay well under).
- [ ] **Step 5: Commit**
```bash
cd ..
git add web
git commit -m "feat(web): show the publish control on the object detail"
```
---
## Self-Review (completed)
**Spec coverage:**
- Segmented stepper on the detail, current highlighted, legal one-step buttons → Tasks 2, 3. ✓
- `adjacentTransitions` (draft→internal; internal↔public/draft; public→internal) → Task 1. ✓
- `useSetVisibility` POST + status-carrying error (422/409/other) → Task 1. ✓
- Confirm only on →Public (AlertDialog) → Task 2. ✓
- 422 gate → inline message + Edit link; 409 illegal → inline (defensive); other → form.rejected → Task 2. ✓
- Invalidate object + list on success (badge/stepper refresh) → Task 1. ✓
- VisibilityBadge stays in header; control is a new detail section → Task 3. ✓
- i18n sv/en parity → Tasks 2, 3. ✓
- Testing Vitest+RTL+MSW (helper + component + detail) → Tasks 13. ✓
- Bundle budget → Task 3. ✓
**Placeholder scan:** none — complete code in every step; the "adapt to generated VisibilityRequest type / base-nova AlertDialog exports" notes are verification instructions with fixed contracts.
**Type consistency:** `Visibility` union defined in `transitions.ts` (Task 1) and used by `useSetVisibility` + `PublishControl`; `VisibilityError` defined in `queries.ts` (Task 1) and consumed in `PublishControl` (Task 2); the `{ id, visibility }` mutation arg shape consistent; the AlertDialog composition mirrors the existing `delete-object-dialog.tsx`; route `/objects/:id/edit` (the Edit link) matches the M2 route.
## Notes for follow-on
- Per-field gate detail needs the backend 422 to carry field info (#28) — until then the gate message is generic.
- A visibility-change history/audit view is a later milestone (the backend already audits transitions).