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 ? (
@@ -751,8 +853,9 @@ export function TaxonomyManager({ taxonomyName }: TaxonomyManagerProps) {
// The count mode belongs in the key: the editor's taxonomy picker reads the
// same endpoint without counts, and this page renders them.
+ const termsQueryKey = ["taxonomy-terms", taxonomyName, activeLocale, { includeCounts: true }];
const { data: terms = [], isLoading: termsLoading } = useQuery({
- queryKey: ["taxonomy-terms", taxonomyName, activeLocale, { includeCounts: true }],
+ queryKey: termsQueryKey,
queryFn: () => fetchTerms(taxonomyName, { locale: activeLocale }),
});
@@ -766,6 +869,53 @@ export function TaxonomyManager({ taxonomyName }: TaxonomyManagerProps) {
},
});
+ const reorderMutationKey = ["taxonomy-terms-reorder", taxonomyName];
+ const reorderMutation = useMutation({
+ mutationKey: reorderMutationKey,
+ // Clicking a caret repeatedly queues moves instead of racing them. Each
+ // request body is an absolute order taken from the optimistic list at
+ // click time, so applying them in click order is what makes the last one
+ // win; `scope` is what serializes them.
+ scope: { id: `taxonomy-reorder:${taxonomyName}` },
+ mutationFn: ({ parentId, ids }: { parentId: string | null; ids: string[] }) =>
+ reorderTerms(taxonomyName, { parentId, ids }),
+ onError: (error: Error) => {
+ toastManager.add({ title: t`Error`, description: error.message, type: "error" });
+ },
+ // Refetch either way: on success to pick up the saved order, on failure to
+ // drop the optimistic one. Only the last queued move refetches — an
+ // earlier one would serve a stale order and snap the list back.
+ onSettled: () => {
+ if (queryClient.isMutating({ mutationKey: reorderMutationKey }) > 1) return;
+ void queryClient.invalidateQueries({ queryKey: ["taxonomy-terms", taxonomyName] });
+ },
+ });
+
+ // The cached list is updated before the request so the row moves on click
+ // rather than after the round trip.
+ const handleMove = (
+ parentId: string | null,
+ siblings: TaxonomyTerm[],
+ term: TaxonomyTerm,
+ direction: -1 | 1,
+ ) => {
+ const movable = siblings.filter((sibling) => !isStranded(sibling, parentId));
+ const from = movable.indexOf(term);
+ const to = from + direction;
+ const moving = movable[from];
+ const displaced = movable[to];
+ if (!moving || !displaced) return;
+
+ const permuted = movable.map((sibling, index) =>
+ index === from ? displaced : index === to ? moving : sibling,
+ );
+ const reordered = reorderWithinSlots(siblings, movable, permuted);
+ queryClient.setQueryData(termsQueryKey, (current: TaxonomyTerm[] | undefined) =>
+ current ? replaceSiblingGroup(current, parentId, reordered) : current,
+ );
+ reorderMutation.mutate({ parentId, ids: permuted.map(termGroup) });
+ };
+
const translateMutation = useMutation({
mutationFn: ({ term, locale }: { term: TaxonomyTerm; locale: string }) =>
createTermTranslation(
@@ -857,8 +1007,11 @@ export function TaxonomyManager({ taxonomyName }: TaxonomyManagerProps) {
1}
/>
diff --git a/packages/admin/src/lib/api/taxonomies.ts b/packages/admin/src/lib/api/taxonomies.ts
index 2c39a9bb2f..3a0941043d 100644
--- a/packages/admin/src/lib/api/taxonomies.ts
+++ b/packages/admin/src/lib/api/taxonomies.ts
@@ -186,6 +186,29 @@ export async function updateTerm(
return data.term;
}
+/**
+ * Set the manual order of one sibling group.
+ *
+ * `ids` and `parentId` are translation groups: a term holds one position across
+ * every locale, so there is no locale to pass. `ids` may name only the terms
+ * this locale renders — the server permutes them within the positions they
+ * already occupy and leaves untranslated members where they are.
+ *
+ * Returns the group's translation groups in their new order.
+ */
+export async function reorderTerms(
+ taxonomyName: string,
+ input: { parentId: string | null; ids: string[] },
+): Promise {
+ const response = await apiFetch(`${API_BASE}/taxonomies/${taxonomyName}/reorder`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(input),
+ });
+ const data = await parseApiResponse<{ order: string[] }>(response, "Failed to reorder terms");
+ return data.order;
+}
+
/**
* Delete a term
*/
diff --git a/packages/admin/tests/components/TaxonomyManager.test.tsx b/packages/admin/tests/components/TaxonomyManager.test.tsx
index d1a30406ca..c98a7066d7 100644
--- a/packages/admin/tests/components/TaxonomyManager.test.tsx
+++ b/packages/admin/tests/components/TaxonomyManager.test.tsx
@@ -3,7 +3,13 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
-import { getAvailableParentTerms, TaxonomyManager } from "../../src/components/TaxonomyManager";
+import {
+ getAvailableParentTerms,
+ replaceSiblingGroup,
+ reorderWithinSlots,
+ TaxonomyManager,
+} from "../../src/components/TaxonomyManager";
+import type { TaxonomyTerm } from "../../src/lib/api/taxonomies.js";
import { render } from "../utils/render.tsx";
const taxonomyResponse = JSON.stringify({
@@ -95,6 +101,97 @@ const hierarchicalTermsResponse = JSON.stringify({
},
});
+/** Two siblings under one parent, so a nested group can actually be reordered. */
+const nestedSiblingsTermsResponse = JSON.stringify({
+ data: {
+ terms: [
+ {
+ id: "design",
+ name: "design",
+ slug: "design",
+ label: "Design",
+ parentId: null,
+ translationGroup: "design-group",
+ children: [
+ {
+ id: "fonts",
+ name: "fonts",
+ slug: "fonts",
+ label: "Fonts",
+ parentId: "design-group",
+ translationGroup: "fonts-group",
+ children: [],
+ count: 0,
+ },
+ {
+ id: "colour",
+ name: "colour",
+ slug: "colour",
+ label: "Colour",
+ parentId: "design-group",
+ translationGroup: "colour-group",
+ children: [],
+ count: 0,
+ },
+ ],
+ count: 1,
+ },
+ {
+ id: "development",
+ name: "development",
+ slug: "development",
+ label: "Development",
+ parentId: null,
+ translationGroup: "development-group",
+ children: [],
+ count: 4,
+ },
+ ],
+ },
+});
+
+/**
+ * A term whose parent has no row in this locale, rendered between two real
+ * roots. The server lists it at the top level so it isn't lost, but it belongs
+ * to its parent's group and can't be moved or named from here.
+ */
+const untranslatedParentTermsResponse = JSON.stringify({
+ data: {
+ terms: [
+ {
+ id: "beta",
+ name: "beta",
+ slug: "beta",
+ label: "Beta",
+ parentId: null,
+ translationGroup: "beta-group",
+ children: [],
+ count: 0,
+ },
+ {
+ id: "nino",
+ name: "nino",
+ slug: "nino",
+ label: "Nino",
+ parentId: "alpha-group",
+ translationGroup: "nino-group",
+ children: [],
+ count: 0,
+ },
+ {
+ id: "gamma",
+ name: "gamma",
+ slug: "gamma",
+ label: "Gamma",
+ parentId: null,
+ translationGroup: "gamma-group",
+ children: [],
+ count: 0,
+ },
+ ],
+ },
+});
+
vi.mock("../../src/lib/api/client.js", async () => {
const actual = await vi.importActual("../../src/lib/api/client.js");
return {
@@ -288,6 +385,134 @@ describe("TaxonomyManager", () => {
await expect.element(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
});
+ /** Parsed body of the reorder request, or undefined if none was sent. */
+ function reorderRequestBody(): unknown {
+ const call = vi
+ .mocked(apiFetch)
+ .mock.calls.find(([url]) => typeof url === "string" && url.includes("/reorder"));
+ const body = call?.[1]?.body;
+ return typeof body === "string" ? JSON.parse(body) : undefined;
+ }
+
+ it("moving a term sends the whole sibling group in its new order", async () => {
+ const screen = await render(, {
+ wrapper: Wrapper,
+ });
+
+ await expect.element(screen.getByText("Technology", { exact: true })).toBeInTheDocument();
+
+ await screen.getByRole("button", { name: "Move Technology down" }).click();
+
+ expect(reorderRequestBody()).toEqual({ parentId: null, ids: ["2", "1"] });
+ });
+
+ it("cannot move the first term up or the last term down", async () => {
+ const screen = await render(, {
+ wrapper: Wrapper,
+ });
+
+ await expect.element(screen.getByText("Technology", { exact: true })).toBeInTheDocument();
+
+ await expect.element(screen.getByRole("button", { name: "Move Technology up" })).toBeDisabled();
+ await expect.element(screen.getByRole("button", { name: "Move Science down" })).toBeDisabled();
+ await expect
+ .element(screen.getByRole("button", { name: "Move Technology down" }))
+ .toBeEnabled();
+ });
+
+ it("reorders the top level while nested rows are on screen", async () => {
+ mockApiFetch(hierarchicalTermsResponse);
+ const screen = await render(, {
+ wrapper: Wrapper,
+ });
+
+ await expect.element(screen.getByText("Test", { exact: true })).toBeInTheDocument();
+
+ await screen.getByRole("button", { name: "Move Design down" }).click();
+
+ expect(reorderRequestBody()).toEqual({
+ parentId: null,
+ ids: ["development-group", "design-group"],
+ });
+ });
+
+ it("reorders a nested group under its own parent, leaving the roots alone", async () => {
+ mockApiFetch(nestedSiblingsTermsResponse);
+ const screen = await render(, {
+ wrapper: Wrapper,
+ });
+
+ await expect.element(screen.getByText("Fonts", { exact: true })).toBeInTheDocument();
+
+ await screen.getByRole("button", { name: "Move Fonts down" }).click();
+
+ expect(reorderRequestBody()).toEqual({
+ parentId: "design-group",
+ ids: ["colour-group", "fonts-group"],
+ });
+ });
+
+ it("cannot move a term whose parent is untranslated", async () => {
+ mockApiFetch(untranslatedParentTermsResponse);
+ const screen = await render(, {
+ wrapper: Wrapper,
+ });
+
+ await expect.element(screen.getByText("Nino", { exact: true })).toBeInTheDocument();
+
+ await expect.element(screen.getByRole("button", { name: "Move Nino up" })).toBeDisabled();
+ await expect.element(screen.getByRole("button", { name: "Move Nino down" })).toBeDisabled();
+ });
+
+ it("leaves an untranslated-parent term out of the group it is drawn in", async () => {
+ mockApiFetch(untranslatedParentTermsResponse);
+ const screen = await render(, {
+ wrapper: Wrapper,
+ });
+
+ await expect.element(screen.getByText("Gamma", { exact: true })).toBeInTheDocument();
+
+ // Beta and Gamma are the only real roots, so Beta's "down" is enabled even
+ // though Nino sits between them, and Nino is not named in the request.
+ await screen.getByRole("button", { name: "Move Beta down" }).click();
+
+ expect(reorderRequestBody()).toEqual({
+ parentId: null,
+ ids: ["gamma-group", "beta-group"],
+ });
+ });
+
+ it("splices a reordered child group into its parent, leaving the roots alone", () => {
+ const terms: TaxonomyTerm[] = JSON.parse(nestedSiblingsTermsResponse).data.terms;
+ const [fonts, colour] = terms[0]!.children;
+
+ const next = replaceSiblingGroup(terms, "design-group", [colour!, fonts!]);
+
+ expect(next.map((term) => term.label)).toEqual(["Design", "Development"]);
+ expect(next[0]!.children.map((term) => term.label)).toEqual(["Colour", "Fonts"]);
+ });
+
+ it("permutes movable terms within their slots, leaving the rest in place", () => {
+ const terms: TaxonomyTerm[] = JSON.parse(untranslatedParentTermsResponse).data.terms;
+ const [beta, nino, gamma] = terms;
+ const movable = [beta!, gamma!];
+
+ const next = reorderWithinSlots(terms, movable, [gamma!, beta!]);
+
+ // Nino keeps the slot it was rendered in; Beta and Gamma swap the two
+ // slots they held around it.
+ expect(next.map((term) => term.label)).toEqual(["Gamma", "Nino", "Beta"]);
+ expect(next[1]).toBe(nino);
+ });
+
+ it("replaces the root list when ordering the top level", () => {
+ const terms: TaxonomyTerm[] = JSON.parse(nestedSiblingsTermsResponse).data.terms;
+
+ const next = replaceSiblingGroup(terms, null, [terms[1]!, terms[0]!]);
+
+ expect(next.map((term) => term.label)).toEqual(["Development", "Design"]);
+ });
+
it("shows empty state when no terms", async () => {
mockApiFetch(JSON.stringify({ data: { terms: [] } }));
diff --git a/packages/cloudflare/src/sandbox/bridge.ts b/packages/cloudflare/src/sandbox/bridge.ts
index b169356e34..3e7ae6a5a5 100644
--- a/packages/cloudflare/src/sandbox/bridge.ts
+++ b/packages/cloudflare/src/sandbox/bridge.ts
@@ -753,9 +753,9 @@ export class PluginBridge extends WorkerEntrypoint,
+ taxonomyName: string,
+ input: { parentId?: string | null; ids: string[] },
+): Promise> {
+ try {
+ const lookup = await requireTaxonomyDef(db, taxonomyName);
+ if (!lookup.success) return lookup;
+
+ const repo = new TaxonomyRepository(db);
+
+ let parentId = input.parentId === "" || input.parentId === undefined ? null : input.parentId;
+ if (parentId !== null) {
+ // Children store the parent's translation_group, so a caller passing a
+ // row id still addresses the right group. A parent whose anchor row is
+ // gone keeps the value as given (see TaxonomyRepository.resolveParentRef).
+ const parent = await repo.findById(parentId);
+ if (parent) parentId = parent.translationGroup ?? parent.id;
+ }
+
+ // Every locale's rows: membership is locale-agnostic, and a term the
+ // caller can't see still holds its slot.
+ const members = await repo.findByName(taxonomyName, { parentId });
+ const positions = new Map();
+ const resolve = new Map();
+ const distinct: string[] = [];
+ for (const term of members) {
+ const group = term.translationGroup ?? term.id;
+ if (!positions.has(group)) distinct.push(group);
+ positions.set(group, term.sortOrder);
+ resolve.set(group, group);
+ resolve.set(term.id, group);
+ }
+
+ const groups: string[] = [];
+ for (const id of input.ids) {
+ const group = resolve.get(id);
+ if (!group) {
+ return {
+ success: false,
+ error: {
+ code: "REORDER_MISMATCH",
+ message: `Term "${id}" is not in the sibling group being reordered`,
+ },
+ };
+ }
+ if (groups.includes(group)) {
+ return {
+ success: false,
+ error: {
+ code: "REORDER_MISMATCH",
+ message: `Term "${id}" is listed more than once`,
+ },
+ };
+ }
+ groups.push(group);
+ }
+
+ const applied = await repo.reorder(groups, positions);
+ invalidateTermCache();
+
+ for (const [group, position] of applied) positions.set(group, position);
+ const order = distinct.toSorted(
+ (a, b) => (positions.get(a) ?? 0) - (positions.get(b) ?? 0) || a.localeCompare(b),
+ );
+ return { success: true, data: { order } };
+ } catch (error) {
+ console.error("[taxonomies] term reorder failed:", error);
+ return {
+ success: false,
+ error: { code: "TERM_REORDER_ERROR", message: "Failed to reorder terms" },
+ };
+ }
+}
+
/**
* Validate a parent term reference for create/update.
*
diff --git a/packages/core/src/api/openapi/document.ts b/packages/core/src/api/openapi/document.ts
index ddf7e90696..b9bdeb9838 100644
--- a/packages/core/src/api/openapi/document.ts
+++ b/packages/core/src/api/openapi/document.ts
@@ -112,10 +112,12 @@ import {
import { settingsUpdateBody, siteSettingsSchema } from "../schemas/settings.js";
import {
createTermBody,
+ reorderTermsBody,
taxonomyListResponseSchema,
termGetResponseSchema,
termListQuery,
termListResponseSchema,
+ termReorderResponseSchema,
termResponseSchema,
updateTermBody,
} from "../schemas/taxonomies.js";
@@ -1320,6 +1322,29 @@ const taxonomyPaths = {
},
},
},
+ "/_emdash/api/taxonomies/{name}/reorder": {
+ post: {
+ operationId: "reorderTerms",
+ summary: "Set the manual order of one sibling group of terms",
+ description:
+ "`ids` names the terms to move, in the desired order, and may be a subset of the group — the listed terms are permuted within the positions they already occupy. A position belongs to a term across every locale, so there is no `locale` parameter. Ordering never reparents — use the term update endpoint to change a parent.",
+ tags: ["Taxonomies"],
+ requestParams: {
+ path: z.object({ name: z.string().meta({ description: "Taxonomy name" }) }),
+ },
+ requestBody: { content: { [JSON_CONTENT]: { schema: reorderTermsBody } } },
+ responses: {
+ "200": {
+ description: "The group in its new order",
+ content: {
+ [JSON_CONTENT]: { schema: successEnvelope(termReorderResponseSchema) },
+ },
+ },
+ ...authErrors,
+ ...standardErrors(400, 404, 500),
+ },
+ },
+ },
"/_emdash/api/taxonomies/{name}/terms": {
get: {
operationId: "listTerms",
diff --git a/packages/core/src/api/schemas/taxonomies.ts b/packages/core/src/api/schemas/taxonomies.ts
index 83cf3244da..1578fb21ab 100644
--- a/packages/core/src/api/schemas/taxonomies.ts
+++ b/packages/core/src/api/schemas/taxonomies.ts
@@ -55,6 +55,19 @@ export const updateTermBody = z
})
.meta({ id: "UpdateTermBody" });
+export const reorderTermsBody = z
+ .object({
+ parentId: z.string().min(1).nullish().meta({
+ description:
+ "Parent term whose children are being ordered (translation_group or row id). Omit or null for the top level, which for a flat taxonomy is every term.",
+ }),
+ ids: z.array(z.string().min(1)).meta({
+ description:
+ "Terms to move, in the desired order — each a row id or translation_group. May be a subset of the group: the listed terms are permuted within the positions they already occupy and every other member keeps its place. An id outside the group is rejected with REORDER_MISMATCH.",
+ }),
+ })
+ .meta({ id: "ReorderTermsBody" });
+
export const termListQuery = z
.object({
locale: localeCode.optional(),
@@ -153,6 +166,14 @@ export const termListResponseSchema = z
export const termResponseSchema = z.object({ term: termSchema }).meta({ id: "TermResponse" });
+export const termReorderResponseSchema = z
+ .object({
+ order: z.array(z.string()).meta({
+ description: "The sibling group's translation_groups, in their new order.",
+ }),
+ })
+ .meta({ id: "TermReorderResponse" });
+
export const termGetResponseSchema = z
.object({
term: termSchema.extend({
diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts
index 93f0efdfa3..84854ff029 100644
--- a/packages/core/src/astro/integration/routes.ts
+++ b/packages/core/src/astro/integration/routes.ts
@@ -389,6 +389,11 @@ export function injectCoreRoutes(
entrypoint: resolveRoute("api/taxonomies/index.ts"),
});
+ injectRoute({
+ pattern: "/_emdash/api/taxonomies/[name]/reorder",
+ entrypoint: resolveRoute("api/taxonomies/[name]/reorder.ts"),
+ });
+
injectRoute({
pattern: "/_emdash/api/taxonomies/[name]/terms",
entrypoint: resolveRoute("api/taxonomies/[name]/terms/index.ts"),
diff --git a/packages/core/src/astro/routes/api/taxonomies/[name]/reorder.ts b/packages/core/src/astro/routes/api/taxonomies/[name]/reorder.ts
new file mode 100644
index 0000000000..d48cb2fa9b
--- /dev/null
+++ b/packages/core/src/astro/routes/api/taxonomies/[name]/reorder.ts
@@ -0,0 +1,41 @@
+/**
+ * Taxonomy term reorder endpoint
+ *
+ * POST /_emdash/api/taxonomies/:name/reorder
+ * body: { parentId?, ids }
+ *
+ * Sets the manual order of one sibling group. There is no `locale`: a term
+ * holds one position across every locale it is translated into.
+ */
+
+import type { APIRoute } from "astro";
+
+import { requirePerm } from "#api/authorize.js";
+import { apiError, handleError, requireDb, unwrapResult } from "#api/error.js";
+import { handleTermReorder } from "#api/handlers/taxonomies.js";
+import { isParseError, parseBody } from "#api/parse.js";
+import { reorderTermsBody } from "#api/schemas.js";
+
+export const prerender = false;
+
+export const POST: APIRoute = async ({ params, request, locals }) => {
+ const { emdash, user } = locals;
+ const { name } = params;
+ if (!name) return apiError("VALIDATION_ERROR", "Taxonomy name required", 400);
+
+ const dbErr = requireDb(emdash?.db);
+ if (dbErr) return dbErr;
+
+ const denied = requirePerm(user, "taxonomies:manage");
+ if (denied) return denied;
+
+ try {
+ const body = await parseBody(request, reorderTermsBody);
+ if (isParseError(body)) return body;
+
+ const result = await handleTermReorder(emdash.db, name, body);
+ return unwrapResult(result);
+ } catch (error) {
+ return handleError(error, "Failed to reorder terms", "TERM_REORDER_ERROR");
+ }
+};
diff --git a/packages/core/src/database/migrations/056_taxonomy_term_sort_order.ts b/packages/core/src/database/migrations/056_taxonomy_term_sort_order.ts
new file mode 100644
index 0000000000..4b4363ec43
--- /dev/null
+++ b/packages/core/src/database/migrations/056_taxonomy_term_sort_order.ts
@@ -0,0 +1,79 @@
+import { sql, type Kysely } from "kysely";
+
+import { columnExists } from "../dialect-helpers.js";
+
+/**
+ * Add `taxonomies.sort_order` and mint a position for every existing term.
+ *
+ * Terms carry an explicit order rather than falling back to alphabetical, so
+ * existing rows need real positions rather than a uniform default. Groups are
+ * numbered by `(label, id)` — the order term listings used before this
+ * migration — so the rendered order is unchanged the moment it runs. What
+ * changes is what happens next: a term created afterwards appends to the end of
+ * its group instead of slotting in alphabetically.
+ *
+ * A position belongs to a `translation_group`, not a row, so every row of a
+ * group gets the same value and sibling groups are keyed on the raw `parent_id`
+ * column (itself a translation_group). Numbering reads the table once and
+ * computes in JS: SQLite and Postgres would each need their own window-function
+ * spelling, and `taxonomies` is small enough that it isn't worth two dialects
+ * of SQL.
+ */
+export async function up(db: Kysely): Promise {
+ if (await columnExists(db, "taxonomies", "sort_order")) return;
+
+ await db.schema
+ .alterTable("taxonomies")
+ .addColumn("sort_order", "integer", (col) => col.notNull().defaultTo(0))
+ .execute();
+
+ const { rows } = await sql<{
+ id: string;
+ name: string;
+ label: string | null;
+ parent_id: string | null;
+ translation_group: string | null;
+ }>`SELECT id, name, label, parent_id, translation_group FROM taxonomies`.execute(db);
+
+ // One entry per translation_group: a group holds a single position, and its
+ // rows can disagree on label and parent across locales, so the lowest row id
+ // decides for the whole group and the mint stays deterministic however the
+ // rows come back. Taxonomy names are `[a-z][a-z0-9_]*` and parents are
+ // ULIDs, so "/" can't appear in either half of the sibling key.
+ const groups = new Map();
+ for (const row of rows) {
+ const group = row.translation_group ?? row.id;
+ const chosen = groups.get(group);
+ if (chosen && chosen.id <= row.id) continue;
+ groups.set(group, {
+ sibling: row.name + "/" + (row.parent_id ?? ""),
+ label: row.label ?? "",
+ id: row.id,
+ });
+ }
+
+ const siblings = new Map();
+ for (const [group, { sibling, label, id }] of groups) {
+ let members = siblings.get(sibling);
+ if (!members) siblings.set(sibling, (members = []));
+ members.push({ group, label, id });
+ }
+
+ for (const members of siblings.values()) {
+ members.sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
+ for (const [position, member] of members.entries()) {
+ if (position === 0) continue; // The column default already covers it.
+ await sql`
+ UPDATE taxonomies SET sort_order = ${position}
+ WHERE translation_group = ${member.group}
+ OR (translation_group IS NULL AND id = ${member.group})
+ `.execute(db);
+ }
+ }
+}
+
+export async function down(db: Kysely): Promise {
+ if (await columnExists(db, "taxonomies", "sort_order")) {
+ await db.schema.alterTable("taxonomies").dropColumn("sort_order").execute();
+ }
+}
diff --git a/packages/core/src/database/migrations/runner.ts b/packages/core/src/database/migrations/runner.ts
index 33b8eecfde..a32299b608 100644
--- a/packages/core/src/database/migrations/runner.ts
+++ b/packages/core/src/database/migrations/runner.ts
@@ -58,6 +58,7 @@ import * as m052 from "./052_media_usage_read_index.js";
import * as m053 from "./053_plugin_mcp_tools.js";
import * as m054 from "./054_media_upload_attempts.js";
import * as m055 from "./055_content_translation_group_locale_index.js";
+import * as m056 from "./056_taxonomy_term_sort_order.js";
const MIGRATIONS: Readonly> = Object.freeze({
"001_initial": m001,
@@ -114,6 +115,7 @@ const MIGRATIONS: Readonly> = Object.freeze({
"053_plugin_mcp_tools": m053,
"054_media_upload_attempts": m054,
"055_content_translation_group_locale_index": m055,
+ "056_taxonomy_term_sort_order": m056,
});
/** Total number of registered migrations. Exported for use in tests. */
diff --git a/packages/core/src/database/repositories/taxonomy.ts b/packages/core/src/database/repositories/taxonomy.ts
index 2e22eff4b8..2cc8285ddb 100644
--- a/packages/core/src/database/repositories/taxonomy.ts
+++ b/packages/core/src/database/repositories/taxonomy.ts
@@ -3,6 +3,7 @@ import { ulid } from "ulidx";
import { invalidateTaxonomyObjectCache } from "../../object-cache/index.js";
import { isMissingTableError } from "../../utils/db-errors.js";
+import { withTransaction } from "../transaction.js";
import type { Database, TaxonomyTable } from "../types.js";
import { validateIdentifier } from "../validate.js";
@@ -38,6 +39,11 @@ export interface Taxonomy {
data: Record | null;
locale: string;
translationGroup: string | null;
+ /**
+ * Position among siblings. Shared by every row of a `translation_group` —
+ * a term sits in the same place in every locale it is translated into.
+ */
+ sortOrder: number;
}
export interface CreateTaxonomyInput {
@@ -75,6 +81,12 @@ export interface FindOptions {
* The repository does not resolve locale fallbacks on its own — callers supply
* the locale they want. Runtime helpers and handlers use `getFallbackChain()`
* from `i18n/config` when they need fallback behaviour.
+ *
+ * `sort_order` is per translation_group, not per row: every row sharing a
+ * translation_group carries the same value, so a term holds one position across
+ * all its locales. Sibling groups are keyed on the raw `parent_id` column, which
+ * is locale-agnostic for the same reason (it stores the parent's
+ * translation_group). Writes must preserve both invariants.
*/
export class TaxonomyRepository {
constructor(private db: Kysely) {}
@@ -98,10 +110,15 @@ export class TaxonomyRepository {
const parentId = parentInput ? await this.resolveParentRef(parentInput) : null;
let translationGroup = id;
+ // A translation is the same term in another locale, so it takes the
+ // group's position rather than being placed again.
+ let sortOrder: number | null = null;
if (input.translationOf) {
const source = await this.findById(input.translationOf);
if (source?.translationGroup) translationGroup = source.translationGroup;
+ if (source) sortOrder = source.sortOrder;
}
+ sortOrder ??= await this.nextSortOrder(input.name, parentId);
await this.db
.insertInto("taxonomies")
@@ -112,6 +129,7 @@ export class TaxonomyRepository {
label: input.label,
parent_id: parentId,
data: input.data ? JSON.stringify(input.data) : null,
+ sort_order: sortOrder,
// When omitted, the DB DEFAULT 'en' is used — keeps behaviour
// consistent with ContentRepository and lets higher layers
// supply an explicit locale from request context.
@@ -155,15 +173,18 @@ export class TaxonomyRepository {
/**
* Get all terms for a taxonomy (e.g., all categories).
*
- * `id asc` is a stable tiebreaker for terms that share a label. Without it
- * the SQL ordering is implementation-defined when labels match, which
- * breaks keyset pagination over `(label, id)`.
+ * `sort_order` carries the manual order set from the admin; it is 0 for
+ * terms nobody has reordered, so an untouched taxonomy still comes back
+ * alphabetically. `id asc` is a stable tiebreaker for terms that share both
+ * — without it the SQL ordering is implementation-defined when they match,
+ * which breaks keyset pagination over `(label, id)`.
*/
async findByName(name: string, options: FindOptions = {}): Promise {
let query = this.db
.selectFrom("taxonomies")
.selectAll()
.where("name", "=", name)
+ .orderBy("sort_order", "asc")
.orderBy("label", "asc")
.orderBy("id", "asc");
@@ -196,6 +217,7 @@ export class TaxonomyRepository {
.selectFrom("taxonomies")
.selectAll()
.where("parent_id", "=", group)
+ .orderBy("sort_order", "asc")
.orderBy("label", "asc")
.orderBy("id", "asc");
if (locale !== undefined) query = query.where("locale", "=", locale);
@@ -223,27 +245,119 @@ export class TaxonomyRepository {
if (!existing) return null;
const updates: Record = {};
+ let reparentedTo: number | null = null;
if (input.slug !== undefined) updates.slug = input.slug;
if (input.label !== undefined) updates.label = input.label;
if (input.parentId !== undefined) {
// Defense in depth: empty-string parentId means null (no parent).
// Otherwise persist the parent's translation_group (locale-agnostic),
// matching create() — see the note there.
- updates.parent_id =
+ const parentId =
input.parentId === "" || input.parentId === null
? null
: await this.resolveParentRef(input.parentId);
+ updates.parent_id = parentId;
+
+ // A position only means anything within one sibling group, so a term
+ // that changes parent is appended to the group it lands in. The whole
+ // translation_group moves with it — the position is the term's, not the
+ // row's.
+ if (parentId !== existing.parentId) {
+ reparentedTo = await this.nextSortOrder(existing.name, parentId);
+ }
}
if (input.data !== undefined) updates.data = JSON.stringify(input.data);
- if (Object.keys(updates).length > 0) {
- await this.db.updateTable("taxonomies").set(updates).where("id", "=", id).execute();
+ if (Object.keys(updates).length > 0 || reparentedTo !== null) {
+ await withTransaction(this.db, async (trx) => {
+ if (Object.keys(updates).length > 0) {
+ await trx.updateTable("taxonomies").set(updates).where("id", "=", id).execute();
+ }
+ if (reparentedTo !== null) {
+ await trx
+ .updateTable("taxonomies")
+ .set({ sort_order: reparentedTo })
+ .where("translation_group", "=", existing.translationGroup ?? existing.id)
+ .execute();
+ }
+ });
invalidateTaxonomyObjectCache();
}
return this.findById(id);
}
+ /**
+ * Move `groups` (translation_groups, in the desired order) into the positions
+ * those same groups already occupy, leaving every other member of the sibling
+ * group where it is.
+ *
+ * Permuting within occupied slots rather than renumbering 0..n-1 is what lets
+ * a caller reorder a group it can only partly see: a locale renders only the
+ * terms translated into it, so an admin in `fr` may never be able to name
+ * every member. A group the caller didn't list keeps its position, so a stale
+ * or partial list can't bury it.
+ *
+ * `current` is each group's stored position; groups already at their target
+ * are skipped, so one swap costs two writes rather than one per group.
+ */
+ async reorder(
+ groups: string[],
+ current: ReadonlyMap,
+ ): Promise