From b713423097579117ddfd18ab98dc7681b33b0f5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eder=20S=C3=A1nchez?= Date: Fri, 7 Aug 2026 16:27:09 -0600 Subject: [PATCH] fix(admin): surface create-content failures as an error toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rejected create (e.g. the 409 SLUG_CONFLICT when the auto-generated slug collides with an existing entry) was fully silent: createMutation had no onError, ContentNewPage had no toast manager, and the QueryClient has no global mutation error handler — the thrown Error (carrying the server's human-readable message) died in mutation state, the Save button reset, and the editor stayed on /new with zero feedback. Every other mutation in router.tsx surfaces failures; only the create path lacked a handler. The handler follows the settings-components pattern (useKumoToastManager + variant: "error") rather than Toast.useToastManager + type: "error": Kumo styles toast severity off variant (danger ring + error icon), while Base UI's type field is inert for styling — verified by rendering both. The regression test drives the real route with a mocked 409 SLUG_CONFLICT response and asserts the toast title, the server's message, the error affordance (the variant-driven toast icon), and that the router stays on /content/posts/new (fails unpatched: 1/14; passes patched: 14/14). --- .changeset/content-create-error-toast.md | 5 ++ packages/admin/src/router.tsx | 11 +++- packages/admin/tests/router.test.tsx | 74 ++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 .changeset/content-create-error-toast.md 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 // ---------------------------------------------------------------------------