516ecf3e95
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
483 lines
22 KiB
Markdown
483 lines
22 KiB
Markdown
# 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 1–3. ✓
|
||
- 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).
|