Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/content-create-error-toast.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 10 additions & 1 deletion packages/admin/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<BylineCreditInput[]>([]);

const { data: manifest } = useQuery({
Expand Down Expand Up @@ -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]);
Expand Down
74 changes: 74 additions & 0 deletions packages/admin/tests/router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createMockFetch>;

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(<TestApp />);

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
// ---------------------------------------------------------------------------
Expand Down
Loading