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:
+14
-1
@@ -6,5 +6,18 @@
|
|||||||
"fieldsLabels": { "objectNumber": "Object number", "objectName": "Name", "count": "Number of objects", "briefDescription": "Brief description", "currentLocation": "Current location", "currentOwner": "Current owner", "recorder": "Recorder", "recordingDate": "Recording date", "visibility": "Visibility", "flexible": "Catalogue fields" },
|
"fieldsLabels": { "objectNumber": "Object number", "objectName": "Name", "count": "Number of objects", "briefDescription": "Brief description", "currentLocation": "Current location", "currentOwner": "Current owner", "recorder": "Recorder", "recordingDate": "Recording date", "visibility": "Visibility", "flexible": "Catalogue fields" },
|
||||||
"visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" },
|
"visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" },
|
||||||
"form": { "selectPlaceholder": "— select —", "create": "Create object", "save": "Save", "cancel": "Cancel", "visibility": "Visibility", "draft": "Draft", "internal": "Internal", "required": "This field is required", "rejected": "The server rejected the changes — check required and referenced fields", "flexibleHeading": "Catalogue fields" },
|
"form": { "selectPlaceholder": "— select —", "create": "Create object", "save": "Save", "cancel": "Cancel", "visibility": "Visibility", "draft": "Draft", "internal": "Internal", "required": "This field is required", "rejected": "The server rejected the changes — check required and referenced fields", "flexibleHeading": "Catalogue fields" },
|
||||||
"actions": { "edit": "Edit", "delete": "Delete", "confirmDelete": "Delete this object? This cannot be undone." }
|
"actions": { "edit": "Edit", "delete": "Delete", "confirmDelete": "Delete this object? This cannot be undone." },
|
||||||
|
"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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-1
@@ -6,5 +6,18 @@
|
|||||||
"fieldsLabels": { "objectNumber": "Föremålsnummer", "objectName": "Namn", "count": "Antal föremål", "briefDescription": "Kort beskrivning", "currentLocation": "Nuvarande plats", "currentOwner": "Nuvarande ägare", "recorder": "Registrerad av", "recordingDate": "Registreringsdatum", "visibility": "Synlighet", "flexible": "Katalogfält" },
|
"fieldsLabels": { "objectNumber": "Föremålsnummer", "objectName": "Namn", "count": "Antal föremål", "briefDescription": "Kort beskrivning", "currentLocation": "Nuvarande plats", "currentOwner": "Nuvarande ägare", "recorder": "Registrerad av", "recordingDate": "Registreringsdatum", "visibility": "Synlighet", "flexible": "Katalogfält" },
|
||||||
"visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" },
|
"visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" },
|
||||||
"form": { "selectPlaceholder": "— välj —", "create": "Skapa föremål", "save": "Spara", "cancel": "Avbryt", "visibility": "Synlighet", "draft": "Utkast", "internal": "Intern", "required": "Fältet är obligatoriskt", "rejected": "Servern avvisade ändringarna — kontrollera obligatoriska och refererade fält", "flexibleHeading": "Katalogfält" },
|
"form": { "selectPlaceholder": "— välj —", "create": "Skapa föremål", "save": "Spara", "cancel": "Avbryt", "visibility": "Synlighet", "draft": "Utkast", "internal": "Intern", "required": "Fältet är obligatoriskt", "rejected": "Servern avvisade ändringarna — kontrollera obligatoriska och refererade fält", "flexibleHeading": "Katalogfält" },
|
||||||
"actions": { "edit": "Redigera", "delete": "Ta bort", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras." }
|
"actions": { "edit": "Redigera", "delete": "Ta bort", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras." },
|
||||||
|
"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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user