/**
* 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();
}}
>
);
}
/**
* 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();
}
}}
>
);
}
/**
* 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();
}
}}
>
);
}
/**
* 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}
} onClick={() => setCreateTaxonomyOpen(true)}>
{t`New Taxonomy`}
} onClick={() => setFormOpen(true)}>
{t`Add ${taxonomyDef.labelSingular || t`Term`}`}
{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` });
}}
/>
);
}