Skip to content
7 changes: 7 additions & 0 deletions .changeset/few-bats-wink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@emdash-cms/admin": patch
---

Fixes locale switcher menus appearing with a light background in dark mode. The selected locale, default-locale indicator, and `All locales` label remain visible when the menu is closed.

Reselecting the current locale preserves unsaved byline edits.
16 changes: 12 additions & 4 deletions e2e/fixtures/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const ADMIN_DASHBOARD_PATTERN = /\/_emdash\/admin\/?$/;
const CONTENT_ID_EXTRACTION_PATTERN = /\/content\/[^/]+\/([^/]+)$/;
const MENU_URL_PATTERN = /\/_emdash\/admin\/menus\//;
const SETUP_PAGE_PATTERN = /\/_emdash\/admin\/setup/;
const DEFAULT_LOCALE_SUFFIX_PATTERN = / \(default\)$/;

export class AdminPage {
readonly page: Page;
Expand Down Expand Up @@ -559,12 +560,15 @@ export class AdminPage {
}

/**
* Get the locale switcher select value from the content list.
* Get the locale selected in the content list.
*/
async getLocaleFilterValue(): Promise<string | null> {
const select = this.page.locator("select").first();
const select = this.page.getByRole("combobox", { name: "Locale" });
if (await select.isVisible({ timeout: 3000 }).catch(() => false)) {
return select.inputValue();
const label = (await select.innerText()).trim();
return label === "All locales"
? ""
: label.replace(DEFAULT_LOCALE_SUFFIX_PATTERN, "").toLowerCase();
}
return null;
}
Expand All @@ -573,7 +577,11 @@ export class AdminPage {
* Change the locale filter in the content list.
*/
async setLocaleFilter(locale: string): Promise<void> {
await this.page.locator("select").first().selectOption(locale);
await this.page.getByRole("combobox", { name: "Locale" }).click();
const label = locale
? new RegExp(`^${locale.toUpperCase()}(?: \\(default\\))?$`)
: "All locales";
await this.page.getByRole("option", { name: label }).click();
await this.waitForLoading();
}

Expand Down
154 changes: 141 additions & 13 deletions e2e/tests/i18n.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,92 @@
* - pages: "About" (en, published), "Contact" (en, draft)
*/

import { test, expect } from "../fixtures";
import { test, expect } from "../fixtures/index.js";

// The edit route preserves the entry's locale as a `?locale=` search param
// (see #1242), so the URL may carry a query string after the ULID.
const CONTENT_EDIT_URL_PATTERN = /\/content\/posts\/[A-Z0-9]+(?:\?.*)?$/;

const localeTest = test.extend<{
localeCollection: {
collection: string;
createPost: (locale: string, title: string) => Promise<void>;
};
}>({
localeCollection: async ({ request, serverInfo }, use) => {
const headers = {
Authorization: `Bearer ${serverInfo.token}`,
"X-EmDash-Request": "1",
Origin: serverInfo.baseUrl,
};
const collection = `locale_switcher_${crypto.randomUUID().replaceAll("-", "")}`;
const collectionPath = `${serverInfo.baseUrl}/_emdash/api/schema/collections/${collection}`;
const contentPath = `${serverInfo.baseUrl}/_emdash/api/content/${collection}`;
const postIds: string[] = [];
const pendingPosts = new Map<string, string>();

try {
const created = await request.post(`${serverInfo.baseUrl}/_emdash/api/schema/collections`, {
headers,
data: { slug: collection, label: "Posts", labelSingular: "Post", supports: ["drafts"] },
});
await expect(created).toBeOK();

const field = await request.post(`${collectionPath}/fields`, {
headers,
data: { slug: "title", label: "Title", type: "string", required: true },
});
await expect(field).toBeOK();

await use({
collection,
createPost: async (locale, title) => {
const slug = `post-${locale}`;
pendingPosts.set(slug, locale);
const response = await request.post(contentPath, {
headers,
data: { data: { title }, slug, locale },
});
await expect(response).toBeOK();
const body = await response.json();
postIds.push(body.data.item.id);
pendingPosts.delete(slug);
},
});
} finally {
for (const [slug, locale] of pendingPosts) {
await expect
.soft(
request
.get(`${contentPath}/${slug}`, { headers, params: { locale } })
.then(async (response) => {
if (response.status() === 404) return true;
await expect(response).toBeOK();
const body = await response.json();
postIds.push(body.data.item.id);
return true;
}),
)
.resolves.toBe(true);
}
const paths = postIds.flatMap((id) => [
`${contentPath}/${id}`,
`${contentPath}/${id}/permanent`,
]);
for (const path of paths) {
await expect.soft(request.delete(path, { headers })).resolves.toBeOK();
}
await expect
.soft(
request
.delete(collectionPath, { headers })
.then((response) => response.ok() || response.status() === 404),
)
.resolves.toBe(true);
}
},
});

interface CreatePostInput {
title: string;
slug: string;
Expand Down Expand Up @@ -179,22 +259,70 @@ test.describe("i18n", () => {
}
});

test("has a locale filter switcher", async ({ admin }) => {
await admin.goToContent("posts");
localeTest("has a locale filter switcher", async ({ admin, serverInfo, localeCollection }) => {
const { collection, createPost } = localeCollection;
await createPost("en", "First Post");
await admin.goToContent(collection);
await admin.waitForLoading();
await expect(admin.page.getByRole("searchbox", { name: "Search posts" })).toHaveValue("");

// Should have a select element for locale filtering
const select = admin.page.locator("select").first();
const select = admin.page.getByRole("combobox", { name: "Locale" });
const englishPost = admin.page.getByRole("link", { name: "First Post", exact: true });
const emptyMessage = admin.page.getByText("No posts yet.");
await expect(select).toBeVisible();

// Should show available locale options
const options = select.locator("option");
const optionTexts = await options.allTextContents();
// Expect EN, FR, ES options (may also have "All locales")
expect(optionTexts.some((t) => t.includes("EN"))).toBe(true);
expect(optionTexts.some((t) => t.includes("FR"))).toBe(true);
expect(optionTexts.some((t) => t.includes("ES"))).toBe(true);
await expect(select).toHaveText("EN (default)");
await expect(englishPost).toBeVisible();
await select.click();

await expect(admin.page.getByRole("option", { name: "EN (default)" })).toBeVisible();
await expect(admin.page.getByRole("option", { name: "FR" })).toBeVisible();
await expect(admin.page.getByRole("option", { name: "ES" })).toBeVisible();
await admin.page.keyboard.press("Escape");

await admin.setLocaleFilter("fr");
await expect(select).toHaveText("FR");
await expect(admin.page).toHaveURL(
`${serverInfo.baseUrl}/_emdash/admin/content/${collection}?locale=fr`,
);
expect(await admin.getLocaleFilterValue()).toBe("fr");
await expect(emptyMessage).toBeVisible();
await expect(englishPost).toHaveCount(0);

await admin.setLocaleFilter("en");
await expect(select).toHaveText("EN (default)");
expect(await admin.getLocaleFilterValue()).toBe("en");
await expect(englishPost).toBeVisible();
await expect(emptyMessage).toHaveCount(0);
});

localeTest(
"shows only content from the selected locale",
async ({ admin, localeCollection }) => {
const { collection, createPost } = localeCollection;
await createPost("en", "First Post");
await createPost("fr", "French Post");
await admin.goToContent(collection);
await admin.waitForLoading();
await expect(admin.page.getByRole("searchbox", { name: "Search posts" })).toHaveValue("");

const select = admin.page.getByRole("combobox", { name: "Locale" });
const englishPost = admin.page.getByRole("link", { name: "First Post", exact: true });
const frenchPost = admin.page.getByRole("link", { name: "French Post", exact: true });
await expect(select).toHaveText("EN (default)");
await expect(englishPost).toBeVisible();
await expect(frenchPost).toHaveCount(0);

await admin.setLocaleFilter("fr");
await expect(select).toHaveText("FR");
await expect(frenchPost).toBeVisible();
await expect(englishPost).toHaveCount(0);

await admin.setLocaleFilter("en");
await expect(select).toHaveText("EN (default)");
await expect(englishPost).toBeVisible();
await expect(frenchPost).toHaveCount(0);
},
);
});

test.describe("Content Editor", () => {
Expand Down
20 changes: 20 additions & 0 deletions e2e/tests/visual-regression.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ interface PageCase {
name: string;
path: (info: ServerInfo) => string;
viewport?: { width: number; height: number };
theme?: "light" | "dark";
prepare?: (admin: AdminPage) => Promise<void>;
}

Expand Down Expand Up @@ -97,6 +98,17 @@ const PAGES: PageCase[] = [
{ name: "media", path: () => "/media" },
{ name: "media-mobile", path: () => "/media", viewport: { width: 320, height: 800 } },
{ name: "menus", path: () => "/menus" },
{
name: "menus-locale-switcher-open",
path: () => "/menus",
prepare: openFilter(".emdash-locale-switcher-trigger", '[role="listbox"]:visible'),
},
{
name: "menus-locale-switcher-open-dark",
path: () => "/menus",
theme: "dark",
prepare: openFilter(".emdash-locale-switcher-trigger", '[role="listbox"]:visible'),
},
{ name: "settings", path: () => "/settings" },
];

Expand Down Expand Up @@ -229,8 +241,16 @@ test.describe("visual regression", () => {
pageCase.name === "content-editor" ? serverInfo.contentIds.posts[0] : undefined,
);
await setLocale(admin, locale.code);
if (pageCase.theme) {
await admin.page.addInitScript((theme) => {
localStorage.setItem("emdash-theme", theme);
}, pageCase.theme);
}
if (pageCase.viewport) await admin.page.setViewportSize(pageCase.viewport);
await openAdmin(admin, pageCase.path(serverInfo), locale.dir);
if (pageCase.theme) {
await expect(admin.page.locator("html")).toHaveAttribute("data-mode", pageCase.theme);
}
await stabilize(admin);
await pageCase.prepare?.(admin);

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 26 additions & 14 deletions packages/admin/src/components/LocaleSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* Only renders when i18n is configured (manifest.i18n is present).
*/

import { Select } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { GlobeSimple } from "@phosphor-icons/react";
import React from "react";
Expand Down Expand Up @@ -48,31 +49,42 @@ export function LocaleSwitcher({
size = "md",
}: LocaleSwitcherProps) {
const { t } = useLingui();
const formatLocaleLabel = (locale: string) => {
const code = locale.toUpperCase();
return locale === defaultLocale ? (
<>
{code}
{t` (default)`}
</>
) : (
code
);
};

return (
<div className={cn("flex items-center gap-1.5", className)}>
<GlobeSimple
className={cn("text-kumo-subtle shrink-0", size === "sm" ? "size-3.5" : "size-4")}
weight="bold"
/>
<select
<Select<string>
Comment thread
MatsudaTsunenori marked this conversation as resolved.
className="emdash-locale-switcher-trigger"
value={value}
onChange={(e) => onChange(e.target.value)}
onValueChange={(nextValue) => {
if (typeof nextValue === "string" && nextValue !== value) onChange(nextValue);
}}
placeholder={showAll ? t`All locales` : undefined}
renderValue={formatLocaleLabel}
aria-label={t`Locale`}
className={cn(
"rounded-md border bg-transparent font-medium transition-colors",
"focus:ring-kumo-ring focus:outline-none focus:ring-2 focus:ring-offset-1",
"hover:bg-kumo-tint/50 cursor-pointer",
size === "sm" ? "px-1.5 py-0.5 text-xs" : "px-2 py-1 text-sm",
)}
size={size === "sm" ? "sm" : "base"}
>
{showAll && <option value="">{t`All locales`}</option>}
{showAll && <Select.Option value="">{t`All locales`}</Select.Option>}
{locales.map((locale) => (
<option key={locale} value={locale}>
{locale.toUpperCase()}
{locale === defaultLocale ? t` (default)` : ""}
</option>
<Select.Option key={locale} value={locale}>
{formatLocaleLabel(locale)}
</Select.Option>
))}
</select>
</Select>
</div>
);
}
Expand Down
Loading
Loading