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
6 changes: 6 additions & 0 deletions .changeset/collection-sort-order.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions docs/src/content/docs/themes/seed-files.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

<Aside type="tip" title="Ordering the sidebar">
Collections are listed alphabetically by slug in the admin sidebar unless they carry a
`sortOrder`. Ordered collections come first, ascending; everything without one keeps the
alphabetical order and follows. Editors can also drag rows on the Content Types screen, which
writes the same value.
</Aside>

### Field Properties

| Property | Type | Required | Description |
Expand Down
214 changes: 163 additions & 51 deletions packages/admin/src/components/ContentTypeList.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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;
}

/**
Expand All @@ -27,11 +68,46 @@ export function ContentTypeList({
isLoading,
onDelete,
onRegisterOrphan,
onReorder,
}: ContentTypeListProps) {
const { t } = useLingui();
const [deleteTarget, setDeleteTarget] = React.useState<SchemaCollection | null>(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<string[] | null>(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 (
<div className="space-y-4">
{/* Header */}
Expand Down Expand Up @@ -88,55 +164,65 @@ export function ContentTypeList({
)}

{/* Table */}
<div className="rounded-md border bg-kumo-base overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b bg-kumo-tint/50">
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
{t`Name`}
</th>
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
{t`Slug`}
</th>
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
{t`Source`}
</th>
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
{t`Features`}
</th>
<th scope="col" className="px-4 py-3 text-end text-sm font-medium">
{t`Actions`}
</th>
</tr>
</thead>
<tbody className="divide-y divide-kumo-line">
{isLoading ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-kumo-subtle">
{t`Loading collections...`}
</td>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<div className="rounded-md border bg-kumo-base overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b bg-kumo-tint/50">
{canReorder && (
<th scope="col" className="w-10 px-2 py-3">
<span className="sr-only">{t`Reorder`}</span>
</th>
)}
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
{t`Name`}
</th>
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
{t`Slug`}
</th>
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
{t`Source`}
</th>
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
{t`Features`}
</th>
<th scope="col" className="px-4 py-3 text-end text-sm font-medium">
{t`Actions`}
</th>
</tr>
) : collections.length === 0 && !hasOrphans ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-kumo-subtle">
{t`No content types yet.`}{" "}
<Link to="/content-types/new" className="text-kumo-link underline">
{t`Create your first one`}
</Link>
</td>
</tr>
) : (
collections.map((collection) => (
<ContentTypeRow
key={collection.id}
collection={collection}
onRequestDelete={setDeleteTarget}
/>
))
)}
</tbody>
</table>
</div>
</thead>
<tbody className="divide-y divide-kumo-line">
{isLoading ? (
<tr>
<td colSpan={columnCount} className="px-4 py-8 text-center text-kumo-subtle">
{t`Loading collections...`}
</td>
</tr>
) : collections.length === 0 && !hasOrphans ? (
<tr>
<td colSpan={columnCount} className="px-4 py-8 text-center text-kumo-subtle">
{t`No content types yet.`}{" "}
<Link to="/content-types/new" className="text-kumo-link underline">
{t`Create your first one`}
</Link>
</td>
</tr>
) : (
<SortableContext items={orderedSlugs} strategy={verticalListSortingStrategy}>
{orderedCollections.map((collection) => (
<ContentTypeRow
key={collection.id}
collection={collection}
canReorder={canReorder}
onRequestDelete={setDeleteTarget}
/>
))}
</SortableContext>
)}
</tbody>
</table>
</div>
</DndContext>

<ConfirmDialog
open={!!deleteTarget}
Expand Down Expand Up @@ -164,15 +250,41 @@ export function ContentTypeList({

interface ContentTypeRowProps {
collection: SchemaCollection;
canReorder?: boolean;
onRequestDelete?: (collection: SchemaCollection) => 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 (
<tr className="hover:bg-kumo-tint/25">
<tr
ref={setNodeRef}
style={style}
className={cn("hover:bg-kumo-tint/25", isDragging && "relative z-10 bg-kumo-base shadow-sm")}
>
{canReorder && (
<td className="w-10 px-2 py-3">
<button
type="button"
{...attributes}
{...listeners}
aria-label={t`Reorder ${collection.label}`}
className="flex h-8 w-8 cursor-grab items-center justify-center rounded-md text-kumo-subtle hover:bg-kumo-tint focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-kumo-focus active:cursor-grabbing"
>
<DotsSixVertical className="h-4 w-4" aria-hidden="true" />
</button>
</td>
)}
<td className="px-4 py-3">
<div className="flex items-center space-x-3">
<div
Expand Down
1 change: 1 addition & 0 deletions packages/admin/src/lib/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export {
updateField,
deleteField,
reorderFields,
reorderCollections,
fetchOrphanedTables,
registerOrphanedTable,
} from "./schema.js";
Expand Down
21 changes: 21 additions & 0 deletions packages/admin/src/lib/api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export interface SchemaCollection {
source?: string;
urlPattern?: string;
hasSeo: boolean;
/** Explicit sidebar position; absent means the alphabetical fallback */
sortOrder?: number;
commentsEnabled: boolean;
commentsModeration: "all" | "first_time" | "none";
commentsClosedAfterDays: number;
Expand Down Expand Up @@ -83,6 +85,7 @@ export interface CreateCollectionInput {
supports?: string[];
urlPattern?: string;
hasSeo?: boolean;
sortOrder?: number | null;
}

export interface UpdateCollectionInput {
Expand All @@ -93,6 +96,7 @@ export interface UpdateCollectionInput {
supports?: string[];
urlPattern?: string;
hasSeo?: boolean;
sortOrder?: number | null;
commentsEnabled?: boolean;
commentsModeration?: "all" | "first_time" | "none";
commentsClosedAfterDays?: number;
Expand Down Expand Up @@ -293,6 +297,23 @@ export async function reorderFields(collectionSlug: string, fieldSlugs: string[]
if (!response.ok) await throwResponseError(response, i18n._(msg`Failed to reorder fields`));
}

/**
* Reorder collections in the admin sidebar.
*
* `slugs` is the full desired order — collections left out lose their
* explicit position and fall back to alphabetical order after the ordered
* ones.
*/
export async function reorderCollections(slugs: string[]): Promise<void> {
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
// ============================================
Expand Down
12 changes: 12 additions & 0 deletions packages/admin/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ import {
updateField,
deleteField,
reorderFields,
reorderCollections,
fetchOrphanedTables,
registerOrphanedTable,
fetchUsers,
Expand Down Expand Up @@ -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 <ErrorScreen error={error.message} />;
Expand All @@ -1866,6 +1877,7 @@ function ContentTypesListPage() {
isLoading={collectionsLoading || orphansLoading}
onDelete={(slug) => deleteMutation.mutate(slug)}
onRegisterOrphan={(slug) => registerOrphanMutation.mutate(slug)}
onReorder={(slugs) => reorderMutation.mutate(slugs)}
/>
);
}
Expand Down
Loading
Loading