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

22 KiB
Raw Permalink Blame History

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

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 failspnpm test src/objects/transitions.test.ts → FAIL (no module).

  • Step 3: Implement web/src/objects/transitions.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 passespnpm 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:

  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
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 failspnpm test src/api/queries.visibility.test.tsx → FAIL (no useSetVisibility).

  • Step 8: Implement — append to web/src/api/queries.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: Runpnpm test src/api/queries.visibility.test.tsx → PASS (2). Then full pnpm test, pnpm typecheck, pnpm lint, pnpm build → clean.

  • Step 10: Commit

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:

"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:

"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
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 failspnpm test src/objects/publish-control.test.tsx → FAIL (no component).

  • Step 4: Implementweb/src/objects/publish-control.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: Runpnpm test src/objects/publish-control.test.tsx → PASS (5). Then full pnpm test, pnpm typecheck, pnpm lint, pnpm build → clean.

  • Step 6: Commit

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:

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:
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: Runpnpm 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

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
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).