/** * Taxonomy Sidebar for Content Editor * * Shows taxonomy selection UI in the content editor sidebar. * - Checkbox tree for hierarchical taxonomies (categories) * - Tag input for flat taxonomies (tags) */ import { Button, Checkbox, Input, Label, Toast } from "@cloudflare/kumo"; import { i18n } from "@lingui/core"; import { msg } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { Plus, X } from "@phosphor-icons/react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import * as React from "react"; import { apiFetch, parseApiResponse, throwResponseError } from "../lib/api/client.js"; import { createTerm, withLocale } from "../lib/api/taxonomies.js"; import { termExactMatches, termMatches } from "../lib/taxonomy-match.js"; import { slugify } from "../lib/utils.js"; interface TaxonomyTerm { id: string; name: string; slug: string; label: string; parentId?: string; children: TaxonomyTerm[]; } interface TaxonomyDef { id: string; name: string; label: string; labelSingular?: string; hierarchical: boolean; collections: string[]; } 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; } const EMPTY_TERMS: TaxonomyTerm[] = []; /** * Fetch taxonomy definitions */ async function fetchTaxonomyDefs(): Promise { const res = await apiFetch(`/_emdash/api/taxonomies`); const data = await parseApiResponse<{ taxonomies: TaxonomyDef[] }>( res, "Failed to fetch taxonomies", ); return data.taxonomies; } /** * Fetch terms for a taxonomy, scoped to the entry's locale so only the matching * translation variants are offered. */ 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`), ); return data.terms; } /** * Fetch entry terms */ async function fetchEntryTerms( collection: string, entryId: string, taxonomy: string, locale?: string, ): Promise { 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`), ); return data.terms; } /** * Set entry terms */ async function setEntryTerms( collection: string, entryId: string, taxonomy: string, termIds: string[], locale?: string, ): Promise { 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`)); } /** * Checkbox tree for hierarchical taxonomies */ function CategoryCheckboxTree({ term, level = 0, selectedIds, onToggle, }: { term: TaxonomyTerm; level?: number; selectedIds: Set; onToggle: (termId: string) => void; }) { const isChecked = selectedIds.has(term.id); return (
onToggle(term.id)} label={{term.label}} />
{term.children.map((child) => ( ))}
); } /** * Tag input for flat taxonomies */ function TagInput({ terms, selectedIds, onAdd, onRemove, onCreate, isCreating, label, }: { terms: TaxonomyTerm[]; selectedIds: Set; onAdd: (termId: string) => void; onRemove: (termId: string) => void; onCreate: (label: string) => void; isCreating: boolean; label: string; }) { const { t } = useLingui(); const [input, setInput] = React.useState(""); const [isOpen, setIsOpen] = React.useState(false); const selectedTerms = terms.filter((term) => selectedIds.has(term.id)); const trimmedInput = input.trim(); const suggestions = React.useMemo(() => { const availableTerms = terms.filter((term) => !selectedIds.has(term.id)); if (!trimmedInput) return availableTerms.slice(0, 5); return availableTerms.filter((term) => termMatches(term, trimmedInput)).slice(0, 5); }, [trimmedInput, terms, selectedIds]); const hasExactMatch = React.useMemo(() => { if (!trimmedInput) return false; return terms.some((term) => termExactMatches(term, trimmedInput)); }, [trimmedInput, terms]); const showCreateOption = trimmedInput.length > 0 && !hasExactMatch; const handleSelect = (term: TaxonomyTerm) => { onAdd(term.id); setInput(""); setIsOpen(false); }; const handleCreate = () => { if (!trimmedInput || isCreating) return; onCreate(trimmedInput); setInput(""); setIsOpen(false); }; const handleBlur = (e: React.FocusEvent) => { const nextFocused = e.relatedTarget; if (nextFocused instanceof Node && e.currentTarget.contains(nextFocused)) return; setIsOpen(false); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); if (suggestions.length === 1 && !showCreateOption) { handleSelect(suggestions[0]!); } else if (showCreateOption && suggestions.length === 0) { handleCreate(); } } }; return (
{/* Selected tags */} {selectedTerms.length > 0 && (
{selectedTerms.map((term) => ( {term.label} ))}
)} {/* Input with autocomplete */}
{ setInput(e.target.value); setIsOpen(true); }} onFocus={() => setIsOpen(true)} onKeyDown={handleKeyDown} placeholder={t`Add tags...`} aria-label={t`Add ${label}`} className="text-sm" /> {/* Suggestions dropdown */} {isOpen && (suggestions.length > 0 || showCreateOption) && (
{suggestions.map((term) => ( ))} {showCreateOption && ( )}
)}
); } /** * Single taxonomy section */ function TaxonomySection({ taxonomy, collection, entryId, entryLocale, onChange, }: { taxonomy: TaxonomyDef; collection: string; entryId?: string; entryLocale?: string; onChange?: (termIds: string[]) => void; }) { const { t } = useLingui(); const queryClient = useQueryClient(); const toastManager = Toast.useToastManager(); const [newCategoryLabel, setNewCategoryLabel] = React.useState(""); const [showCategoryInput, setShowCategoryInput] = React.useState(false); const { data: terms = EMPTY_TERMS } = useQuery({ queryKey: ["taxonomy-terms", taxonomy.name, entryLocale], queryFn: () => fetchTerms(taxonomy.name, entryLocale), }); const { data: entryTerms = EMPTY_TERMS } = useQuery({ queryKey: ["entry-terms", collection, entryId, taxonomy.name, entryLocale], queryFn: () => { if (!entryId) return []; 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, entryLocale); }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["entry-terms", collection, entryId, taxonomy.name, entryLocale], }); toastManager.add({ title: t`${taxonomy.label} updated` }); }, onError: (error) => { toastManager.add({ title: t`Failed to update ${taxonomy.label.toLowerCase()}`, description: error instanceof Error ? error.message : t`An error occurred`, type: "error", }); }, }); const createTermMutation = useMutation({ 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, entryLocale], }); // Auto-select the newly created term const newSelected = new Set(selectedIds); newSelected.add(newTerm.id); setSelectedIds(newSelected); const termIdsArray = [...newSelected]; onChange?.(termIdsArray); if (entryId) { saveMutation.mutate(termIdsArray); } // Reset category input setNewCategoryLabel(""); setShowCategoryInput(false); }, }); const [selectedIds, setSelectedIds] = React.useState>(new Set()); // Sync selected IDs from entry terms React.useEffect(() => { setSelectedIds(new Set(entryTerms.map((term) => term.id))); }, [entryTerms]); const handleToggle = (termId: string) => { const newSelected = new Set(selectedIds); if (newSelected.has(termId)) { newSelected.delete(termId); } else { newSelected.add(termId); } setSelectedIds(newSelected); // Notify parent of change const termIdsArray = [...newSelected]; onChange?.(termIdsArray); // Auto-save if entry exists if (entryId) { saveMutation.mutate(termIdsArray); } }; const handleAdd = (termId: string) => { handleToggle(termId); }; const handleRemove = (termId: string) => { handleToggle(termId); }; const handleCreateCategory = () => { const label = newCategoryLabel.trim(); if (!label || createTermMutation.isPending) return; createTermMutation.mutate(label); }; return (
{taxonomy.hierarchical ? ( <> {terms.length === 0 ? (

{t`No ${taxonomy.label.toLowerCase()} available.`}

) : (
{terms.map((term) => ( ))}
)} {/* Add new category inline */} {showCategoryInput ? (
setNewCategoryLabel(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleCreateCategory(); } else if (e.key === "Escape") { setShowCategoryInput(false); setNewCategoryLabel(""); } }} placeholder={t`New ${(taxonomy.labelSingular || taxonomy.label).toLowerCase()}`} className="text-sm flex-1" autoFocus disabled={createTermMutation.isPending} />
) : ( )} {createTermMutation.error && (

{createTermMutation.error instanceof Error ? createTermMutation.error.message : t`Failed to create term`}

)} ) : ( createTermMutation.mutate(label)} isCreating={createTermMutation.isPending} label={taxonomy.label} /> )}
); } /** * Main TaxonomySidebar component */ export function TaxonomySidebar({ collection, entryId, entryLocale, onChange, }: TaxonomySidebarProps) { const { t } = useLingui(); const { data: taxonomies = [] } = useQuery({ queryKey: ["taxonomy-defs"], queryFn: fetchTaxonomyDefs, }); // Filter to taxonomies that apply to this collection const applicableTaxonomies = taxonomies.filter((tax) => tax.collections.includes(collection)); if (applicableTaxonomies.length === 0) { return null; } return (

{t`Taxonomies`}

{applicableTaxonomies.map((taxonomy) => ( onChange?.(taxonomy.name, termIds)} /> ))}
); }