feat(web): PublishControl stepper (legal one-step moves, confirm-on-public, gate/illegal errors)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 08:35:02 +02:00
parent 01f757a239
commit 39b7fc51e9
4 changed files with 242 additions and 2 deletions
+79
View File
@@ -0,0 +1,79 @@
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();
});
+135
View File
@@ -0,0 +1,135 @@
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 },
{
onSuccess: () => setConfirmOpen(false),
onError: (err) => {
setConfirmOpen(false);
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>
);
}