diff --git a/.changeset/content-create-error-toast.md b/.changeset/content-create-error-toast.md new file mode 100644 index 000000000..995bb1b5c --- /dev/null +++ b/.changeset/content-create-error-toast.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": patch +--- + +Fixes silent save failures when creating content in the admin: a rejected create — for example a slug that already exists in the collection — now shows a "Failed to save" toast with the server's message instead of doing nothing. diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index 18e7a82ab..12a292ada 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -4,7 +4,7 @@ * Defines all admin routes and their components. */ -import { Button, Loader, Toast } from "@cloudflare/kumo"; +import { Button, Loader, Toast, useKumoToastManager } from "@cloudflare/kumo"; import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type { QueryClient } from "@tanstack/react-query"; @@ -634,6 +634,8 @@ function ContentNewPage() { const { locale } = useSearch({ from: "/_admin/content/$collection/new" }); const navigate = useNavigate(); const queryClient = useQueryClient(); + const { t } = useLingui(); + const toastManager = useKumoToastManager(); const [selectedBylines, setSelectedBylines] = React.useState([]); const { data: manifest } = useQuery({ @@ -663,6 +665,13 @@ function ContentNewPage() { search: { locale: result.locale }, }); }, + onError: (error) => { + toastManager.add({ + title: t`Failed to save`, + description: error instanceof Error ? error.message : t`An error occurred`, + variant: "error", + }); + }, }); const pluginBlocks = React.useMemo(() => (manifest ? getPluginBlocks(manifest) : []), [manifest]); diff --git a/packages/admin/tests/router.test.tsx b/packages/admin/tests/router.test.tsx index bb20659a6..17fe8ad33 100644 --- a/packages/admin/tests/router.test.tsx +++ b/packages/admin/tests/router.test.tsx @@ -492,6 +492,80 @@ describe("ContentNewPage – locale passed to createContent", () => { }); }); +// --------------------------------------------------------------------------- +// Tests: ContentNewPage – a rejected create surfaces an error toast +// --------------------------------------------------------------------------- + +describe("ContentNewPage – create failure surfaces the server's error", () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = createMockFetch(); + + mockFetch + .on("GET", "/_emdash/api/manifest", { data: MANIFEST }) + .on("GET", "/_emdash/api/auth/me", { + data: { id: "user_01", role: 60 }, + }) + .on("GET", "/_emdash/api/bylines", { data: { items: [] } }) + // A canned 409 SLUG_CONFLICT — the response shape the server + // returns when the entry's slug is already taken in the + // collection (slug derivation itself is server-side and not + // exercised here). + .on( + "POST", + "/_emdash/api/content/posts", + { + success: false, + error: { + code: "SLUG_CONFLICT", + message: "Slug 'test-post' already exists in collection 'posts'", + }, + }, + 409, + ); + }); + + afterEach(() => { + mockFetch.restore(); + }); + + it("shows a toast with the server's message on a slug conflict (and stays on /new)", async () => { + const { router, TestApp } = buildRouter(); + + await router.navigate({ + to: "/content/$collection/new", + params: { collection: "posts" }, + search: { locale: undefined }, + }); + + const screen = await render(); + + await expect + .element(screen.getByRole("button", { name: "Save", exact: true })) + .toBeInTheDocument(); + + await screen.getByRole("button", { name: "Save", exact: true }).click(); + + // The UI surfaces WHAT happened — the server's human-readable + // conflict message. + await expect.element(screen.getByText("Failed to save")).toBeInTheDocument(); + await expect + .element(screen.getByText("Slug 'test-post' already exists in collection 'posts'")) + .toBeInTheDocument(); + + // And with the error affordance: Kumo styles severity off `variant` + // (Base UI's `type` is inert for styling), and the toast icon only + // renders for a non-default variant. + await expect + .poll(() => document.querySelectorAll("[data-toast-icon]").length) + .toBeGreaterThan(0); + + // And the failed create must not navigate anywhere. + expect(router.state.location.pathname).toContain("/content/posts/new"); + }); +}); + // --------------------------------------------------------------------------- // Tests: ContentEditPage – autosave cache stays in sync // ---------------------------------------------------------------------------