Skip to content
Draft
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/fix-locale-aware-tags.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion packages/admin/src/components/ContentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1031,7 +1031,11 @@ export function ContentEditor({
{/* Taxonomy selector */}
{item && (
<div className="p-4 border-t">
<TaxonomySidebar collection={collection} entryId={item.id} />
<TaxonomySidebar
collection={collection}
entryId={item.id}
entryLocale={item.locale ?? entryLocale}
/>
</div>
)}

Expand Down
65 changes: 46 additions & 19 deletions packages/admin/src/components/TaxonomySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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;
}

Expand All @@ -56,10 +59,11 @@ async function fetchTaxonomyDefs(): Promise<TaxonomyDef[]> {
}

/**
* 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<TaxonomyTerm[]> {
const res = await apiFetch(`/_emdash/api/taxonomies/${taxonomyName}/terms`);
async function fetchTerms(taxonomyName: string, locale?: string): Promise<TaxonomyTerm[]> {
const res = await apiFetch(withLocale(`/_emdash/api/taxonomies/${taxonomyName}/terms`, locale));
const data = await parseApiResponse<{ terms: TaxonomyTerm[] }>(
res,
i18n._(msg`Failed to fetch terms`),
Expand All @@ -74,8 +78,11 @@ async function fetchEntryTerms(
collection: string,
entryId: string,
taxonomy: string,
locale?: string,
): Promise<TaxonomyTerm[]> {
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`),
Expand All @@ -91,12 +98,16 @@ async function setEntryTerms(
entryId: string,
taxonomy: string,
termIds: string[],
locale?: string,
): Promise<void> {
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`));
}

Expand Down Expand Up @@ -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();
Expand All @@ -291,27 +304,27 @@ 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,
});

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` });
},
Expand All @@ -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);
Expand Down Expand Up @@ -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"],
Expand All @@ -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)}
/>
))}
Expand Down
2 changes: 1 addition & 1 deletion packages/admin/src/lib/api/taxonomies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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) => ({
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) => ({
Expand Down
Loading
Loading