Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/fair-taxis-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@emdash-cms/admin": patch
"emdash": patch
---

Fixes localized taxonomy navigation, editor choices, and visible term counts so each surface follows the active content locale.
10 changes: 8 additions & 2 deletions packages/admin/src/components/ContentSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,12 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({
const [publishedDate, setPublishedDate] = React.useState(storedPublishedDate);
const [isReorderingSections, setIsReorderingSections] = React.useState(false);
const showDiscard = !isNew && supportsDrafts && hasPendingChanges && !!onDiscardDraft;
const hasApplicableTaxonomies = useHasApplicableTaxonomies(collection);
const activeEntryLocale = item?.locale ?? entryLocale ?? undefined;
const hasApplicableTaxonomies = useHasApplicableTaxonomies(
collection,
activeEntryLocale,
i18n?.defaultLocale,
);
const canUpdatePublishedDate =
item?.publishedAt != null && (currentUser?.role ?? 0) >= ROLE_EDITOR && !!onPublishedAtChange;

Expand Down Expand Up @@ -657,7 +662,8 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({
className="p-4"
collection={collection}
entryId={item.id}
entryLocale={item.locale ?? entryLocale}
entryLocale={activeEntryLocale}
defaultLocale={i18n?.defaultLocale}
/>
</SortableContentSettingsSection>
)}
Expand Down
4 changes: 4 additions & 0 deletions packages/admin/src/components/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,13 @@ export interface ShellProps {
}
>;
taxonomies: Array<{
id?: string;
name: string;
label: string;
locale?: string;
translationGroup?: string | null;
}>;
i18n?: { defaultLocale: string; locales: string[] };
version?: string;
};
}
Expand Down
42 changes: 34 additions & 8 deletions packages/admin/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import * as React from "react";
import { fetchCommentCounts } from "../lib/api/comments";
import { useCurrentUser } from "../lib/api/current-user";
import { resolvePluginPagePath, usePluginAdmins } from "../lib/plugin-context";
import {
resolveTaxonomyDefinitions,
type LocalizedTaxonomyDefinition,
} from "../lib/taxonomy-definitions.js";
import {
ADMIN_NAV_ICONS,
getCollectionNavIcon,
Expand Down Expand Up @@ -76,9 +80,13 @@ export interface SidebarNavProps {
}
>;
taxonomies: Array<{
id?: string;
name: string;
label: string;
locale?: string;
translationGroup?: string | null;
}>;
i18n?: { defaultLocale: string; locales: string[] };
version?: string;
commit?: string;
marketplace?: string;
Expand All @@ -93,11 +101,21 @@ export interface SidebarNavProps {
};
}

/** Locale-normalized taxonomy rows used by the global Manage navigation. */
export function getSidebarTaxonomies<T extends LocalizedTaxonomyDefinition>(
taxonomies: readonly T[],
activeLocale?: string,
defaultLocale?: string,
): T[] {
return resolveTaxonomyDefinitions(taxonomies, activeLocale, defaultLocale);
}

interface NavItem {
to: string;
label: string;
icon: React.ElementType;
params?: Record<string, string>;
search?: Record<string, string>;
/** Minimum role level required to see this item */
minRole?: number;
/** Optional badge count (e.g., pending comments) */
Expand Down Expand Up @@ -162,13 +180,16 @@ export function resolvePluginPageLabel(
}

/** Resolves a nav item's route path by substituting $param placeholders. */
function resolveItemPath(item: NavItem): string {
export function resolveItemPath(item: NavItem): string {
let path = item.to;
if (item.params) {
for (const [key, value] of Object.entries(item.params)) {
path = path.replace(`$${key}`, value);
}
}
if (item.search && Object.keys(item.search).length > 0) {
path += `?${new URLSearchParams(item.search).toString()}`;
}
return path;
}

Expand All @@ -186,6 +207,8 @@ export function SidebarNav({ manifest }: SidebarNavProps) {
const { t, i18n } = useLingui();
const location = useLocation();
const currentPath = location.pathname;
const routeLocale =
new URL(location.href, "http://emdash.local").searchParams.get("locale") ?? undefined;
const pluginAdmins = usePluginAdmins();

const { data: user } = useCurrentUser();
Expand Down Expand Up @@ -232,13 +255,16 @@ export function SidebarNav({ manifest }: SidebarNavProps) {
},
{ to: "/widgets", label: t`Widgets`, icon: ADMIN_NAV_ICONS.widgets, minRole: ROLE_EDITOR },
{ to: "/sections", label: t`Sections`, icon: ADMIN_NAV_ICONS.sections, minRole: ROLE_EDITOR },
...manifest.taxonomies.map((tax) => ({
to: "/taxonomies/$taxonomy" as const,
label: tax.label,
icon: getTaxonomyNavIcon(tax.name),
params: { taxonomy: tax.name },
minRole: ROLE_EDITOR,
})),
...getSidebarTaxonomies(manifest.taxonomies, routeLocale, manifest.i18n?.defaultLocale).map(
(tax) => ({
to: "/taxonomies/$taxonomy" as const,
label: tax.label,
icon: getTaxonomyNavIcon(tax.name),
params: { taxonomy: tax.name },
search: routeLocale ? { locale: routeLocale } : undefined,
minRole: ROLE_EDITOR,
}),
),
{ to: "/bylines", label: t`Bylines`, icon: ADMIN_NAV_ICONS.bylines, minRole: ROLE_EDITOR },
];

Expand Down
26 changes: 21 additions & 5 deletions packages/admin/src/components/TaxonomySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as React from "react";

import { apiFetch, parseApiResponse, throwResponseError } from "../lib/api/client.js";
import { createTerm, withLocale } from "../lib/api/taxonomies.js";
import { resolveTaxonomyDefinitions } from "../lib/taxonomy-definitions.js";
import { termExactMatches, termMatches } from "../lib/taxonomy-match.js";
import { cn, slugify } from "../lib/utils.js";

Expand All @@ -35,6 +36,8 @@ interface TaxonomyDef {
labelSingular?: string;
hierarchical: boolean;
collections: string[];
locale?: string;
translationGroup?: string | null;
}

interface TaxonomySidebarProps {
Expand All @@ -43,6 +46,8 @@ interface TaxonomySidebarProps {
/** Locale of the entry being edited. Scopes term reads/writes so only the
* matching translation variants are shown — see issue #1218. */
entryLocale?: string;
/** Site default used when this logical taxonomy has no entry-locale definition. */
defaultLocale?: string;
onChange?: (taxonomyName: string, termIds: string[]) => void;
/** Applied to the root when the section renders. Omitted when the section
* is empty so the caller doesn't need to guess whether to draw chrome. */
Expand All @@ -63,17 +68,27 @@ async function fetchTaxonomyDefs(): Promise<TaxonomyDef[]> {
return data.taxonomies;
}

function useApplicableTaxonomies(collection: string): TaxonomyDef[] {
function useApplicableTaxonomies(
collection: string,
activeLocale?: string,
defaultLocale?: string,
): TaxonomyDef[] {
const { data: taxonomies = [] } = useQuery({
queryKey: ["taxonomy-defs"],
queryFn: fetchTaxonomyDefs,
});
return taxonomies.filter((taxonomy) => taxonomy.collections.includes(collection));
return resolveTaxonomyDefinitions(taxonomies, activeLocale, defaultLocale).filter((taxonomy) =>
taxonomy.collections.includes(collection),
);
}

/** Whether the editor should include a taxonomy settings section. */
export function useHasApplicableTaxonomies(collection: string): boolean {
return useApplicableTaxonomies(collection).length > 0;
export function useHasApplicableTaxonomies(
collection: string,
activeLocale?: string,
defaultLocale?: string,
): boolean {
return useApplicableTaxonomies(collection, activeLocale, defaultLocale).length > 0;
}

/**
Expand Down Expand Up @@ -538,11 +553,12 @@ export function TaxonomySidebar({
collection,
entryId,
entryLocale,
defaultLocale,
onChange,
className,
}: TaxonomySidebarProps) {
const { t } = useLingui();
const applicableTaxonomies = useApplicableTaxonomies(collection);
const applicableTaxonomies = useApplicableTaxonomies(collection, entryLocale, defaultLocale);

if (applicableTaxonomies.length === 0) {
return null;
Expand Down
3 changes: 3 additions & 0 deletions packages/admin/src/lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,11 +179,14 @@ export interface AdminManifest {
* Taxonomy definitions for the admin sidebar.
*/
taxonomies: Array<{
id?: string;
name: string;
label: string;
labelSingular?: string;
hierarchical: boolean;
collections: string[];
locale?: string;
translationGroup?: string | null;
}>;
/**
* Marketplace registry URL. Present when `marketplace` is configured
Expand Down
59 changes: 59 additions & 0 deletions packages/admin/src/lib/taxonomy-definitions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
export interface LocalizedTaxonomyDefinition {
id?: string;
name: string;
label: string;
locale?: string;
translationGroup?: string | null;
}

function normalizedLocale(locale: string | undefined): string | undefined {
return locale?.trim().toLowerCase() || undefined;
}

/**
* Collapse localized definition rows to one row per logical taxonomy.
*
* Selection prefers the active locale, then the configured default locale.
* If neither exists, the lexically first locale/id/label wins so incomplete
* translation groups remain usable and produce stable manifests and UI.
* Legacy definitions without translation metadata are grouped by `name`.
*/
export function resolveTaxonomyDefinitions<T extends LocalizedTaxonomyDefinition>(
definitions: readonly T[],
activeLocale?: string,
defaultLocale?: string,
): T[] {
const active = normalizedLocale(activeLocale);
const fallback = normalizedLocale(defaultLocale);
const groups = new Map<string, T[]>();

for (const definition of definitions) {
const group = definition.translationGroup?.trim() || definition.name;
const variants = groups.get(group);
if (variants) variants.push(definition);
else groups.set(group, [definition]);
}

return Array.from(groups.values(), (variants) => {
const exact = active
? variants.find((definition) => normalizedLocale(definition.locale) === active)
: undefined;
if (exact) return exact;

const defaultVariant = fallback
? variants.find((definition) => normalizedLocale(definition.locale) === fallback)
: undefined;
if (defaultVariant) return defaultVariant;

return variants.toSorted((left, right) => {
const leftKey = [normalizedLocale(left.locale) ?? "", left.id ?? "", left.name, left.label];
const rightKey = [
normalizedLocale(right.locale) ?? "",
right.id ?? "",
right.name,
right.label,
];
return leftKey.join("\0").localeCompare(rightKey.join("\0"));
})[0]!;
});
}
41 changes: 41 additions & 0 deletions packages/admin/tests/components/Sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import { describe, it, expect } from "vitest";
import {
BYLINE_SCHEMA_NAV_ITEM,
filterNavItemsByRole,
getSidebarTaxonomies,
resolveItemPath,
resolveNavIcon,
resolvePluginPageLabel,
toPhosphorIconName,
Expand All @@ -50,6 +52,45 @@ const ROLE_AUTHOR = 30;
const ROLE_EDITOR = 40;
const ROLE_ADMIN = 50;

describe("getSidebarTaxonomies", () => {
const taxonomies = [
{ id: "course-en", name: "course", label: "Courses", locale: "en", translationGroup: "course" },
{ id: "course-de", name: "course", label: "Gänge", locale: "de", translationGroup: "course" },
{
id: "course-fr",
name: "course",
label: "Types de plats",
locale: "fr",
translationGroup: "course",
},
];

it("renders one logical taxonomy using the active route locale", () => {
expect(getSidebarTaxonomies(taxonomies, "de").map((taxonomy) => taxonomy.label)).toEqual([
"Gänge",
]);
});

it("falls back to the configured default locale, then deterministically", () => {
expect(getSidebarTaxonomies(taxonomies, "it", "fr")[0]?.label).toBe("Types de plats");
expect(getSidebarTaxonomies(taxonomies, "it")[0]?.label).toBe("Gänge");
});
});

describe("resolveItemPath", () => {
it("preserves the active locale on taxonomy-management links", () => {
expect(
resolveItemPath({
to: "/taxonomies/$taxonomy",
label: "Gänge",
icon: Gear,
params: { taxonomy: "course" },
search: { locale: "de" },
}),
).toBe("/taxonomies/course?locale=de");
});
});

describe("BYLINE_SCHEMA_NAV_ITEM invariants", () => {
it("points to the /byline-schema route", () => {
expect(BYLINE_SCHEMA_NAV_ITEM.to).toBe("/byline-schema");
Expand Down
34 changes: 34 additions & 0 deletions packages/admin/tests/components/TaxonomySidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ interface TestTaxonomy {
id: string;
name: string;
label: string;
locale?: string;
translationGroup?: string;
labelSingular?: string;
hierarchical: boolean;
collections: string[];
Expand Down Expand Up @@ -192,4 +194,36 @@ describe("TaxonomySidebar", () => {
await expect.element(screen.getByText("Alpha")).toBeInTheDocument();
expect(screen.getByLabelText("Add Categories").query()).toBeNull();
});

it("renders only the entry-locale definition for a translated taxonomy", async () => {
mockApiFetch({
taxonomies: [
{ ...tagsTaxonomy, id: "tags-en", label: "Tags", locale: "en", translationGroup: "tags" },
{
...tagsTaxonomy,
id: "tags-de",
label: "Schlagwörter",
locale: "de",
translationGroup: "tags",
},
{
...tagsTaxonomy,
id: "tags-fr",
label: "Étiquettes",
locale: "fr",
translationGroup: "tags",
},
],
});

const screen = await render(
<TaxonomySidebar collection="products" entryLocale="de" defaultLocale="en" />,
{ wrapper: Wrapper },
);

await expect.element(screen.getByText("Schlagwörter")).toBeInTheDocument();
expect(screen.getByText("Tags").query()).toBeNull();
expect(screen.getByText("Étiquettes").query()).toBeNull();
await expect.element(screen.getByLabelText("Add Schlagwörter")).toBeInTheDocument();
});
});
Loading
Loading