feat(taxonomies): let terms carry a manual order - #2353
Open
MA2153 wants to merge 2 commits into
Open
Conversation
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 detectedLatest commit: 5436c84 The changes in this PR will be included in the next version bump. This PR includes changesets to release 17 packages
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 |
Contributor
Scope checkThis 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. |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-verification
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
Contributor
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
This was referenced Aug 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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: #756 — not 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_orderaddstaxonomies.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 inquery.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 renumbered0..n-1and new terms append to the end. A group still sitting at a uniform0keeps 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 existingtaxonomies:managepermission and the standard CSRF header.idsmust be the group's exact membership in the desired order — a partial or stale list is rejected with the existingREORDER_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 waybuildTreeresolves 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
scopeinstead of racing — each request is built from the previous one's result, and only the last one refetches.Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain.The two caret buttons carry Lingui
taria-labels (Move {label} up/down) and add no physical-direction Tailwind classes — they're KumoButtons in the row's existingflex gap-2action 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.jsonchanges only in SQL text (the addedsort_orderin the folded aggregate and theORDER BY); every route's query count is unchanged.sort_orderleads theORDER BYand 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 labelhad 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.tspins this: both reads must still seek throughidx_taxonomies_name_locale/idx_taxonomies_parentand 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
pnpm typecheckandpnpm 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 threeREORDER_MISMATCHrejections (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