From 39b7fc51e9ac733c8686da100626cd12bd9bf4e6 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 4 Jun 2026 08:35:02 +0200 Subject: [PATCH] feat(web): PublishControl stepper (legal one-step moves, confirm-on-public, gate/illegal errors) Co-Authored-By: Claude Sonnet 4.6 --- web/src/i18n/en.json | 15 ++- web/src/i18n/sv.json | 15 ++- web/src/objects/publish-control.test.tsx | 79 +++++++++++++ web/src/objects/publish-control.tsx | 135 +++++++++++++++++++++++ 4 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 web/src/objects/publish-control.test.tsx create mode 100644 web/src/objects/publish-control.tsx diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 5cecee0..21f404a 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -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" }, "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" }, - "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." + } } diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index aa7ff7b..93e15b8 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -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" }, "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" }, - "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." + } } diff --git a/web/src/objects/publish-control.test.tsx b/web/src/objects/publish-control.test.tsx new file mode 100644 index 0000000..c455632 --- /dev/null +++ b/web/src/objects/publish-control.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); +}); diff --git a/web/src/objects/publish-control.tsx b/web/src/objects/publish-control.tsx new file mode 100644 index 0000000..7b25a8f --- /dev/null +++ b/web/src/objects/publish-control.tsx @@ -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 ( +
+
+ {t("publish.heading")} +
+ +
+ {STEPS.map((step, i) => ( +
+ {t(`visibility.${step}`)} +
+ ))} +
+ +
+ {back && ( + + )} + {forward === "internal" && ( + + )} + {forward === "public" && ( + + + {t("publish.publish")} + + } + /> + + {t("publish.confirmTitle")} + {t("publish.confirmBody")} + + {t("form.cancel")} + go("public")}> + {t("publish.confirm")} + + + + + )} +
+ + {errorKind === "gate" && ( +

+ {t("publish.gateError")}{" "} + + {t("publish.editLink")} + +

+ )} + {errorKind === "illegal" && ( +

+ {t("publish.illegalError")} +

+ )} + {errorKind === "other" && ( +

+ {t("form.rejected")} +

+ )} +
+ ); +}