Emdash source with visual editor image upload fix
Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
418
packages/admin/tests/router.test.tsx
Normal file
418
packages/admin/tests/router.test.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* Tests for admin router page components.
|
||||
*
|
||||
* Bug: content created in the wrong locale when using the locale switcher.
|
||||
*
|
||||
* Root cause (two parts):
|
||||
* 1. ContentListPage renders ContentList with `activeLocale` but the "Add New"
|
||||
* <Link> in ContentList does NOT forward `search={{ locale: activeLocale }}` to
|
||||
* the new-content route. The locale is silently dropped on navigation.
|
||||
* 2. ContentNewPage (router.tsx:380) has no `validateSearch` and never reads the
|
||||
* locale from URL search params, so `createContent` is always called without a
|
||||
* locale, defaulting to English regardless of what is configured.
|
||||
*
|
||||
* Fix required in:
|
||||
* packages/admin/src/components/ContentList.tsx – forward locale on Add-New links
|
||||
* packages/admin/src/router.tsx (ContentNewPage) – read locale from search params
|
||||
* and pass it to createContent
|
||||
*/
|
||||
|
||||
import { Toasty } from "@cloudflare/kumo";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import type { AdminManifest } from "../src/lib/api";
|
||||
import { createAdminRouter } from "../src/router";
|
||||
import { render } from "./utils/render.tsx";
|
||||
import { createTestQueryClient, createMockFetch, waitFor } from "./utils/test-helpers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component mocks – keep layout plumbing out of these tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock("../src/components/Shell", () => ({
|
||||
Shell: ({ children }: { children: React.ReactNode }) => <div data-testid="shell">{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../src/components/AdminCommandPalette", () => ({
|
||||
AdminCommandPalette: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../src/components/ContentEditor", () => ({
|
||||
ContentEditor: ({
|
||||
item,
|
||||
onSave,
|
||||
onAutosave,
|
||||
}: {
|
||||
item?: { data?: { title?: string }; slug?: string | null };
|
||||
onSave?: (payload: { data: Record<string, unknown> }) => void;
|
||||
onAutosave?: (payload: { data: Record<string, unknown>; slug?: string }) => void;
|
||||
}) => (
|
||||
<div data-testid="content-editor">
|
||||
<div data-testid="mock-title">{item?.data?.title ?? ""}</div>
|
||||
<div data-testid="mock-slug">{item?.slug ?? ""}</div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSave?.({ data: { title: "Test Post" } });
|
||||
}}
|
||||
>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onAutosave?.({
|
||||
data: { title: "Autosaved Title" },
|
||||
slug: "autosaved-title",
|
||||
})
|
||||
}
|
||||
>
|
||||
Trigger Draft Sync
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MANIFEST: AdminManifest = {
|
||||
version: "1.0.0",
|
||||
hash: "abc123",
|
||||
authMode: "passkey",
|
||||
collections: {
|
||||
posts: {
|
||||
label: "Posts",
|
||||
labelSingular: "Post",
|
||||
supports: ["drafts"],
|
||||
hasSeo: false,
|
||||
fields: {
|
||||
title: { kind: "string", label: "Title" },
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {},
|
||||
taxonomies: [],
|
||||
i18n: {
|
||||
defaultLocale: "fr",
|
||||
locales: ["fr", "en", "de"],
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildRouter() {
|
||||
const queryClient = createTestQueryClient();
|
||||
const router = createAdminRouter(queryClient);
|
||||
if (!i18n.locale) {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
}
|
||||
// Toasty and I18nProvider are provided by App.tsx in production.
|
||||
// Mirror that here so useLingui() and Toast.useToastManager() work inside page components.
|
||||
function TestApp() {
|
||||
return (
|
||||
<I18nProvider i18n={i18n}>
|
||||
<Toasty>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</Toasty>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
return { router, queryClient, TestApp };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: ContentListPage – locale forwarded to "Add New" link
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ContentListPage – locale forwarding to the new-content route", () => {
|
||||
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/content/posts", {
|
||||
data: { items: [], nextCursor: undefined },
|
||||
})
|
||||
.on("GET", "/_emdash/api/content/posts/trashed", {
|
||||
data: { items: [] },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFetch.restore();
|
||||
});
|
||||
|
||||
it("Add New link includes the active locale when a non-default locale (de) is selected", async () => {
|
||||
// Navigate to the content list with locale=de selected in the switcher.
|
||||
// The default locale is fr, so de is a non-default locale.
|
||||
// The "Add New" <Link> must carry ?locale=de so that ContentNewPage
|
||||
// receives it and creates content in German, not the default French.
|
||||
const { router, TestApp } = buildRouter();
|
||||
|
||||
await router.navigate({
|
||||
to: "/content/$collection",
|
||||
params: { collection: "posts" },
|
||||
search: { locale: "de" },
|
||||
});
|
||||
|
||||
const screen = await render(<TestApp />);
|
||||
|
||||
const addNewLink = screen.getByRole("link", { name: /add new/i });
|
||||
await expect.element(addNewLink).toBeInTheDocument();
|
||||
|
||||
const href = addNewLink.element().getAttribute("href") ?? "";
|
||||
expect(href).toContain("locale=de");
|
||||
});
|
||||
|
||||
it("Add New link uses the default locale (fr) when no locale is set in the URL", async () => {
|
||||
// Navigate to the content list without an explicit locale param.
|
||||
// activeLocale falls back to the configured defaultLocale ("fr").
|
||||
// The "Add New" <Link> must carry ?locale=fr so that ContentNewPage
|
||||
// creates content in the correct default language.
|
||||
const { router, TestApp } = buildRouter();
|
||||
|
||||
await router.navigate({
|
||||
to: "/content/$collection",
|
||||
params: { collection: "posts" },
|
||||
});
|
||||
|
||||
const screen = await render(<TestApp />);
|
||||
|
||||
const addNewLink = screen.getByRole("link", { name: /add new/i });
|
||||
await expect.element(addNewLink).toBeInTheDocument();
|
||||
|
||||
const href = addNewLink.element().getAttribute("href") ?? "";
|
||||
expect(href).toContain("locale=fr");
|
||||
});
|
||||
|
||||
it("Add New link does not include a locale param when i18n is not configured", async () => {
|
||||
const manifestWithoutI18n: AdminManifest = { ...MANIFEST, i18n: undefined };
|
||||
mockFetch.on("GET", "/_emdash/api/manifest", { data: manifestWithoutI18n });
|
||||
|
||||
const { router, TestApp } = buildRouter();
|
||||
|
||||
await router.navigate({
|
||||
to: "/content/$collection",
|
||||
params: { collection: "posts" },
|
||||
});
|
||||
|
||||
const screen = await render(<TestApp />);
|
||||
|
||||
const addNewLink = screen.getByRole("link", { name: /add new/i });
|
||||
await expect.element(addNewLink).toBeInTheDocument();
|
||||
|
||||
const href = addNewLink.element().getAttribute("href") ?? "";
|
||||
expect(href).not.toContain("locale=");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: ContentNewPage – locale passed to createContent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ContentNewPage – locale passed to createContent", () => {
|
||||
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: [] } })
|
||||
.on("POST", "/_emdash/api/content/posts", {
|
||||
data: {
|
||||
item: {
|
||||
id: "new_01",
|
||||
type: "posts",
|
||||
slug: null,
|
||||
status: "draft",
|
||||
locale: "de",
|
||||
translationGroup: null,
|
||||
data: { title: "Test Post" },
|
||||
authorId: null,
|
||||
primaryBylineId: null,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-01T00:00:00Z",
|
||||
publishedAt: null,
|
||||
scheduledAt: null,
|
||||
liveRevisionId: null,
|
||||
draftRevisionId: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFetch.restore();
|
||||
});
|
||||
|
||||
it("passes locale=de to the API when ?locale=de is in the URL", async () => {
|
||||
// The default locale is fr; navigating with ?locale=de tests that the
|
||||
// non-default locale is read from search params and forwarded to createContent.
|
||||
const { router, TestApp } = buildRouter();
|
||||
|
||||
await router.navigate({
|
||||
to: "/content/$collection/new",
|
||||
params: { collection: "posts" },
|
||||
search: { locale: "de" },
|
||||
});
|
||||
|
||||
const screen = await render(<TestApp />);
|
||||
|
||||
// Wait for the editor to appear (manifest must have loaded)
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Save", exact: true }))
|
||||
.toBeInTheDocument();
|
||||
|
||||
// Capture outgoing requests
|
||||
const requests: { url: string; body: unknown }[] = [];
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("/content/posts") && init?.method === "POST") {
|
||||
const body = init.body ? JSON.parse(init.body as string) : null;
|
||||
requests.push({ url, body });
|
||||
}
|
||||
return origFetch(input, init);
|
||||
};
|
||||
|
||||
await screen.getByRole("button", { name: "Save", exact: true }).click();
|
||||
|
||||
globalThis.fetch = origFetch;
|
||||
|
||||
// After the fix: the POST body must include locale: "de"
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0]!.body).toMatchObject({ locale: "de" });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: ContentEditPage – autosave cache stays in sync
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ContentEditPage – autosave cache patching", () => {
|
||||
let mockFetch: ReturnType<typeof createMockFetch>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch = createMockFetch();
|
||||
|
||||
const manifestWithRevisions: AdminManifest = {
|
||||
...MANIFEST,
|
||||
i18n: undefined,
|
||||
collections: {
|
||||
posts: {
|
||||
...MANIFEST.collections.posts,
|
||||
supports: ["drafts", "revisions"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockFetch
|
||||
.on("GET", "/_emdash/api/manifest", { data: manifestWithRevisions })
|
||||
.on("GET", "/_emdash/api/auth/me", {
|
||||
data: { id: "user_01", role: 30 },
|
||||
})
|
||||
.on("GET", "/_emdash/api/bylines", { data: { items: [] } })
|
||||
.on("GET", "/_emdash/api/content/posts/post_1", {
|
||||
data: {
|
||||
item: {
|
||||
id: "post_1",
|
||||
type: "posts",
|
||||
slug: "published-slug",
|
||||
status: "draft",
|
||||
locale: "en",
|
||||
translationGroup: null,
|
||||
data: { title: "Published Title" },
|
||||
authorId: null,
|
||||
primaryBylineId: null,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-01T00:00:00Z",
|
||||
publishedAt: "2025-01-01T00:00:00Z",
|
||||
scheduledAt: null,
|
||||
liveRevisionId: "rev_live",
|
||||
draftRevisionId: "rev_draft",
|
||||
},
|
||||
},
|
||||
})
|
||||
.on("GET", "/_emdash/api/revisions/rev_draft", {
|
||||
data: {
|
||||
item: {
|
||||
id: "rev_draft",
|
||||
collection: "posts",
|
||||
entryId: "post_1",
|
||||
data: { title: "Draft Title", _slug: "draft-slug" },
|
||||
authorId: null,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
})
|
||||
.on("PUT", "/_emdash/api/content/posts/post_1", {
|
||||
data: {
|
||||
item: {
|
||||
id: "post_1",
|
||||
type: "posts",
|
||||
slug: "published-slug",
|
||||
status: "draft",
|
||||
locale: "en",
|
||||
translationGroup: null,
|
||||
data: { title: "Published Title" },
|
||||
authorId: null,
|
||||
primaryBylineId: null,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-02T00:00:00Z",
|
||||
publishedAt: "2025-01-01T00:00:00Z",
|
||||
scheduledAt: null,
|
||||
liveRevisionId: "rev_live",
|
||||
draftRevisionId: "rev_draft",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFetch.restore();
|
||||
});
|
||||
|
||||
it("keeps the edited draft title and slug after autosave completes", async () => {
|
||||
const { router, TestApp } = buildRouter();
|
||||
|
||||
await router.navigate({
|
||||
to: "/content/$collection/$id",
|
||||
params: { collection: "posts", id: "post_1" },
|
||||
});
|
||||
|
||||
const screen = await render(<TestApp />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mock-title").element().textContent).toBe("Draft Title");
|
||||
expect(screen.getByTestId("mock-slug").element().textContent).toBe("draft-slug");
|
||||
});
|
||||
|
||||
await screen.getByRole("button", { name: "Trigger Draft Sync" }).click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mock-title").element().textContent).toBe("Autosaved Title");
|
||||
expect(screen.getByTestId("mock-slug").element().textContent).toBe("autosaved-title");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user