Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/sortable-taxonomy-terms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@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. Taxonomies you never reorder stay alphabetical, and new terms join the end of a group you have ordered.
102 changes: 98 additions & 4 deletions packages/admin/src/components/TaxonomyManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -20,6 +20,7 @@ import {
createTaxonomy,
createTerm,
createTermTranslation,
reorderTerms,
updateTerm,
deleteTerm,
} from "../lib/api/taxonomies.js";
Expand Down Expand Up @@ -65,21 +66,49 @@ export function getAvailableParentTerms(
return flatTerms.filter((term) => !excludedIds.has(term.id));
}

/**
* 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) =>
(term.translationGroup ?? term.id) === 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 — moving
* a term reorders that group and never changes its parent.
*/
function TermRow({
term,
siblings,
position,
level = 0,
onEdit,
onDelete,
onMove,
onTranslate,
canTranslate,
}: {
term: TaxonomyTerm;
siblings: TaxonomyTerm[];
position: number;
level?: number;
onEdit: (term: TaxonomyTerm) => void;
onDelete: (term: TaxonomyTerm) => void;
onMove: (siblings: TaxonomyTerm[], from: number, direction: -1 | 1) => void;
onTranslate?: (term: TaxonomyTerm) => void;
canTranslate: boolean;
}) {
Expand All @@ -93,6 +122,24 @@ function TermRow({
</div>
<div className="text-sm text-kumo-subtle">{term.count || 0}</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
aria-label={t`Move ${term.label} up`}
disabled={position === 0}
onClick={() => onMove(siblings, position, -1)}
>
<CaretUp className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
aria-label={t`Move ${term.label} down`}
disabled={position >= siblings.length - 1}
onClick={() => onMove(siblings, position, 1)}
>
<CaretDown className="w-4 h-4" />
</Button>
{canTranslate && onTranslate ? (
<Button
variant="ghost"
Expand Down Expand Up @@ -121,13 +168,16 @@ function TermRow({
</Button>
</div>
</div>
{term.children.map((child) => (
{term.children.map((child, childPosition) => (
<TermRow
key={child.id}
term={child}
siblings={term.children}
position={childPosition}
level={level + 1}
onEdit={onEdit}
onDelete={onDelete}
onMove={onMove}
onTranslate={onTranslate}
canTranslate={canTranslate}
/>
Expand Down Expand Up @@ -751,8 +801,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 }),
});

Expand All @@ -766,6 +817,46 @@ 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 is built from the previous one's result, so the server has to
// apply them in that order too.
scope: { id: `taxonomy-reorder:${taxonomyName}` },
mutationFn: ({ parentId, ids }: { parentId: string | null; ids: string[] }) =>
reorderTerms(taxonomyName, { parentId, ids }, { locale: activeLocale }),
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] });
},
});

// Swaps a term with its neighbour and sends the whole group's new order. The
// list is updated in place first so the row moves on click rather than after
// the round trip.
const handleMove = (siblings: TaxonomyTerm[], from: number, direction: -1 | 1) => {
const to = from + direction;
const moving = siblings[from];
const displaced = siblings[to];
if (!moving || !displaced) return;

const reordered = siblings.map((sibling, index) =>
index === from ? displaced : index === to ? moving : sibling,
);
const parentId = moving.parentId ?? null;
queryClient.setQueryData(termsQueryKey, (current: TaxonomyTerm[] | undefined) =>
current ? replaceSiblingGroup(current, parentId, reordered) : current,
);
reorderMutation.mutate({ parentId, ids: reordered.map((sibling) => sibling.id) });
};

const translateMutation = useMutation({
mutationFn: ({ term, locale }: { term: TaxonomyTerm; locale: string }) =>
createTermTranslation(
Expand Down Expand Up @@ -853,12 +944,15 @@ export function TaxonomyManager({ taxonomyName }: TaxonomyManagerProps) {
</div>
) : (
<div className="divide-y divide-kumo-line">
{terms.map((term) => (
{terms.map((term, position) => (
<TermRow
key={term.id}
term={term}
siblings={terms}
position={position}
onEdit={handleEdit}
onDelete={handleDelete}
onMove={handleMove}
onTranslate={setTranslateTarget}
canTranslate={!!i18n && !!activeLocale && i18n.locales.length > 1}
/>
Expand Down
31 changes: 31 additions & 0 deletions packages/admin/src/lib/api/taxonomies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,37 @@ export async function updateTerm(
return data.term;
}

/**
* Set the manual order of one sibling group.
*
* `ids` must be the group's exact membership in the desired order — the server
* rejects a partial or stale list rather than applying it. `parentId` is the
* parent's translation group; null orders the top level, which for a flat
* taxonomy is every term.
*
* The group comes back in its new order, flat — the endpoint reorders one group
* and returns that group, so its members carry no `children`.
*/
export async function reorderTerms(
taxonomyName: string,
input: { parentId: string | null; ids: string[] },
options: LocaleOptions = {},
): Promise<Omit<TaxonomyTerm, "children">[]> {
const response = await apiFetch(
withLocale(`${API_BASE}/taxonomies/${taxonomyName}/reorder`, options.locale),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
},
);
const data = await parseApiResponse<{ terms: Omit<TaxonomyTerm, "children">[] }>(
response,
"Failed to reorder terms",
);
return data.terms;
}

/**
* Delete a term
*/
Expand Down
138 changes: 137 additions & 1 deletion packages/admin/tests/components/TaxonomyManager.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ 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,
TaxonomyManager,
} from "../../src/components/TaxonomyManager";
import type { TaxonomyTerm } from "../../src/lib/api/taxonomies.js";
import { render } from "../utils/render.tsx";

const taxonomyResponse = JSON.stringify({
Expand Down Expand Up @@ -95,6 +100,55 @@ 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,
},
],
},
});

vi.mock("../../src/lib/api/client.js", async () => {
const actual = await vi.importActual("../../src/lib/api/client.js");
return {
Expand Down Expand Up @@ -288,6 +342,88 @@ 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(<TaxonomyManager taxonomyName="categories" />, {
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(<TaxonomyManager taxonomyName="categories" />, {
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(<TaxonomyManager taxonomyName="categories" />, {
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", "design"] });
});

it("reorders a nested group under its own parent, leaving the roots alone", async () => {
mockApiFetch(nestedSiblingsTermsResponse);
const screen = await render(<TaxonomyManager taxonomyName="categories" />, {
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", "fonts"],
});
});

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("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: [] } }));

Expand Down
Loading
Loading