/** * Taxonomy Terms Manager * * Provides UI for managing taxonomy terms (categories, tags, custom taxonomies). * Shows hierarchical structure for categories, flat list for tags. */ import { Button, Checkbox, Dialog, Input, InputArea, Select, Toast } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; import { Plus, Pencil, Trash, X } from "@phosphor-icons/react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import * as React from "react"; import { fetchManifest } from "../lib/api/client.js"; import type { TaxonomyTerm, TaxonomyDef, CreateTaxonomyInput } from "../lib/api/taxonomies.js"; import { fetchTaxonomyDef, fetchTermTranslations, fetchTerms, createTaxonomy, createTerm, createTermTranslation, updateTerm, deleteTerm, } from "../lib/api/taxonomies.js"; import { slugify } from "../lib/utils"; import { ConfirmDialog } from "./ConfirmDialog.js"; import { DialogError, getMutationError } from "./DialogError.js"; import { LocaleSwitcher, useI18nConfig } from "./LocaleSwitcher.js"; import { TranslationsPanel } from "./TranslationsPanel.js"; interface TaxonomyManagerProps { taxonomyName: string; } // Regex patterns for taxonomy name generation and validation (module-scoped per lint rules) const NON_ALPHANUMERIC_PATTERN = /[^a-z0-9]+/g; const LEADING_TRAILING_UNDERSCORE_PATTERN = /^_|_$/g; const TAXONOMY_NAME_PATTERN = /^[a-z][a-z0-9_]*$/; /** * Flatten tree to get all terms */ function flattenTerms(terms: TaxonomyTerm[]): TaxonomyTerm[] { return terms.flatMap((t) => [t, ...flattenTerms(t.children)]); } /** * Term row component (recursive for hierarchy) */ function TermRow({ term, level = 0, onEdit, onDelete, onTranslate, canTranslate, }: { term: TaxonomyTerm; level?: number; onEdit: (term: TaxonomyTerm) => void; onDelete: (term: TaxonomyTerm) => void; onTranslate?: (term: TaxonomyTerm) => void; canTranslate: boolean; }) { const { t } = useLingui(); return ( <>
{term.label} ({term.slug})
{term.count || 0}
{canTranslate && onTranslate ? ( ) : null}
{term.children.map((child) => ( ))} ); } /** * Dialog to pick a target locale for creating a term translation. */ function TranslateTermDialog({ term, taxonomyName, locales, activeLocale, isPending, error, onClose, onSubmit, }: { term: TaxonomyTerm; taxonomyName: string; locales: string[]; activeLocale: string | undefined; isPending: boolean; error: Error | null; onClose: () => void; onSubmit: (locale: string) => void; }) { const { t } = useLingui(); const otherLocales = locales.filter((l) => l !== activeLocale); const [selected, setSelected] = React.useState(otherLocales[0] ?? ""); return ( { if (!isOpen) onClose(); }} >
{t`Translate "${term.label}"`} {t`Taxonomy: ${taxonomyName}`}
( )} />
); } /** * Term form dialog */ function TermFormDialog({ open, onClose, taxonomyName, taxonomyDef, term, allTerms, locale, i18n, onOpenTranslation, }: { open: boolean; onClose: () => void; taxonomyName: string; taxonomyDef: TaxonomyDef; term?: TaxonomyTerm; allTerms: TaxonomyTerm[]; locale?: string; i18n: { defaultLocale: string; locales: string[] } | null; onOpenTranslation?: (translatedTerm: { slug: string; locale: string }) => void; }) { const { t } = useLingui(); const queryClient = useQueryClient(); const [label, setLabel] = React.useState(term?.label || ""); const [slug, setSlug] = React.useState(term?.slug || ""); const [parentId, setParentId] = React.useState(term?.parentId || ""); const [description, setDescription] = React.useState(term?.description || ""); const [autoSlug, setAutoSlug] = React.useState(!term); const [error, setError] = React.useState(null); // Sync form state when term prop changes (for edit mode) React.useEffect(() => { setLabel(term?.label || ""); setSlug(term?.slug || ""); setParentId(term?.parentId || ""); setDescription(term?.description || ""); setAutoSlug(!term); setError(null); }, [term]); // Auto-generate slug from label React.useEffect(() => { if (autoSlug && label) { setSlug(slugify(label)); } }, [label, autoSlug]); const createMutation = useMutation({ mutationFn: () => createTerm(taxonomyName, { slug, label, parentId: parentId || undefined, description: description || undefined, locale, }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["taxonomy-terms", taxonomyName], }); onClose(); }, onError: (err: Error) => { setError(err.message); }, }); const updateMutation = useMutation({ mutationFn: () => { if (!term) throw new Error("No term to update"); return updateTerm( taxonomyName, term.slug, { slug, label, parentId: parentId || undefined, description: description || undefined, }, { locale: term.locale ?? locale }, ); }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["taxonomy-terms", taxonomyName], }); onClose(); }, onError: (err: Error) => { setError(err.message); }, }); // Translations list (only when editing an existing term and i18n is on). const { data: translationsData } = useQuery({ queryKey: ["term-translations", taxonomyName, term?.id ?? null], queryFn: () => { if (!term) throw new Error("No term"); return fetchTermTranslations(taxonomyName, term.slug, { locale: term.locale ?? locale }); }, enabled: !!term && !!i18n && i18n.locales.length > 1, }); const translateMutation = useMutation({ mutationFn: (targetLocale: string) => { if (!term) throw new Error("No term"); return createTermTranslation( taxonomyName, term.slug, { locale: targetLocale, label: term.label, slug: term.slug }, { locale: term.locale ?? locale }, ); }, onSuccess: (translated) => { void queryClient.invalidateQueries({ queryKey: ["taxonomy-terms", taxonomyName] }); void queryClient.invalidateQueries({ queryKey: ["term-translations", taxonomyName] }); onClose(); onOpenTranslation?.({ slug: translated.slug, locale: translated.locale }); }, onError: (err: Error) => { setError(err.message); }, }); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); setError(null); if (term) { updateMutation.mutate(); } else { createMutation.mutate(); } }; // Flatten terms for parent selector (exclude current term and its children) const flatTerms = flattenTerms(allTerms); const availableParents = term ? flatTerms.filter((item) => item.id !== term.id && item.parentId !== term.id) : flatTerms; return ( { if (!isOpen) { setError(null); onClose(); } }} >
{term ? t`Edit ${taxonomyDef.labelSingular || t`Term`}` : t`Add ${taxonomyDef.labelSingular || t`Term`}`} {term ? t`Update the ${taxonomyDef.labelSingular?.toLowerCase() || "term"} details` : t`Create a new ${taxonomyDef.labelSingular?.toLowerCase() || "term"}`}
( )} />
setLabel(e.target.value)} placeholder={t`News`} required />
{ setSlug(e.target.value); setAutoSlug(false); }} placeholder="news" required />

{t`Auto-generated from name (you can edit)`}

{taxonomyDef.hierarchical && ( )} setDescription(e.target.value)} placeholder={t`Optional description`} rows={3} /> {term && i18n && i18n.locales.length > 1 ? (
{ onClose(); onOpenTranslation?.({ slug: term.slug, locale: tr.locale }); }} onCreate={(target) => translateMutation.mutate(target)} pendingLocale={ translateMutation.isPending ? (translateMutation.variables ?? null) : null } />
) : null}
); } /** * Create Taxonomy dialog */ function CreateTaxonomyDialog({ open, onClose, onCreated, }: { open: boolean; onClose: () => void; onCreated: () => void; }) { const { t } = useLingui(); const queryClient = useQueryClient(); const [name, setName] = React.useState(""); const [label, setLabel] = React.useState(""); const [hierarchical, setHierarchical] = React.useState(false); const [selectedCollections, setSelectedCollections] = React.useState([]); const [autoName, setAutoName] = React.useState(true); const [error, setError] = React.useState(null); const { data: manifest } = useQuery({ queryKey: ["manifest"], queryFn: fetchManifest, }); const collectionEntries = manifest ? Object.entries(manifest.collections).map(([slug, config]) => ({ slug, label: config.label, })) : []; // Auto-generate name from label React.useEffect(() => { if (autoName && label) { setName( label .toLowerCase() .replace(NON_ALPHANUMERIC_PATTERN, "_") .replace(LEADING_TRAILING_UNDERSCORE_PATTERN, ""), ); } }, [label, autoName]); const createMutation = useMutation({ mutationFn: (input: CreateTaxonomyInput) => createTaxonomy(input), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["taxonomy-defs"] }); void queryClient.invalidateQueries({ queryKey: ["taxonomy-def"] }); onCreated(); resetForm(); }, }); const resetForm = () => { setName(""); setLabel(""); setHierarchical(false); setSelectedCollections([]); setAutoName(true); setError(null); createMutation.reset(); }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); setError(null); if (!name || !label) { setError(t`Name and label are required`); return; } if (!TAXONOMY_NAME_PATTERN.test(name)) { setError( t`Name must start with a letter and contain only lowercase letters, numbers, and underscores`, ); return; } createMutation.mutate({ name, label, hierarchical, collections: selectedCollections, }); }; const toggleCollection = (slug: string) => { setSelectedCollections((prev) => prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug], ); }; return ( { if (!isOpen) { resetForm(); onClose(); } }} >
{t`Create Taxonomy`} {t`Define a new taxonomy for classifying content`}
( )} />
setLabel(e.target.value)} placeholder={t`Genres`} required />
{ setName(e.target.value); setAutoName(false); }} placeholder="genre" required pattern="[a-z][a-z0-9_]*" title={t`Lowercase letters, numbers, and underscores only, starting with a letter`} />

{t`Used as the identifier. Lowercase letters, numbers, and underscores only.`}

setHierarchical(checked)} /> {collectionEntries.length > 0 && (

{t`Which content types can use this taxonomy`}

{collectionEntries.map(({ slug, label: collLabel }) => (
toggleCollection(slug)} label={{collLabel}} />
))}
)}
); } /** * Main TaxonomyManager component */ export function TaxonomyManager({ taxonomyName }: TaxonomyManagerProps) { const { t } = useLingui(); const queryClient = useQueryClient(); const toastManager = Toast.useToastManager(); const [formOpen, setFormOpen] = React.useState(false); const [editingTerm, setEditingTerm] = React.useState(); const [deleteTarget, setDeleteTarget] = React.useState(null); const [createTaxonomyOpen, setCreateTaxonomyOpen] = React.useState(false); const [translateTarget, setTranslateTarget] = React.useState(null); const { data: manifest } = useQuery({ queryKey: ["manifest"], queryFn: fetchManifest, }); const i18n = useI18nConfig(manifest); const [activeLocale, setActiveLocale] = React.useState(undefined); React.useEffect(() => { if (i18n && !activeLocale) setActiveLocale(i18n.defaultLocale); }, [i18n, activeLocale]); // The taxonomy definition is looked up without filtering by locale — the // def is primarily structural ("does this taxonomy exist, is it // hierarchical, which collections use it"). Label translations exist per // locale but are not required for the page to render. const { data: taxonomyDef, isLoading: defLoading } = useQuery({ queryKey: ["taxonomy-def", taxonomyName], queryFn: () => fetchTaxonomyDef(taxonomyName), }); const { data: terms = [], isLoading: termsLoading } = useQuery({ queryKey: ["taxonomy-terms", taxonomyName, activeLocale], queryFn: () => fetchTerms(taxonomyName, { locale: activeLocale }), }); const deleteMutation = useMutation({ mutationFn: (term: TaxonomyTerm) => deleteTerm(taxonomyName, term.slug, { locale: activeLocale }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["taxonomy-terms", taxonomyName] }); setDeleteTarget(null); toastManager.add({ title: t`Term deleted` }); }, }); const translateMutation = useMutation({ mutationFn: ({ term, locale }: { term: TaxonomyTerm; locale: string }) => createTermTranslation( taxonomyName, term.slug, { locale, label: term.label, slug: term.slug }, { locale: activeLocale }, ), onSuccess: (term) => { void queryClient.invalidateQueries({ queryKey: ["taxonomy-terms", taxonomyName] }); setTranslateTarget(null); setActiveLocale(term.locale); toastManager.add({ title: t`Translation created`, description: t`Term "${term.label}" created in ${term.locale.toUpperCase()}.`, }); }, }); const handleEdit = (term: TaxonomyTerm) => { setEditingTerm(term); setFormOpen(true); }; const handleDelete = (term: TaxonomyTerm) => { setDeleteTarget(term); }; const handleCloseForm = () => { setFormOpen(false); setEditingTerm(undefined); }; if (defLoading) { return
{t`Loading...`}
; } if (!taxonomyDef) { return (
{t`Taxonomy not found:`} {taxonomyName}
); } const flatTerms = flattenTerms(terms); return (

{taxonomyDef.label}

{t`Manage ${taxonomyDef.label.toLowerCase()} for ${taxonomyDef.collections.join(", ")}`}

{i18n && activeLocale ? ( ) : null}
{t`Name`}
{t`Count`}
{t`Actions`}
{termsLoading ? (
{t`Loading terms...`}
) : terms.length === 0 ? (
{t`No ${taxonomyDef.label.toLowerCase()} yet. Create one to get started.`}
) : (
{terms.map((term) => ( 1} /> ))}
)}
setActiveLocale(tr.locale)} /> {i18n && translateTarget ? ( { setTranslateTarget(null); translateMutation.reset(); }} onSubmit={(locale) => translateMutation.mutate({ term: translateTarget, locale })} /> ) : null} { setDeleteTarget(null); deleteMutation.reset(); }} title={t`Delete ${taxonomyDef.labelSingular || "Term"}?`} description={ <>{t`This will permanently delete "${deleteTarget?.label}" and remove it from all content.`} } confirmLabel={t`Delete`} pendingLabel={t`Deleting...`} isPending={deleteMutation.isPending} error={deleteMutation.error} onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget)} /> setCreateTaxonomyOpen(false)} onCreated={() => { setCreateTaxonomyOpen(false); toastManager.add({ title: t`Taxonomy created` }); }} />
); }