Skip to content

feat(taxonomies): let terms carry a manual order - #2353

Open
MA2153 wants to merge 2 commits into
emdash-cms:mainfrom
MA2153:feat/sortable-taxonomy-terms
Open

feat(taxonomies): let terms carry a manual order#2353
MA2153 wants to merge 2 commits into
emdash-cms:mainfrom
MA2153:feat/sortable-taxonomy-terms

Conversation

@MA2153

@MA2153 MA2153 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds manual ordering for taxonomy terms, so the order a taxonomy renders in on a site is no longer tied to term labels.

Discussion: #756not yet approved. Opening this so the proposal has something concrete attached; happy to close it or leave it parked if the direction isn't wanted.

Schema. Migration 055_taxonomy_term_sort_order adds taxonomies.sort_order (integer NOT NULL DEFAULT 0). Every term read is ordered by (sort_order, label, id) — the repository, the runtime taxonomy helpers, the folded-hydration path in query.ts, and both plugin-sandbox bridges (cloudflare, workerd).

Backwards compatibility. Existing rows are all 0, so a taxonomy nobody has reordered stays alphabetical — the behaviour before this change. A sibling group only becomes "manually ordered" once someone reorders it, at which point it's renumbered 0..n-1 and new terms append to the end. A group still sitting at a uniform 0 keeps inserting new terms alphabetically, so nothing changes for anyone who never touches the new controls.

API. POST /_emdash/api/taxonomies/{name}/reorder[?locale=xx] with { parentId?, ids }, gated on the existing taxonomies:manage permission and the standard CSRF header. ids must be the group's exact membership in the desired order — a partial or stale list is rejected with the existing REORDER_MISMATCH (400) rather than applied, so a client working from a stale list can't silently bury the terms it didn't know about. Reordering never reparents; changing a parent is still a term update. The group is (taxonomy, locale, parentId), so an order set in one locale can't decide placement in another. Membership is resolved exactly the way buildTree resolves it for the term list, so the group the server validates is the group the client saw.

Admin UI. Up/down carets per term row on the Taxonomies screen, disabled at the ends of a group. The list updates optimistically so a row moves on click rather than after the round trip, and repeated clicks queue via a mutation scope instead of racing — each request is built from the previous one's result, and only the last one refetches.

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: Make taxonomies sortable #756linked, but not yet approved.

The two caret buttons carry Lingui t aria-labels (Move {label} up / down) and add no physical-direction Tailwind classes — they're Kumo Buttons in the row's existing flex gap-2 action group, with vertically-symmetric caret icons that need no RTL flip. I have not run the admin in Arabic in a browser to confirm this, so please treat the RTL claim as reviewed-by-reading rather than verified. The Taxonomies screen isn't in the visual-regression suite, so CI won't cover it either.

Performance

No new queries on the logged-out hot path — scripts/query-counts.queries.sqlite.json changes only in SQL text (the added sort_order in the folded aggregate and the ORDER BY); every route's query count is unchanged.

sort_order leads the ORDER BY and no index satisfies it, so the seeked sibling group is sorted in a temp b-tree. That's deliberate: the seek is the part that matters on stats-blind SQLite/D1, the sort is over one taxonomy's terms in one locale, and it costs no extra rows read — which is what D1 bills. ORDER BY label had the same temp b-tree before terms were sortable, so this isn't a regression the manual order introduced. Eliminating it would take a five-column (name, locale, sort_order, label, id) index paid on every term write, on a table already carrying four indexes; that trade didn't seem worth making unilaterally.

tests/integration/taxonomy-term-order-plan.test.ts pins this: both reads must still seek through idx_taxonomies_name_locale / idx_taxonomies_parent and must not fall back to scanning the whole locale (the #1723 regression). The temp-b-tree assertions are called out in the file header as the ones to delete if someone decides the index is worth it.

Screenshots / test output

packages/core       Test Files  403 passed | 1 skipped (404)
                         Tests  5187 passed | 3 skipped (5190)
packages/cloudflare Test Files   20 passed (20)
                         Tests  293 passed (293)
packages/workerd    Test Files   11 passed (11)
                         Tests   76 passed (76)
packages/admin      tests/components/TaxonomyManager.test.tsx  18 passed

pnpm typecheck and pnpm lint:json (0 diagnostics) both clean.

New tests:

  • tests/unit/taxonomies/term-reorder.test.ts — dialect-agnostic (SQLite + Postgres): alphabetical until ordered, order survives creation and translation, per-locale isolation, children ordered independently of roots, and the three REORDER_MISMATCH rejections (missing term, duplicate id, term from another taxonomy) each asserted to leave the stored order untouched.
  • tests/integration/taxonomy-term-order-plan.test.ts — query-plan assertions above, run against the repository's real emitted SQL rather than a hand-copied literal.
  • TaxonomyManager.test.tsx — the request body sent for a root and a nested group, end-of-group buttons disabled, and direct unit tests for the tree-splice helper.

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 5 (Claude Code)

Term listings were ordered by label alone, so the only way to control
how a taxonomy renders on a site was to rename its terms. Add a
`sort_order` column and order every term read by (sort_order, label, id).

Existing rows default to 0, which keeps a taxonomy nobody has reordered
alphabetical -- the behaviour before this change. A group only becomes
manually ordered once it is reordered, at which point it is renumbered
0..n-1 and new terms append to it; groups still at a uniform 0 keep
inserting new terms alphabetically.

Ordering is scoped to one sibling group in one locale so an order set in
one language cannot decide placement in another, and reordering never
reparents -- the endpoint rejects any list that is not the group's exact
membership rather than applying a stale one.

sort_order leads the ORDER BY and no index satisfies it, so the seeked
group is sorted in a temp b-tree. That costs no extra rows read (what D1
bills) and ORDER BY label had the same sort before; the alternative is a
five-column index paid on every term write. The query-plan test pins the
index seek so the emdash-cms#1723 full-locale scan cannot come back unnoticed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5436c84

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 17 packages
Name Type
@emdash-cms/admin Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Minor
emdash Minor
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/plugin-mcp-smoke Major
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 1,161 lines across 24 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@pkg-pr-new

pkg-pr-new Bot commented Aug 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@2353

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@2353

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@2353

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@2353

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@2353

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@2353

emdash

npm i https://pkg.pr.new/emdash@2353

create-emdash

npm i https://pkg.pr.new/create-emdash@2353

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@2353

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@2353

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@2353

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@2353

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@2353

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@2353

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@2353

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@2353

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@2353

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@2353

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@2353

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@2353

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@2353

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@2353

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@2353

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@2353

commit: 5436c84

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant