diff --git a/.changeset/fix-locale-aware-tags.md b/.changeset/fix-locale-aware-tags.md new file mode 100644 index 000000000..a61e157a8 --- /dev/null +++ b/.changeset/fix-locale-aware-tags.md @@ -0,0 +1,6 @@ +--- +"emdash": patch +"@emdash-cms/admin": patch +--- + +Fix taxonomy terms not being locale-aware in the content editor (#1218). Term assignments are stored against the per-locale content row while the term's `translation_group` spans every locale, so resolving terms for an entry must scope to the entry's locale. The content terms endpoint (`/content/:collection/:id/terms/:taxonomy`) now derives the entry's locale server-side and passes it to `getTermsForEntry`, and the admin `TaxonomySidebar` threads the entry locale through its fetch/save calls (and into its React Query keys, so switching translations refetches). Previously a localized post showed and applied every locale variant of a tag instead of just the variant for its own locale. diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index 523ad9e36..0bece59c5 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -1031,7 +1031,11 @@ export function ContentEditor({ {/* Taxonomy selector */} {item && (
- +
)} diff --git a/packages/admin/src/components/TaxonomySidebar.tsx b/packages/admin/src/components/TaxonomySidebar.tsx index cc0335e5e..9d72d1326 100644 --- a/packages/admin/src/components/TaxonomySidebar.tsx +++ b/packages/admin/src/components/TaxonomySidebar.tsx @@ -15,7 +15,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import * as React from "react"; import { apiFetch, parseApiResponse, throwResponseError } from "../lib/api/client.js"; -import { createTerm } from "../lib/api/taxonomies.js"; +import { createTerm, withLocale } from "../lib/api/taxonomies.js"; import { termExactMatches, termMatches } from "../lib/taxonomy-match.js"; import { slugify } from "../lib/utils.js"; @@ -40,6 +40,9 @@ interface TaxonomyDef { interface TaxonomySidebarProps { collection: string; entryId?: string; + /** Locale of the entry being edited. Scopes term reads/writes so only the + * matching translation variants are shown — see issue #1218. */ + entryLocale?: string; onChange?: (taxonomyName: string, termIds: string[]) => void; } @@ -56,10 +59,11 @@ async function fetchTaxonomyDefs(): Promise { } /** - * Fetch terms for a taxonomy + * Fetch terms for a taxonomy, scoped to the entry's locale so only the matching + * translation variants are offered. */ -async function fetchTerms(taxonomyName: string): Promise { - const res = await apiFetch(`/_emdash/api/taxonomies/${taxonomyName}/terms`); +async function fetchTerms(taxonomyName: string, locale?: string): Promise { + const res = await apiFetch(withLocale(`/_emdash/api/taxonomies/${taxonomyName}/terms`, locale)); const data = await parseApiResponse<{ terms: TaxonomyTerm[] }>( res, i18n._(msg`Failed to fetch terms`), @@ -74,8 +78,11 @@ async function fetchEntryTerms( collection: string, entryId: string, taxonomy: string, + locale?: string, ): Promise { - const res = await apiFetch(`/_emdash/api/content/${collection}/${entryId}/terms/${taxonomy}`); + const res = await apiFetch( + withLocale(`/_emdash/api/content/${collection}/${entryId}/terms/${taxonomy}`, locale), + ); const data = await parseApiResponse<{ terms: TaxonomyTerm[] }>( res, i18n._(msg`Failed to fetch entry terms`), @@ -91,12 +98,16 @@ async function setEntryTerms( entryId: string, taxonomy: string, termIds: string[], + locale?: string, ): Promise { - const res = await apiFetch(`/_emdash/api/content/${collection}/${entryId}/terms/${taxonomy}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ termIds }), - }); + const res = await apiFetch( + withLocale(`/_emdash/api/content/${collection}/${entryId}/terms/${taxonomy}`, locale), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ termIds }), + }, + ); if (!res.ok) await throwResponseError(res, i18n._(msg`Failed to set entry terms`)); } @@ -277,11 +288,13 @@ function TaxonomySection({ taxonomy, collection, entryId, + entryLocale, onChange, }: { taxonomy: TaxonomyDef; collection: string; entryId?: string; + entryLocale?: string; onChange?: (termIds: string[]) => void; }) { const { t } = useLingui(); @@ -291,15 +304,15 @@ function TaxonomySection({ const [showCategoryInput, setShowCategoryInput] = React.useState(false); const { data: terms = [] } = useQuery({ - queryKey: ["taxonomy-terms", taxonomy.name], - queryFn: () => fetchTerms(taxonomy.name), + queryKey: ["taxonomy-terms", taxonomy.name, entryLocale], + queryFn: () => fetchTerms(taxonomy.name, entryLocale), }); const { data: entryTerms = [] } = useQuery({ - queryKey: ["entry-terms", collection, entryId, taxonomy.name], + queryKey: ["entry-terms", collection, entryId, taxonomy.name, entryLocale], queryFn: () => { if (!entryId) return []; - return fetchEntryTerms(collection, entryId, taxonomy.name); + return fetchEntryTerms(collection, entryId, taxonomy.name, entryLocale); }, enabled: !!entryId, }); @@ -307,11 +320,11 @@ function TaxonomySection({ const saveMutation = useMutation({ mutationFn: (termIds: string[]) => { if (!entryId) throw new Error("No entry ID"); - return setEntryTerms(collection, entryId, taxonomy.name, termIds); + return setEntryTerms(collection, entryId, taxonomy.name, termIds, entryLocale); }, onSuccess: () => { void queryClient.invalidateQueries({ - queryKey: ["entry-terms", collection, entryId, taxonomy.name], + queryKey: ["entry-terms", collection, entryId, taxonomy.name, entryLocale], }); toastManager.add({ title: t`${taxonomy.label} updated` }); }, @@ -325,9 +338,17 @@ function TaxonomySection({ }); const createTermMutation = useMutation({ - mutationFn: (label: string) => createTerm(taxonomy.name, { slug: slugify(label), label }), + mutationFn: (label: string) => + createTerm(taxonomy.name, { + slug: slugify(label), + label, + // Create the term in the entry's locale so it resolves on this entry. + ...(entryLocale ? { locale: entryLocale } : {}), + }), onSuccess: (newTerm) => { - void queryClient.invalidateQueries({ queryKey: ["taxonomy-terms", taxonomy.name] }); + void queryClient.invalidateQueries({ + queryKey: ["taxonomy-terms", taxonomy.name, entryLocale], + }); // Auto-select the newly created term const newSelected = new Set(selectedIds); newSelected.add(newTerm.id); @@ -475,7 +496,12 @@ function TaxonomySection({ /** * Main TaxonomySidebar component */ -export function TaxonomySidebar({ collection, entryId, onChange }: TaxonomySidebarProps) { +export function TaxonomySidebar({ + collection, + entryId, + entryLocale, + onChange, +}: TaxonomySidebarProps) { const { t } = useLingui(); const { data: taxonomies = [] } = useQuery({ queryKey: ["taxonomy-defs"], @@ -500,6 +526,7 @@ export function TaxonomySidebar({ collection, entryId, onChange }: TaxonomySideb taxonomy={taxonomy} collection={collection} entryId={entryId} + entryLocale={entryLocale} onChange={(termIds) => onChange?.(taxonomy.name, termIds)} /> ))} diff --git a/packages/admin/src/lib/api/taxonomies.ts b/packages/admin/src/lib/api/taxonomies.ts index 42fc2e699..2c39a9bb2 100644 --- a/packages/admin/src/lib/api/taxonomies.ts +++ b/packages/admin/src/lib/api/taxonomies.ts @@ -90,7 +90,7 @@ export interface LocaleOptions { locale?: string; } -function withLocale(path: string, locale?: string): string { +export function withLocale(path: string, locale?: string): string { return locale ? `${path}${path.includes("?") ? "&" : "?"}locale=${encodeURIComponent(locale)}` : path; diff --git a/packages/core/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts b/packages/core/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts index af76d5ee1..57e57e12a 100644 --- a/packages/core/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +++ b/packages/core/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts @@ -11,6 +11,7 @@ import { requirePerm, requireOwnerPerm } from "#api/authorize.js"; import { apiError, apiSuccess, handleError, requireDb } from "#api/error.js"; import { parseBody, isParseError } from "#api/parse.js"; import { contentTermsBody } from "#api/schemas.js"; +import { ContentRepository } from "#db/repositories/content.js"; import { TaxonomyRepository } from "#db/repositories/taxonomy.js"; import { invalidateTermCache } from "#taxonomies/index.js"; @@ -34,8 +35,15 @@ export const GET: APIRoute = async ({ params, locals }) => { if (dbErr) return dbErr; try { + // Terms are stored against the per-locale entry row but their + // translation_group spans every locale. Resolve the entry's own locale + // server-side (deterministic, not client-spoofable) so only the matching + // term variant is returned — see issue #1218. + const entry = await new ContentRepository(emdash.db).findByIdOrSlug(collection, id); + const locale = entry?.locale ?? undefined; + const repo = new TaxonomyRepository(emdash.db); - const terms = await repo.getTermsForEntry(collection, id, taxonomy); + const terms = await repo.getTermsForEntry(collection, id, taxonomy, locale); return apiSuccess({ terms: terms.map((t) => ({ @@ -101,6 +109,9 @@ export const POST: APIRoute = async ({ params, request, locals }) => { // Resolve the canonical content ID from the handler result. // The URL `id` param may be a slug; we must use the real ID for term storage. const canonicalId = typeof existingItem?.id === "string" ? existingItem.id : id; + // The entry is per-locale; scope the term read to its locale so only the + // matching translation variant is returned in the response — see #1218. + const entryLocale = typeof existingItem?.locale === "string" ? existingItem.locale : undefined; try { const body = await parseBody(request, contentTermsBody); @@ -131,8 +142,8 @@ export const POST: APIRoute = async ({ params, request, locals }) => { // so hydration on subsequent reads issues a fresh query. invalidateTermCache(); - // Get the updated terms using the canonical ID - const terms = await repo.getTermsForEntry(collection, canonicalId, taxonomy); + // Get the updated terms using the canonical ID, scoped to the entry locale + const terms = await repo.getTermsForEntry(collection, canonicalId, taxonomy, entryLocale); return apiSuccess({ terms: terms.map((t) => ({ diff --git a/packages/core/tests/integration/taxonomies/taxonomy-locale-terms.test.ts b/packages/core/tests/integration/taxonomies/taxonomy-locale-terms.test.ts new file mode 100644 index 000000000..dfb06789a --- /dev/null +++ b/packages/core/tests/integration/taxonomies/taxonomy-locale-terms.test.ts @@ -0,0 +1,214 @@ +/** + * Locale-aware term resolution for content entries (issue #1218). + * + * The storage model is correct: `content_taxonomies` stores + * `entry_id` = the per-locale content row id and `taxonomy_id` = the term's + * `translation_group` (which spans every locale). Resolving the terms for an + * entry must therefore scope to the entry's own locale, otherwise EVERY locale + * variant of the term is returned. + * + * The bug was that the admin content-editor terms route + * (`/content/:collection/:id/terms/:taxonomy`) never passed a locale, so a + * French post showed both the English and French variants of its tag. + */ + +import { Role, type RoleLevel } from "@emdash-cms/auth"; +import type { APIContext } from "astro"; +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { handleContentGet } from "../../../src/api/handlers/content.js"; +import { + GET as getTerms, + POST as postTerms, +} from "../../../src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].js"; +import { ContentRepository } from "../../../src/database/repositories/content.js"; +import { TaxonomyRepository } from "../../../src/database/repositories/taxonomy.js"; +import type { Database } from "../../../src/database/types.js"; +import { + describeEachDialect, + setupForDialectWithCollections, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +interface TermFixture { + enContentId: string; + frContentId: string; + enTagId: string; + frTagId: string; +} + +async function seedLocalizedTags(db: Kysely): Promise { + const contentRepo = new ContentRepository(db); + const taxRepo = new TaxonomyRepository(db); + + // Two content rows: EN + FR, same translation group. + const enContent = await contentRepo.create({ + type: "post", + slug: "hello", + data: { title: "Hello" }, + locale: "en", + }); + const frContent = await contentRepo.create({ + type: "post", + slug: "bonjour", + data: { title: "Bonjour" }, + locale: "fr", + translationOf: enContent.id, + }); + + // One tag with an EN + FR translation (shared translation_group). + const enTag = await taxRepo.create({ + name: "tags", + slug: "news", + label: "News", + locale: "en", + }); + const frTag = await taxRepo.create({ + name: "tags", + slug: "actualites", + label: "Actualités", + locale: "fr", + translationOf: enTag.id, + }); + + // Attach the tag (by group) to BOTH entries. + await taxRepo.attachToEntry("post", enContent.id, enTag.id); + await taxRepo.attachToEntry("post", frContent.id, enTag.id); + + return { + enContentId: enContent.id, + frContentId: frContent.id, + enTagId: enTag.id, + frTagId: frTag.id, + }; +} + +const adminUser = { + id: "u-admin", + email: "a@example.com", + name: "Admin", + role: Role.ADMIN as RoleLevel, +}; + +function buildGetContext( + db: Kysely, + params: { collection: string; id: string; taxonomy: string }, +): APIContext { + const url = new URL( + `http://localhost/_emdash/api/content/${params.collection}/${params.id}/terms/${params.taxonomy}`, + ); + return { + params, + url, + request: new Request(url, { headers: { "X-EmDash-Request": "1" } }), + locals: { + emdash: { + db, + handleContentGet: (collection: string, id: string, locale?: string) => + handleContentGet(db, collection, id, locale), + }, + user: adminUser, + }, + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- minimal stub for tests + } as unknown as APIContext; +} + +function buildPostContext( + db: Kysely, + params: { collection: string; id: string; taxonomy: string }, + termIds: string[], +): APIContext { + const url = new URL( + `http://localhost/_emdash/api/content/${params.collection}/${params.id}/terms/${params.taxonomy}`, + ); + return { + params, + url, + request: new Request(url, { + method: "POST", + headers: { "Content-Type": "application/json", "X-EmDash-Request": "1" }, + body: JSON.stringify({ termIds }), + }), + locals: { + emdash: { + db, + handleContentGet: (collection: string, id: string, locale?: string) => + handleContentGet(db, collection, id, locale), + }, + user: adminUser, + }, + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- minimal stub for tests + } as unknown as APIContext; +} + +interface TermsResponse { + data?: { terms?: Array<{ id: string; slug: string; label: string }> }; + error?: { code: string }; +} + +describeEachDialect("content terms route locale-awareness (#1218)", (dialect) => { + let ctx: DialectTestContext; + + beforeEach(async () => { + ctx = await setupForDialectWithCollections(dialect); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + it("repository resolves only the entry-locale variant when locale is given", async () => { + const fx = await seedLocalizedTags(ctx.db); + const taxRepo = new TaxonomyRepository(ctx.db); + + const all = await taxRepo.getTermsForEntry("post", fx.frContentId, "tags"); + expect(all).toHaveLength(2); // bug surface: both locales without a filter + + const frOnly = await taxRepo.getTermsForEntry("post", fx.frContentId, "tags", "fr"); + expect(frOnly).toHaveLength(1); + expect(frOnly[0]!.id).toBe(fx.frTagId); + }); + + it("GET returns only the FR variant for the FR entry", async () => { + const fx = await seedLocalizedTags(ctx.db); + + const res = await getTerms( + buildGetContext(ctx.db, { collection: "post", id: fx.frContentId, taxonomy: "tags" }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as TermsResponse; + expect(body.error).toBeUndefined(); + const ids = (body.data?.terms ?? []).map((t) => t.id); + expect(ids).toEqual([fx.frTagId]); + }); + + it("GET returns only the EN variant for the EN entry", async () => { + const fx = await seedLocalizedTags(ctx.db); + + const res = await getTerms( + buildGetContext(ctx.db, { collection: "post", id: fx.enContentId, taxonomy: "tags" }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as TermsResponse; + const ids = (body.data?.terms ?? []).map((t) => t.id); + expect(ids).toEqual([fx.enTagId]); + }); + + it("POST response echoes only the entry-locale variant", async () => { + const fx = await seedLocalizedTags(ctx.db); + + // Re-set the FR entry's tags via the EN term id (resolved to the group). + const res = await postTerms( + buildPostContext(ctx.db, { collection: "post", id: fx.frContentId, taxonomy: "tags" }, [ + fx.enTagId, + ]), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as TermsResponse; + expect(body.error).toBeUndefined(); + const ids = (body.data?.terms ?? []).map((t) => t.id); + expect(ids).toEqual([fx.frTagId]); + }); +});