diff --git a/.changeset/collection-sort-order.md b/.changeset/collection-sort-order.md new file mode 100644 index 0000000000..d017960ad6 --- /dev/null +++ b/.changeset/collection-sort-order.md @@ -0,0 +1,6 @@ +--- +"@emdash-cms/core": minor +"@emdash-cms/admin": minor +--- + +Adds an explicit sidebar order for collections. Drag the rows on the Content Types screen, or set `sortOrder` in a seed file, and the admin sidebar follows that order instead of sorting alphabetically by slug. Collections without a `sortOrder` keep the alphabetical order and are listed after the ordered ones, so existing sites look the same until someone reorders. `reorder` is now a reserved collection slug. diff --git a/docs/src/content/docs/themes/seed-files.mdx b/docs/src/content/docs/themes/seed-files.mdx index 9ce91bf6b5..faf98e7096 100644 --- a/docs/src/content/docs/themes/seed-files.mdx +++ b/docs/src/content/docs/themes/seed-files.mdx @@ -121,8 +121,16 @@ Each collection definition creates a content type in the database: | `description` | `string` | No | Admin UI description | | `icon` | `string` | No | Lucide icon name | | `supports` | `array` | No | Features: `"drafts"`, `"revisions"` | +| `sortOrder` | `number` | No | Position in the admin sidebar (ascending) | | `fields` | `array` | Yes | Field definitions | + + ### Field Properties | Property | Type | Required | Description | diff --git a/packages/admin/src/components/ContentTypeList.tsx b/packages/admin/src/components/ContentTypeList.tsx index 787a063d36..d35f51e9ad 100644 --- a/packages/admin/src/components/ContentTypeList.tsx +++ b/packages/admin/src/components/ContentTypeList.tsx @@ -1,7 +1,32 @@ import { Badge, Button } from "@cloudflare/kumo"; +import { + DndContext, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import type { DragEndEvent } from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; -import { Plus, Pencil, Trash, Database, FileText, Warning, Check } from "@phosphor-icons/react"; +import { + Plus, + Pencil, + Trash, + Database, + FileText, + Warning, + Check, + DotsSixVertical, +} from "@phosphor-icons/react"; import { Link } from "@tanstack/react-router"; import * as React from "react"; @@ -10,12 +35,28 @@ import { cn } from "../lib/utils"; import { ConfirmDialog } from "./ConfirmDialog"; import { RouterLinkButton } from "./RouterLinkButton.js"; +/** + * Apply a drag-and-drop move to the collection order. Returns the input array + * unchanged when the move is a no-op. + */ +export function moveCollection(slugs: string[], activeSlug: string, overSlug: string): string[] { + const from = slugs.indexOf(activeSlug); + const to = slugs.indexOf(overSlug); + if (from === -1 || to === -1 || from === to) return slugs; + + const next = [...slugs]; + next.splice(to, 0, next.splice(from, 1)[0]!); + return next; +} + export interface ContentTypeListProps { collections: SchemaCollection[]; orphanedTables?: OrphanedTable[]; isLoading?: boolean; onDelete?: (slug: string) => void; onRegisterOrphan?: (slug: string) => void; + /** Persist a new sidebar order. Omit to render the list without reordering. */ + onReorder?: (slugs: string[]) => void; } /** @@ -27,11 +68,46 @@ export function ContentTypeList({ isLoading, onDelete, onRegisterOrphan, + onReorder, }: ContentTypeListProps) { const { t } = useLingui(); const [deleteTarget, setDeleteTarget] = React.useState(null); const hasOrphans = orphanedTables && orphanedTables.length > 0; + // Optimistic order: the drop lands immediately, the server order takes + // over once the mutation invalidates the query. + const [order, setOrder] = React.useState(null); + const serverOrder = React.useMemo(() => collections.map((c) => c.slug), [collections]); + const orderedSlugs = order ?? serverOrder; + React.useEffect(() => { + setOrder(null); + }, [serverOrder]); + + const orderedCollections = React.useMemo(() => { + const bySlug = new Map(collections.map((c) => [c.slug, c])); + return orderedSlugs.map((slug) => bySlug.get(slug)).filter((c) => c !== undefined); + }, [collections, orderedSlugs]); + + const canReorder = !!onReorder && collections.length > 1; + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + const columnCount = canReorder ? 6 : 5; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + + const next = moveCollection(orderedSlugs, String(active.id), String(over.id)); + if (next === orderedSlugs) return; + + setOrder(next); + onReorder?.(next); + }; + return (
{/* Header */} @@ -88,55 +164,65 @@ export function ContentTypeList({ )} {/* Table */} -
- - - - - - - - - - - - {isLoading ? ( - - + +
+
- {t`Name`} - - {t`Slug`} - - {t`Source`} - - {t`Features`} - - {t`Actions`} -
- {t`Loading collections...`} -
+ + + {canReorder && ( + + )} + + + + + - ) : collections.length === 0 && !hasOrphans ? ( - - - - ) : ( - collections.map((collection) => ( - - )) - )} - -
+ {t`Reorder`} + + {t`Name`} + + {t`Slug`} + + {t`Source`} + + {t`Features`} + + {t`Actions`} +
- {t`No content types yet.`}{" "} - - {t`Create your first one`} - -
-
+ + + {isLoading ? ( + + + {t`Loading collections...`} + + + ) : collections.length === 0 && !hasOrphans ? ( + + + {t`No content types yet.`}{" "} + + {t`Create your first one`} + + + + ) : ( + + {orderedCollections.map((collection) => ( + + ))} + + )} + + +
+ void; } -function ContentTypeRow({ collection, onRequestDelete }: ContentTypeRowProps) { +function ContentTypeRow({ collection, canReorder, onRequestDelete }: ContentTypeRowProps) { const { t } = useLingui(); const isFromCode = collection.source === "code"; + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: collection.slug, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; return ( - + + {canReorder && ( + + + + )}
{ + const response = await apiFetch(`${API_BASE}/schema/collections/reorder`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ slugs }), + }); + if (!response.ok) + await throwResponseError(response, i18n._(msg`Failed to reorder content types`)); +} + // ============================================ // Orphaned Tables // ============================================ diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index c39a322770..11c866362e 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -88,6 +88,7 @@ import { updateField, deleteField, reorderFields, + reorderCollections, fetchOrphanedTables, registerOrphanedTable, fetchUsers, @@ -1854,6 +1855,16 @@ function ContentTypesListPage() { }, }); + const reorderMutation = useMutation({ + mutationFn: (slugs: string[]) => reorderCollections(slugs), + // The manifest drives the sidebar order, so it has to be refetched + // alongside the collection list for the move to show up in the nav. + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: ["schema", "collections"] }); + void queryClient.invalidateQueries({ queryKey: ["manifest"] }); + }, + }); + const error = collectionsError || orphansError; if (error) { return ; @@ -1866,6 +1877,7 @@ function ContentTypesListPage() { isLoading={collectionsLoading || orphansLoading} onDelete={(slug) => deleteMutation.mutate(slug)} onRegisterOrphan={(slug) => registerOrphanMutation.mutate(slug)} + onReorder={(slugs) => reorderMutation.mutate(slugs)} /> ); } diff --git a/packages/admin/tests/components/ContentTypeList.test.tsx b/packages/admin/tests/components/ContentTypeList.test.tsx index c7b407756b..71b6a95092 100644 --- a/packages/admin/tests/components/ContentTypeList.test.tsx +++ b/packages/admin/tests/components/ContentTypeList.test.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { ContentTypeList } from "../../src/components/ContentTypeList"; +import { ContentTypeList, moveCollection } from "../../src/components/ContentTypeList"; import type { SchemaCollection, OrphanedTable } from "../../src/lib/api"; import { render } from "../utils/render.tsx"; @@ -222,4 +222,69 @@ describe("ContentTypeList", () => { await expect.element(screen.getByText("Loading collections...")).toBeInTheDocument(); }); }); + + describe("reordering", () => { + const twoCollections = [ + makeCollection({ id: "1", slug: "posts", label: "Posts" }), + makeCollection({ id: "2", slug: "pages", label: "Pages" }), + ]; + + it("renders a labelled drag handle per row when onReorder is provided", async () => { + const screen = await render( + , + ); + + // The accessible name carries the collection, so screen-reader users + // know which row the handle moves. + await expect + .element(screen.getByRole("button", { name: "Reorder Posts" })) + .toBeInTheDocument(); + await expect + .element(screen.getByRole("button", { name: "Reorder Pages" })) + .toBeInTheDocument(); + }); + + it("renders no drag handles without onReorder", async () => { + const screen = await render(); + + expect(screen.getByRole("button", { name: "Reorder Posts" }).query()).toBeNull(); + }); + + it("renders no drag handles for a single collection (nothing to reorder)", async () => { + const screen = await render( + , + ); + + expect(screen.getByRole("button", { name: "Reorder Posts" }).query()).toBeNull(); + }); + + it("renders collections in the order given, not alphabetically", async () => { + // The server already returns them ordered; the list must not re-sort. + const screen = await render( + , + ); + + const rendered = screen.container.querySelectorAll("tbody code"); + expect(Array.from(rendered, (el) => el.textContent)).toEqual(["posts", "pages"]); + }); + }); + + describe("moveCollection", () => { + it("moves an item down to the drop target index", () => { + expect(moveCollection(["a", "b", "c"], "a", "c")).toEqual(["b", "c", "a"]); + }); + + it("moves an item up to the drop target index", () => { + expect(moveCollection(["a", "b", "c"], "c", "a")).toEqual(["c", "a", "b"]); + }); + + it("returns the same reference when the move is a no-op", () => { + const slugs = ["a", "b", "c"]; + // Same identity lets the caller skip both the state update and the + // network request on a drop that changes nothing. + expect(moveCollection(slugs, "b", "b")).toBe(slugs); + expect(moveCollection(slugs, "b", "missing")).toBe(slugs); + expect(moveCollection(slugs, "missing", "b")).toBe(slugs); + }); + }); }); diff --git a/packages/core/src/api/handlers/schema.ts b/packages/core/src/api/handlers/schema.ts index 2426a47d39..950ba27688 100644 --- a/packages/core/src/api/handlers/schema.ts +++ b/packages/core/src/api/handlers/schema.ts @@ -423,6 +423,43 @@ export async function handleSchemaFieldDelete( } } +/** + * Reorder collections in the admin sidebar + */ +export async function handleSchemaCollectionReorder( + db: Kysely, + slugs: string[], +): Promise> { + try { + const registry = new SchemaRegistry(db); + await registry.reorderCollections(slugs); + + return { + success: true, + data: { success: true }, + }; + } catch (error) { + if (error instanceof SchemaError) { + return { + success: false, + error: { + code: error.code, + message: error.message, + details: error.details, + }, + }; + } + console.error("[emdash] Failed to reorder collections:", error); + return { + success: false, + error: { + code: "SCHEMA_COLLECTION_REORDER_ERROR", + message: "Failed to reorder collections", + }, + }; + } +} + /** * Reorder fields */ diff --git a/packages/core/src/api/openapi/document.ts b/packages/core/src/api/openapi/document.ts index 4dceded223..f712a5a1c5 100644 --- a/packages/core/src/api/openapi/document.ts +++ b/packages/core/src/api/openapi/document.ts @@ -87,6 +87,7 @@ import { createCollectionBody, createFieldBody, fieldListResponseSchema, + collectionReorderBody, fieldReorderBody, fieldResponseSchema, orphanedTableListResponseSchema, @@ -1030,6 +1031,28 @@ const schemaPaths = { }, }, }, + "/_emdash/api/schema/collections/reorder": { + post: { + operationId: "reorderCollections", + summary: "Reorder collections in the admin sidebar", + description: + "Sets the sidebar order. Collections omitted from the list lose their explicit position and fall back to alphabetical order after the ordered ones.", + tags: ["Schema"], + requestBody: { content: { [JSON_CONTENT]: { schema: collectionReorderBody } } }, + responses: { + "200": { + description: "Reordered", + content: { + [JSON_CONTENT]: { + schema: successEnvelope(z.object({ success: z.literal(true) })), + }, + }, + }, + ...authErrors, + ...standardErrors(400, 404, 500), + }, + }, + }, "/_emdash/api/schema/collections/{slug}/fields/reorder": { post: { operationId: "reorderFields", diff --git a/packages/core/src/api/schemas/schema.ts b/packages/core/src/api/schemas/schema.ts index f2922289e8..fddabf2364 100644 --- a/packages/core/src/api/schemas/schema.ts +++ b/packages/core/src/api/schemas/schema.ts @@ -86,6 +86,7 @@ export const createCollectionBody = z source: z.string().regex(collectionSourcePattern).optional(), urlPattern: z.string().optional(), hasSeo: z.boolean().optional(), + sortOrder: z.number().int().nullish(), }) .meta({ id: "CreateCollectionBody" }); @@ -98,6 +99,7 @@ export const updateCollectionBody = z supports: z.array(collectionSupportValues).optional(), urlPattern: z.string().nullish(), hasSeo: z.boolean().optional(), + sortOrder: z.number().int().nullish(), commentsEnabled: z.boolean().optional(), commentsModeration: z.enum(["all", "first_time", "none"]).optional(), commentsClosedAfterDays: z.number().int().min(0).optional(), @@ -144,6 +146,13 @@ export const fieldReorderBody = z }) .meta({ id: "FieldReorderBody" }); +export const collectionReorderBody = z + .object({ + /** Full desired sidebar order. Collections left out fall back to alphabetical. */ + slugs: z.array(z.string().min(1)), + }) + .meta({ id: "CollectionReorderBody" }); + export const orphanRegisterBody = z .object({ label: z.string().optional(), @@ -179,6 +188,7 @@ export const collectionSchema = z source: z.string().nullable(), urlPattern: z.string().nullable(), hasSeo: z.boolean(), + sortOrder: z.number().int().nullable(), createdAt: z.string(), updatedAt: z.string(), }) diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts index 1c0b98cd14..9b2e277684 100644 --- a/packages/core/src/astro/integration/routes.ts +++ b/packages/core/src/astro/integration/routes.ts @@ -308,6 +308,15 @@ export function injectCoreRoutes( entrypoint: resolveRoute("api/schema/collections/index.ts"), }); + // Order matters: the static `reorder` route must precede the dynamic + // `[slug]` route so Astro's resolver dispatches POST + // /schema/collections/reorder to the reorder handler instead of treating + // "reorder" as a collection slug. + injectRoute({ + pattern: "/_emdash/api/schema/collections/reorder", + entrypoint: resolveRoute("api/schema/collections/reorder.ts"), + }); + injectRoute({ pattern: "/_emdash/api/schema/collections/[slug]", entrypoint: resolveRoute("api/schema/collections/[slug]/index.ts"), diff --git a/packages/core/src/astro/routes/api/schema/collections/reorder.ts b/packages/core/src/astro/routes/api/schema/collections/reorder.ts new file mode 100644 index 0000000000..b13c8bd4af --- /dev/null +++ b/packages/core/src/astro/routes/api/schema/collections/reorder.ts @@ -0,0 +1,31 @@ +/** + * Collection reorder endpoint + * + * POST /_emdash/api/schema/collections/reorder - Set the admin sidebar order + */ + +import type { APIRoute } from "astro"; + +import { requirePerm } from "#api/authorize.js"; +import { requireDb, unwrapResult } from "#api/error.js"; +import { handleSchemaCollectionReorder } from "#api/index.js"; +import { parseBody, isParseError } from "#api/parse.js"; +import { collectionReorderBody } from "#api/schemas.js"; + +export const prerender = false; + +export const POST: APIRoute = async ({ request, locals }) => { + const { emdash, user } = locals; + + const dbErr = requireDb(emdash?.db); + if (dbErr) return dbErr; + + const denied = requirePerm(user, "schema:manage"); + if (denied) return denied; + + const body = await parseBody(request, collectionReorderBody); + if (isParseError(body)) return body; + + const result = await handleSchemaCollectionReorder(emdash.db, body.slugs); + return unwrapResult(result); +}; diff --git a/packages/core/src/astro/routes/api/schema/index.ts b/packages/core/src/astro/routes/api/schema/index.ts index ddadd34378..5d9c0ae5d4 100644 --- a/packages/core/src/astro/routes/api/schema/index.ts +++ b/packages/core/src/astro/routes/api/schema/index.ts @@ -79,6 +79,7 @@ import type { PortableTextBlock } from "emdash"; description: c.description, icon: c.icon, supports: c.supports, + sortOrder: c.sortOrder, fields: c.fields.map((f) => ({ slug: f.slug, label: f.label, diff --git a/packages/core/src/cli/commands/export-seed.ts b/packages/core/src/cli/commands/export-seed.ts index 707a8a122c..8b1aa719a3 100644 --- a/packages/core/src/cli/commands/export-seed.ts +++ b/packages/core/src/cli/commands/export-seed.ts @@ -316,6 +316,7 @@ async function exportCollections(db: Kysely): Promise 0 ? collection.supports : undefined, urlPattern: collection.urlPattern || undefined, + sortOrder: collection.sortOrder, fields: fields.map( (field): SeedField => ({ slug: field.slug, diff --git a/packages/core/src/database/migrations/054_collection_sort_order.ts b/packages/core/src/database/migrations/054_collection_sort_order.ts new file mode 100644 index 0000000000..574f8a51fc --- /dev/null +++ b/packages/core/src/database/migrations/054_collection_sort_order.ts @@ -0,0 +1,20 @@ +import type { Kysely } from "kysely"; + +import { columnExists } from "../dialect-helpers.js"; + +/** + * Migration: explicit collection order in the admin sidebar. + * + * Adds `sort_order` to `_emdash_collections`. A NULL `sort_order` means no + * explicit position: those collections sort after the ordered ones, keeping + * the alphabetical-by-slug order. + */ +export async function up(db: Kysely): Promise { + if (!(await columnExists(db, "_emdash_collections", "sort_order"))) { + await db.schema.alterTable("_emdash_collections").addColumn("sort_order", "integer").execute(); + } +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("_emdash_collections").dropColumn("sort_order").execute(); +} diff --git a/packages/core/src/database/migrations/runner.ts b/packages/core/src/database/migrations/runner.ts index a3ce955d45..ed7534b66f 100644 --- a/packages/core/src/database/migrations/runner.ts +++ b/packages/core/src/database/migrations/runner.ts @@ -56,6 +56,7 @@ import * as m050 from "./050_media_usage_index_status.js"; import * as m051 from "./051_content_taxonomies_denorm.js"; import * as m052 from "./052_media_usage_read_index.js"; import * as m053 from "./053_plugin_mcp_tools.js"; +import * as m054 from "./054_collection_sort_order.js"; const MIGRATIONS: Readonly> = Object.freeze({ "001_initial": m001, @@ -110,6 +111,7 @@ const MIGRATIONS: Readonly> = Object.freeze({ "051_content_taxonomies_denorm": m051, "052_media_usage_read_index": m052, "053_plugin_mcp_tools": m053, + "054_collection_sort_order": m054, }); /** Total number of registered migrations. Exported for use in tests. */ diff --git a/packages/core/src/database/types.ts b/packages/core/src/database/types.ts index 0f4ba13ed7..e9f9a23e52 100644 --- a/packages/core/src/database/types.ts +++ b/packages/core/src/database/types.ts @@ -296,6 +296,7 @@ export interface CollectionTable { search_config: string | null; // JSON: { enabled: boolean, weights: Record } has_seo: number; // 0 or 1 — opt-in SEO fields for this collection url_pattern: string | null; // URL pattern with {slug} placeholder (e.g. "/blog/{slug}") + sort_order: number | null; // explicit admin sidebar position; NULL = alphabetical fallback comments_enabled: Generated; // 0 or 1 comments_moderation: Generated; // 'all' | 'first_time' | 'none' comments_closed_after_days: Generated; // 0 = never close diff --git a/packages/core/src/schema/registry.ts b/packages/core/src/schema/registry.ts index a2608f45a2..9c853307ee 100644 --- a/packages/core/src/schema/registry.ts +++ b/packages/core/src/schema/registry.ts @@ -73,6 +73,19 @@ const VALID_COLLECTION_SUPPORTS: ReadonlySet = new Set`coalesce(sort_order, ${sql.lit(UNORDERED_COLLECTION_RANK)})`; + function isCollectionSupport(value: unknown): value is CollectionSupport { return typeof value === "string" && VALID_COLLECTION_SUPPORTS.has(value); } @@ -126,6 +139,7 @@ export class SchemaRegistry { const rows = await this.db .selectFrom("_emdash_collections") .selectAll() + .orderBy(collectionOrder, "asc") .orderBy("slug", "asc") .execute(); @@ -177,6 +191,7 @@ export class SchemaRegistry { const collectionRows = await this.db .selectFrom("_emdash_collections") .selectAll() + .orderBy(collectionOrder, "asc") .orderBy("slug", "asc") .execute(); @@ -256,6 +271,7 @@ export class SchemaRegistry { supports: JSON.stringify(supports), source: input.source ?? "manual", has_seo: hasSeo ? 1 : 0, + sort_order: input.sortOrder ?? null, comments_enabled: input.commentsEnabled ? 1 : 0, url_pattern: input.urlPattern ?? null, }) @@ -354,6 +370,7 @@ export class SchemaRegistry { supports: JSON.stringify(supports), source: "seed", has_seo: hasSeo ? 1 : 0, + sort_order: input.sortOrder ?? null, comments_enabled: input.commentsEnabled ? 1 : 0, url_pattern: input.urlPattern ?? null, }) @@ -416,6 +433,8 @@ export class SchemaRegistry { ? (input.urlPattern ?? null) : (existing.urlPattern ?? null), has_seo: hasSeo ? 1 : 0, + sort_order: + input.sortOrder !== undefined ? input.sortOrder : (existing.sortOrder ?? null), comments_enabled: input.commentsEnabled !== undefined ? input.commentsEnabled @@ -876,6 +895,60 @@ export class SchemaRegistry { } } + /** + * Reorder collections in the admin sidebar. + * + * `slugs` is the full desired order: every listed collection gets its + * index as `sort_order`, and any collection left out has its explicit + * position cleared, dropping it back to the alphabetical tail. Unknown or + * duplicate slugs throw before anything is written. + */ + async reorderCollections(slugs: string[]): Promise { + const known = new Set((await this.listCollections()).map((collection) => collection.slug)); + + const unknown = slugs.filter((slug) => !known.has(slug)); + if (unknown.length > 0) { + throw new SchemaError( + `Unknown collection${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}`, + "COLLECTION_NOT_FOUND", + { slugs: unknown }, + ); + } + + const duplicates = slugs.filter((slug, index) => slugs.indexOf(slug) !== index); + if (duplicates.length > 0) { + throw new SchemaError( + `Duplicate collection${duplicates.length > 1 ? "s" : ""}: ${[...new Set(duplicates)].join(", ")}`, + "DUPLICATE_SLUG", + { slugs: [...new Set(duplicates)] }, + ); + } + + const now = new Date().toISOString(); + const ordered = new Set(slugs); + + await withTransaction(this.db, async (trx) => { + for (const [index, slug] of slugs.entries()) { + await trx + .updateTable("_emdash_collections") + .set({ sort_order: index, updated_at: now }) + .where("slug", "=", slug) + .execute(); + } + + const cleared = [...known].filter((slug) => !ordered.has(slug)); + // Chunked to stay under D1's bound-parameter limit; typical sites + // clear far fewer than one chunk. + for (const slugChunk of chunks(cleared, SQL_BATCH_SIZE)) { + await trx + .updateTable("_emdash_collections") + .set({ sort_order: null, updated_at: now }) + .where("slug", "in", slugChunk) + .execute(); + } + }); + } + /** * Reorder fields */ @@ -1228,6 +1301,7 @@ export class SchemaRegistry { source: row.source && isCollectionSource(row.source) ? row.source : undefined, hasSeo: row.has_seo === 1, urlPattern: row.url_pattern ?? undefined, + sortOrder: row.sort_order ?? undefined, commentsEnabled: row.comments_enabled === 1, commentsModeration: moderation === "all" || moderation === "first_time" || moderation === "none" diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 6d5055e981..d9681501b9 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -171,6 +171,12 @@ export interface Collection { hasSeo: boolean; /** URL pattern with {slug} placeholder (e.g. "/{slug}", "/blog/{slug}") */ urlPattern?: string; + /** + * Explicit position in the admin sidebar. Collections with a `sortOrder` + * come first, in ascending order; the rest keep the alphabetical-by-slug + * order and follow. `undefined` means "no explicit position". + */ + sortOrder?: number; /** Whether comments are enabled for this collection */ commentsEnabled: boolean; /** Moderation strategy: "all" | "first_time" | "none" */ @@ -219,6 +225,8 @@ export interface CreateCollectionInput { source?: CollectionSource; urlPattern?: string; hasSeo?: boolean; + /** Explicit admin sidebar position (omit for the alphabetical fallback) */ + sortOrder?: number | null; commentsEnabled?: boolean; } @@ -233,6 +241,8 @@ export interface UpdateCollectionInput { supports?: CollectionSupport[]; urlPattern?: string; hasSeo?: boolean; + /** Explicit admin sidebar position; `null` clears it back to alphabetical */ + sortOrder?: number | null; commentsEnabled?: boolean; commentsModeration?: "all" | "first_time" | "none"; commentsClosedAfterDays?: number; @@ -331,6 +341,9 @@ export const RESERVED_COLLECTION_SLUGS = [ "taxonomies", "options", "audit_logs", + // Shadowed by the static POST /schema/collections/reorder route: a + // collection with this slug could never be addressed at its own URL. + "reorder", ]; /** diff --git a/packages/core/src/seed/apply.ts b/packages/core/src/seed/apply.ts index 6c1da34284..aa455fba2e 100644 --- a/packages/core/src/seed/apply.ts +++ b/packages/core/src/seed/apply.ts @@ -179,6 +179,7 @@ export async function applySeed( icon: collection.icon, supports: collection.supports || [], urlPattern: collection.urlPattern, + sortOrder: collection.sortOrder, commentsEnabled: collection.commentsEnabled, }); result.collections.updated++; @@ -247,6 +248,7 @@ export async function applySeed( icon: collection.icon, supports: collection.supports || [], urlPattern: collection.urlPattern, + sortOrder: collection.sortOrder, commentsEnabled: collection.commentsEnabled, }, fields, diff --git a/packages/core/src/seed/types.ts b/packages/core/src/seed/types.ts index a106bfcd51..acaff1275e 100644 --- a/packages/core/src/seed/types.ts +++ b/packages/core/src/seed/types.ts @@ -74,6 +74,11 @@ export interface SeedCollection { icon?: string; supports?: ("drafts" | "revisions" | "preview" | "scheduling" | "search" | "seo")[]; urlPattern?: string; + /** + * Explicit position in the admin sidebar (ascending). Collections without + * a `sortOrder` keep the alphabetical order and follow the ordered ones. + */ + sortOrder?: number; /** Enable comments on this collection */ commentsEnabled?: boolean; fields: SeedField[]; diff --git a/packages/core/tests/unit/schema/registry.test.ts b/packages/core/tests/unit/schema/registry.test.ts index 0991d0d9ce..b7f5a84435 100644 --- a/packages/core/tests/unit/schema/registry.test.ts +++ b/packages/core/tests/unit/schema/registry.test.ts @@ -95,6 +95,98 @@ describe("SchemaRegistry", () => { expect(collections.map((c) => c.slug)).toEqual(["pages", "posts"]); // sorted }); + describe("sidebar sort order", () => { + it("has no explicit order by default", async () => { + const collection = await registry.createCollection({ slug: "posts", label: "Posts" }); + + expect(collection.sortOrder).toBeUndefined(); + }); + + it("lists explicitly ordered collections first, then the rest alphabetically", async () => { + // `projects` sorts last alphabetically but is pinned first; + // `education`/`certifications` have no position and keep the + // alphabetical fallback behind it. + await registry.createCollection({ slug: "education", label: "Education" }); + await registry.createCollection({ slug: "projects", label: "Projects", sortOrder: 0 }); + await registry.createCollection({ slug: "certifications", label: "Certifications" }); + await registry.createCollection({ slug: "positions", label: "Positions", sortOrder: 1 }); + + const collections = await registry.listCollections(); + + expect(collections.map((c) => c.slug)).toEqual([ + "projects", + "positions", + "certifications", + "education", + ]); + }); + + it("applies the same order to listCollectionsWithFields (the manifest path)", async () => { + await registry.createCollection({ slug: "education", label: "Education" }); + await registry.createCollection({ slug: "projects", label: "Projects", sortOrder: 0 }); + + const collections = await registry.listCollectionsWithFields(); + + expect(collections.map((c) => c.slug)).toEqual(["projects", "education"]); + }); + + it("reorderCollections assigns positions in the given order", async () => { + await registry.createCollection({ slug: "posts", label: "Posts" }); + await registry.createCollection({ slug: "pages", label: "Pages" }); + await registry.createCollection({ slug: "authors", label: "Authors" }); + + await registry.reorderCollections(["posts", "authors", "pages"]); + + const collections = await registry.listCollections(); + expect(collections.map((c) => c.slug)).toEqual(["posts", "authors", "pages"]); + expect(collections.map((c) => c.sortOrder)).toEqual([0, 1, 2]); + }); + + it("reorderCollections clears the position of collections left out", async () => { + await registry.createCollection({ slug: "posts", label: "Posts", sortOrder: 0 }); + await registry.createCollection({ slug: "pages", label: "Pages", sortOrder: 1 }); + + await registry.reorderCollections(["pages"]); + + // `posts` loses its pin and falls back to the alphabetical tail, + // so it must sort *after* the still-ordered `pages`. + const collections = await registry.listCollections(); + expect(collections.map((c) => c.slug)).toEqual(["pages", "posts"]); + expect(await registry.getCollection("posts").then((c) => c?.sortOrder)).toBeUndefined(); + }); + + it("reorderCollections rejects unknown slugs without touching the order", async () => { + await registry.createCollection({ slug: "posts", label: "Posts" }); + await registry.createCollection({ slug: "pages", label: "Pages" }); + + await expect(registry.reorderCollections(["posts", "ghosts"])).rejects.toThrow(SchemaError); + + const collections = await registry.listCollections(); + expect(collections.map((c) => c.sortOrder)).toEqual([undefined, undefined]); + }); + + it("reorderCollections rejects duplicate slugs", async () => { + await registry.createCollection({ slug: "posts", label: "Posts" }); + + await expect(registry.reorderCollections(["posts", "posts"])).rejects.toThrow(SchemaError); + }); + + it("update preserves the position when sortOrder is omitted, and clears it on null", async () => { + await registry.createCollection({ slug: "posts", label: "Posts", sortOrder: 3 }); + + expect((await registry.updateCollection("posts", { label: "Blog" })).sortOrder).toBe(3); + expect((await registry.updateCollection("posts", { sortOrder: null })).sortOrder).toBe( + undefined, + ); + }); + + it("rejects `reorder` as a collection slug (shadowed by the reorder route)", async () => { + await expect( + registry.createCollection({ slug: "reorder", label: "Reorder" }), + ).rejects.toThrow(SchemaError); + }); + }); + it("should get a collection by slug", async () => { await registry.createCollection({ slug: "products", diff --git a/packages/core/tests/unit/seed/apply.test.ts b/packages/core/tests/unit/seed/apply.test.ts index 6b82987143..50f5e717d7 100644 --- a/packages/core/tests/unit/seed/apply.test.ts +++ b/packages/core/tests/unit/seed/apply.test.ts @@ -163,6 +163,35 @@ describe("applySeed", () => { expect(row.rows[0]?.title).toBe("Untitled"); }); + it("applies sortOrder from the seed and orders the list by it", async () => { + const seed: SeedFile = { + version: "1", + collections: [ + { + slug: "education", + label: "Education", + fields: [{ slug: "title", label: "Title", type: "string" }], + }, + { + slug: "projects", + label: "Projects", + sortOrder: 0, + fields: [{ slug: "title", label: "Title", type: "string" }], + }, + ], + }; + + await applySeed(db, seed); + + const registry = new SchemaRegistry(db); + expect((await registry.getCollection("projects"))?.sortOrder).toBe(0); + expect((await registry.getCollection("education"))?.sortOrder).toBeUndefined(); + expect((await registry.listCollections()).map((c) => c.slug)).toEqual([ + "projects", + "education", + ]); + }); + it("should skip existing collections", async () => { // Create collection first const registry = new SchemaRegistry(db);