diff --git a/.changeset/sortable-taxonomy-terms.md b/.changeset/sortable-taxonomy-terms.md new file mode 100644 index 0000000000..b02d67133b --- /dev/null +++ b/.changeset/sortable-taxonomy-terms.md @@ -0,0 +1,10 @@ +--- +"@emdash-cms/admin": minor +"@emdash-cms/cloudflare": minor +"@emdash-cms/sandbox-workerd": minor +"emdash": minor +--- + +Adds manual ordering for taxonomy terms. Move terms up and down from the Taxonomies screen, and templates render them in that order. + +Existing terms keep the order they display in today, now stored explicitly instead of derived from their labels. Terms added afterwards go to the end of their sibling group rather than slotting in alphabetically — if you want a taxonomy alphabetical, order it that way once and it stays. A term's position is shared by all of its translations, so ordering a taxonomy in one locale orders it everywhere. diff --git a/docs/src/content/docs/reference/rest-api.mdx b/docs/src/content/docs/reference/rest-api.mdx index 7cc818168e..93c4899e60 100644 --- a/docs/src/content/docs/reference/rest-api.mdx +++ b/docs/src/content/docs/reference/rest-api.mdx @@ -959,6 +959,9 @@ Content-Type: application/json GET /_emdash/api/taxonomies/:name/terms ``` +Terms come back in their manual order (see [Reorder Terms](#reorder-terms)). A +new term is added to the end of its sibling group. + ### Create Term ```http @@ -985,6 +988,34 @@ PUT /_emdash/api/taxonomies/:name/terms/:slug DELETE /_emdash/api/taxonomies/:name/terms/:slug ``` +### Reorder Terms + +```http +POST /_emdash/api/taxonomies/:name/reorder +Content-Type: application/json + +{ + "parentId": "term_abc", + "ids": ["term_news", "term_featured"] +} +``` + +Sets the order of one sibling group. `parentId` names the parent whose children +are being ordered; omit it (or send `null`) for the top level, which for a flat +taxonomy is every term. Reordering never changes a term's parent — use +[Update Term](#update-term) for that. + +`ids` may be a subset of the group: the terms you list are permuted within the +positions they already occupy, and every other member keeps its place. That +matters when a locale doesn't render the whole group, and it means a stale list +can't bury the terms it left out. An id outside the group is rejected with +`REORDER_MISMATCH`. + +There is no `locale` parameter. A term holds one position across every locale it +is translated into, so an id may be either a term id or a translation group, and +ordering a taxonomy in one locale orders it in all of them. Sites that need +different orders per locale should use separate taxonomies. + ### Set Entry Terms ```http diff --git a/packages/admin/src/components/TaxonomyManager.tsx b/packages/admin/src/components/TaxonomyManager.tsx index fb99a2b7d4..e3904ed88e 100644 --- a/packages/admin/src/components/TaxonomyManager.tsx +++ b/packages/admin/src/components/TaxonomyManager.tsx @@ -7,7 +7,7 @@ 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 { CaretDown, CaretUp, Plus, Pencil, Trash, X } from "@phosphor-icons/react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import * as React from "react"; @@ -20,6 +20,7 @@ import { createTaxonomy, createTerm, createTermTranslation, + reorderTerms, updateTerm, deleteTerm, } from "../lib/api/taxonomies.js"; @@ -65,25 +66,105 @@ export function getAvailableParentTerms( return flatTerms.filter((term) => !excludedIds.has(term.id)); } +/** The translation group a term belongs to — what `parentId` and reorder ids hold. */ +export function termGroup(term: TaxonomyTerm): string { + return term.translationGroup ?? term.id; +} + +/** + * Whether a term is rendered in a group it isn't a member of. + * + * A child whose parent has no row in this locale is listed at the top level so + * it isn't lost, but its position belongs to its parent's group. It can't be + * moved from here, and it can't be named in a reorder of the group it's drawn in. + */ +export function isStranded(term: TaxonomyTerm, parentId: string | null): boolean { + return parentId === null && !!term.parentId; +} + +/** + * Permute `movable` into the slots those same terms occupy in `rendered`, + * leaving everything else where it is. + * + * Mirrors what the reorder endpoint does with the ids it's given, so the + * optimistic list matches what comes back. + */ +export function reorderWithinSlots( + rendered: TaxonomyTerm[], + movable: TaxonomyTerm[], + permuted: TaxonomyTerm[], +): TaxonomyTerm[] { + const next = [...rendered]; + let slot = 0; + for (const [index, term] of rendered.entries()) { + if (!movable.includes(term)) continue; + const replacement = permuted[slot++]; + if (replacement) next[index] = replacement; + } + return next; +} + +/** + * Replace one sibling group in a term tree with a reordered copy of itself. + * + * `parentId` is a translation group (what `term.parentId` holds), so it matches + * the parent in whichever locale is on screen. `null` replaces the roots. + */ +export function replaceSiblingGroup( + terms: TaxonomyTerm[], + parentId: string | null, + ordered: TaxonomyTerm[], +): TaxonomyTerm[] { + if (parentId === null) return ordered; + return terms.map((term) => + termGroup(term) === parentId + ? { ...term, children: ordered } + : { ...term, children: replaceSiblingGroup(term.children, parentId, ordered) }, + ); +} + /** * Term row component (recursive for hierarchy) + * + * `siblings` is the group the term is rendered from, in display order, and + * `parentId` is that group's parent. Moving a term reorders that group and + * never changes its parent. + * + * A term whose parent has no row in this locale is rendered at the top level so + * it isn't lost, but it belongs to its parent's group — a group this locale + * can't show. Its move buttons are disabled rather than silently addressing the + * wrong group. */ function TermRow({ term, + siblings, + parentId, level = 0, onEdit, onDelete, + onMove, onTranslate, canTranslate, }: { term: TaxonomyTerm; + siblings: TaxonomyTerm[]; + parentId: string | null; level?: number; onEdit: (term: TaxonomyTerm) => void; onDelete: (term: TaxonomyTerm) => void; + onMove: ( + parentId: string | null, + siblings: TaxonomyTerm[], + term: TaxonomyTerm, + direction: -1 | 1, + ) => void; onTranslate?: (term: TaxonomyTerm) => void; canTranslate: boolean; }) { const { t } = useLingui(); + const stranded = isStranded(term, parentId); + const movable = siblings.filter((sibling) => !isStranded(sibling, parentId)); + const position = movable.indexOf(term); return ( <>
@@ -93,6 +174,24 @@ function TermRow({
{term.count || 0}
+ + {canTranslate && onTranslate ? (