Skip to content

feat(admin): explicit collection order in the sidebar - #2267

Merged
ascorbic merged 2 commits into
emdash-cms:mainfrom
DavidPivert:feat/collection-sort-order
Aug 9, 2026
Merged

feat(admin): explicit collection order in the sidebar#2267
ascorbic merged 2 commits into
emdash-cms:mainfrom
DavidPivert:feat/collection-sort-order

Conversation

@DavidPivert

Copy link
Copy Markdown
Contributor

What does this PR do?

Lets a site choose the order its collections appear in the admin sidebar, instead of always sorting alphabetically by slug.

Today listCollections sorts by slug, and that order flows through the manifest into the sidebar. The issue's example is a portfolio site that always renders "Certifications → Education → Endorsements → Pages → Positions → Posts → Projects", with no way out short of renaming slugs (breaking URLs and queries) or forking the admin.

Two ways to set it:

  • Drag the rows on the Content Types screen.
  • sortOrder in a seed file, for sites that provision their schema from code:
{ "slug": "projects", "label": "Projects", "sortOrder": 1, "fields": [] }

Three decisions worth flagging for review:

  • The column is nullable, not DEFAULT 0. NULL means "no explicit position": those collections keep the alphabetical order and are listed after the ordered ones. With a 0 default, pinning two collections would have left every other one sorting ahead of them, which is the opposite of what pinning means. It also makes the migration a no-op visually — a site that never reorders renders exactly as before.
  • Reads materialise the fallback with COALESCE rather than relying on NULL ordering. SQLite sorts NULL first on ASC, Postgres sorts it last, so a bare ORDER BY sort_order would have given D1 and Postgres sites different sidebars. packages/core/tests/integration/database/dialect-compat.test.ts covers the dialect pair generally; the ordering itself is asserted in the registry tests.
  • reorder is now a reserved collection slug. The static POST /schema/collections/reorder route is injected before the dynamic [slug] route, so a collection called reorder could never be addressed at its own URL. Reserving it at the data layer mirrors what byline-fields/reorder already does. Sites with an existing collection by that name are unaffected — the reservation only rejects new creates.

reorderCollections takes the full desired order and clears the position of anything left out, so the stored state stays a faithful picture of what the admin renders rather than a sparse set the UI has to reconcile. Unknown and duplicate slugs are rejected rather than silently applied.

On accessibility: the drag handles are real buttons with an accessible name per row (Reorder Posts, not a bare icon), and the KeyboardSensor is wired with sortableKeyboardCoordinates, so the whole reorder is doable from the keyboard — same pattern as the widgets and repeater lists.

Closes #474

Type of change

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

Discussion: #1751 — the Roadmap discussion mirroring #474. The issue is on the 1.0 milestone, labelled roadmap/1.0 / roadmap/editor-experience. #795 was closed as a duplicate of it.

Scope note: this is flat ordering only. #1023 (sidebar menu tree with collection grouping) is a larger reshaping of the same nav — sort_order is orthogonal to it and would carry over as the order within a group, but say the word if you'd rather this wait for that design to land.

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: Feature: Collection sort order in admin sidebar #1751

AI-generated code disclosure

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

Screenshots / test output

15 new tests:

  • packages/core/tests/unit/schema/registry.test.ts — default is unordered; ordered collections come first and the rest stay alphabetical; the same order applies on the listCollectionsWithFields (manifest) path; reorderCollections assigns positions, clears omitted ones, and rejects unknown/duplicate slugs without touching the order; update preserves the position when sortOrder is omitted and clears it on null; reorder is rejected as a slug.
  • packages/core/tests/unit/seed/apply.test.tssortOrder is applied from a seed and drives the resulting order.
  • packages/admin/tests/components/ContentTypeList.test.tsx — a labelled drag handle per row when reordering is enabled, none without it or for a single collection, rows render in the given order, plus the pure moveCollection reducer (down, up, and the no-op cases that must skip the network call).
packages/core   vitest run tests/unit                    3432 passed, 1 failed
packages/core   vitest run tests/integration/openapi       14 passed
packages/admin  vitest run                               1249 passed
pnpm --filter @emdash-cms/core typecheck                 clean
pnpm --filter @emdash-cms/admin typecheck                clean
oxlint --type-aware --deny-warnings                      clean
oxfmt --check && prettier --check .                      clean

Two notes on that output, both checked against a clean main by stashing this branch:

  • The core failure (createVirtualModulesPlugin scheduler wiring watches resolved sandbox plugin entries) reproduces on unmodified main on this machine — pre-existing, unrelated to this change.
  • An earlier full admin run showed one failure in SeoPanel.test.tsx ("expected 1 times, got 2"). It passes in isolation and passed on a repeat full run (1249/1249); SeoPanel is untouched here. Flagging it as a flake I saw rather than leaving it out.

@changeset-bot

changeset-bot Bot commented Jul 28, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ae7c280

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

This PR includes changesets to release 17 packages
Name Type
emdash Minor
@emdash-cms/admin Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/plugin-mcp-smoke Major
@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/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

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 692 lines across 22 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.

@github-actions

Copy link
Copy Markdown
Contributor

@pkg-pr-new

pkg-pr-new Bot commented Aug 8, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/registry-verification

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

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: ae7c280

Comment thread .changeset/collection-sort-order.md Outdated
@@ -0,0 +1,6 @@
---
"@emdash-cms/core": minor

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"@emdash-cms/core": minor
"emdash": minor

@ascorbic

ascorbic commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Thanks! Can you fix the changeset, rename the migraiton and resolve the conflict. Then we can merge.

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels Aug 8, 2026
DavidPivert and others added 2 commits August 9, 2026 11:34
The sidebar order came straight from `listCollections`, which sorted by
slug. A site's collections then appear in an order nobody chose —
"Certifications, Education, Endorsements, Pages, Positions, Posts,
Projects" — with no way out short of renaming slugs (breaking URLs and
queries) or forking the admin.

Adds `sort_order` on `_emdash_collections`, settable by dragging rows on
the Content Types screen or via `sortOrder` in a seed file. The column is
nullable rather than defaulting to 0: NULL means "no explicit position",
and those collections keep the alphabetical order behind the ordered
ones, so a site that never reorders renders exactly as before. Reads
materialise the fallback with COALESCE instead of relying on NULL
ordering, which SQLite puts first and Postgres last on ASC.

`reorderCollections` takes the full desired order and clears the position
of anything left out, so the stored state stays a faithful picture of
what the admin renders instead of a sparse set the UI has to reconcile.

`reorder` becomes a reserved collection slug: the static
POST /schema/collections/reorder route would otherwise shadow a
collection by that name. Same defence in depth already applied to
byline-fields/reorder.

Closes emdash-cms#474

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pre-empts the review notes already raised on the sibling `hidden` PR.

The full schema export the CLI reads for `emdash types` builds its
collection shape by hand and omitted the new field. The comment changes
apply AGENTS.md: drop justification and narrative, keep the non-obvious
constraints (route ordering, NULL sorting across dialects), and remove
issue references from test titles. The migration docstring also pointed
at a symbol name that never existed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ascorbic
ascorbic force-pushed the feat/collection-sort-order branch from 616ef4e to ae7c280 Compare August 9, 2026 10:36
@ascorbic

ascorbic commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Rebased this branch over main now that #2264 has landed (maintainer edit), resolving the overlap between hidden and sortOrder across the shared schema/seed files — both features coexist everywhere they meet. Also from Matt's review: the changeset package name is fixed (emdash, not @emdash-cms/core) and the migration is renumbered to 058_collection_sort_order (main's head is 057). Two small additions along the way: 058_collection_sort_order is appended to the trailing re-run list in migrations.test.ts, which any new migration needs, and the seed test's issue-number prefix is dropped from its name per repo convention. Verified locally: migration/registry/seed tests pass (114/114), lint and typecheck clean after a full build. Thanks @DavidPivert!

~ 🤖 Fable 5

@ascorbic ascorbic added bot:review Trigger an emdashbot code review on this PR and removed needs-rebase labels Aug 9, 2026
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Aug 9, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right change for the problem: it adds a nullable sort_order column, materialises the fallback with COALESCE so SQLite and Postgres agree, exposes drag-and-drop reordering in the Content Types UI, and reserves reorder as a slug to avoid the new static route shadowing collection CRUD. The implementation follows EmDash's conventions — handler returns ApiResult, route uses requirePerm("schema:manage") + unwrapResult, the admin uses Lingui and invalidates the manifest query, and tests cover ordering, seed round-trips, unknown/duplicate rejection, and the reserved slug.

I read the full diff, the changed files, the manifest/sidebar consumers, the route injection order, and the registry tests. I found no logic bugs, security issues, AGENTS.md convention violations in SQL/auth/API shape, or missing locale filters. The only issues are two comment-discipline notes: both comments restate what the adjacent code already says and should be removed.

Headline conclusion: clean, well-tested feature. Minor comment cleanup only.

Comment on lines 76 to +79

// Optimistic order: the drop lands immediately, the server order takes
// over once the mutation invalidates the query.
const [order, setOrder] = React.useState<string[] | null>(null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This block restates the optimistic-update behavior that is already expressed by setOrder(next) and onReorder?.(next) on lines 81–82. Per AGENTS.md comment discipline, comments should not narrate what the code plainly does.

Suggested change
// Optimistic order: the drop lands immediately, the server order takes
// over once the mutation invalidates the query.
const [order, setOrder] = React.useState<string[] | null>(null);
const [order, setOrder] = React.useState<string[] | null>(null);
const serverOrder = React.useMemo(() => collections.map((c) => c.slug), [collections]);
const orderedSlugs = order ?? serverOrder;
React.useEffect(() => {
setOrder(null);
}, [serverOrder]);

Comment on lines +83 to +87
/**
* Collection ordering shared by every list read: explicit `sort_order`
* first (ascending), then alphabetically by slug.
*/
const collectionOrder = sql<number>`coalesce(sort_order, ${sql.lit(UNORDERED_COLLECTION_RANK)})`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The constant name collectionOrder and its definition already make it clear that this expression orders collections by sort_order with a fallback. The JSDoc repeats the same information and can be deleted.

Suggested change
/**
* Collection ordering shared by every list read: explicit `sort_order`
* first (ascending), then alphabetically by slug.
*/
const collectionOrder = sql<number>`coalesce(sort_order, ${sql.lit(UNORDERED_COLLECTION_RANK)})`;
const collectionOrder = sql<number>`coalesce(sort_order, ${sql.lit(UNORDERED_COLLECTION_RANK)})`;

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Aug 9, 2026
@ascorbic
ascorbic enabled auto-merge (squash) August 9, 2026 10:54
@ascorbic
ascorbic merged commit e8048e4 into emdash-cms:main Aug 9, 2026
51 checks passed
@emdashbot emdashbot Bot mentioned this pull request Aug 9, 2026
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.

Feature: Collection sort order in admin sidebar

2 participants