/** * 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, fetchTerms, createTaxonomy, createTerm, updateTerm, deleteTerm, } from "../lib/api/taxonomies.js"; import { slugify } from "../lib/utils"; import { ConfirmDialog } from "./ConfirmDialog.js"; import { DialogError, getMutationError } from "./DialogError.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, }: { term: TaxonomyTerm; level?: number; onEdit: (term: TaxonomyTerm) => void; onDelete: (term: TaxonomyTerm) => void; }) { const { t } = useLingui(); return ( <>
{term.label} ({term.slug})
{term.count || 0}
{term.children.map((child) => ( ))} ); } /** * Term form dialog */ function TermFormDialog({ open, onClose, taxonomyName, taxonomyDef, term, allTerms, }: { open: boolean; onClose: () => void; taxonomyName: string; taxonomyDef: TaxonomyDef; term?: TaxonomyTerm; allTerms: TaxonomyTerm[]; }) { 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, }), 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, }); }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["taxonomy-terms", taxonomyName], }); onClose(); }, 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` : 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="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} />
); } /** * 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="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 }) => ( ))}
)}
); } /** * 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 { data: taxonomyDef, isLoading: defLoading } = useQuery({ queryKey: ["taxonomy-def", taxonomyName], queryFn: () => fetchTaxonomyDef(taxonomyName), }); const { data: terms = [], isLoading: termsLoading } = useQuery({ queryKey: ["taxonomy-terms", taxonomyName], queryFn: () => fetchTerms(taxonomyName), }); const deleteMutation = useMutation({ mutationFn: (term: TaxonomyTerm) => deleteTerm(taxonomyName, term.slug), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["taxonomy-terms", taxonomyName], }); setDeleteTarget(null); toastManager.add({ title: t`Term deleted` }); }, }); 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(", ")}`}

{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) => ( ))}
)}
{ 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` }); }} />
); }