Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .changeset/fix-content-list-sortable-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"emdash": patch
"@emdash-cms/admin": patch
---

Make the admin collection list column headers sortable. `Title`, `Status`, `Locale`, and `Date` are now clickable buttons that toggle direction; the current sort state is exposed via `aria-sort` on the `<th>` so screen readers announce it correctly.

The server's `orderBy` field whitelist now accepts `status`, `locale`, and `name` alongside the existing date fields — unchanged from a security standpoint, the repo still rejects unknown field names to prevent column enumeration.

Callers of `<ContentList>` that don't pass `onSortChange` render the previous static-label headers, so legacy integrations (e.g. the content picker) are unaffected.
122 changes: 110 additions & 12 deletions packages/admin/src/components/ContentList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import {
MagnifyingGlass,
CaretLeft,
CaretRight,
CaretUp,
CaretDown,
CaretUpDown,
} from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import * as React from "react";
Expand All @@ -20,6 +23,13 @@ import { contentUrl } from "../lib/url.js";
import { cn } from "../lib/utils";
import { LocaleSwitcher } from "./LocaleSwitcher";

/** Sortable content list columns. Maps to the server's order field whitelist. */
export type ContentListSortField = "title" | "status" | "locale" | "updatedAt";
export interface ContentListSort {
field: ContentListSortField;
direction: "asc" | "desc";
}

export interface ContentListProps {
collection: string;
collectionLabel: string;
Expand All @@ -44,6 +54,14 @@ export interface ContentListProps {
onLocaleChange?: (locale: string) => void;
/** URL pattern for published content links (e.g. `/blog/{slug}`) */
urlPattern?: string;
/**
* Controlled sort state. When `onSortChange` is also provided, the column
* headers become sort controls that invoke it. Uncontrolled sort keeps
* the backward-compatible "static headers, server-default ordering"
* behavior for callers that haven't opted in yet.
*/
sort?: ContentListSort;
onSortChange?: (sort: ContentListSort) => void;
}

type ViewTab = "all" | "trash";
Expand Down Expand Up @@ -84,6 +102,8 @@ export function ContentList({
activeLocale,
onLocaleChange,
urlPattern,
sort,
onSortChange,
}: ContentListProps) {
const { t } = useLingui();
const [activeTab, setActiveTab] = React.useState<ViewTab>("all");
Expand Down Expand Up @@ -186,20 +206,32 @@ export function ContentList({
<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`Title`}
</th>
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
{t`Status`}
</th>
<SortableTh
field="title"
sort={sort}
onSortChange={onSortChange}
label={t`Title`}
/>
<SortableTh
field="status"
sort={sort}
onSortChange={onSortChange}
label={t`Status`}
/>
{i18n && (
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
{t`Locale`}
</th>
<SortableTh
field="locale"
sort={sort}
onSortChange={onSortChange}
label={t`Locale`}
/>
)}
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
{t`Date`}
</th>
<SortableTh
field="updatedAt"
sort={sort}
onSortChange={onSortChange}
label={t`Date`}
/>
<th scope="col" className="px-4 py-3 text-end text-sm font-medium">
{t`Actions`}
</th>
Expand Down Expand Up @@ -345,6 +377,72 @@ export function ContentList({
);
}

interface SortableThProps {
field: ContentListSortField;
sort: ContentListSort | undefined;
onSortChange: ((sort: ContentListSort) => void) | undefined;
label: string;
}

/**
* Table header that doubles as a sort control when the parent opted in by
* passing `onSortChange`. When no callback is provided we fall back to a
* plain `<th>` so legacy callers (and screen readers) see exactly the same
* markup as before this change.
*
* The button's accessible name is just the column label — the sort state
* is conveyed via `aria-sort` on the <th>, which screen readers announce
* automatically. Adding a verbose aria-label would make each header re-read
* the sort instruction on every focus, which is noisy.
*/
function SortableTh({ field, sort, onSortChange, label }: SortableThProps) {
const isActive = sort?.field === field;
const direction = isActive ? sort?.direction : undefined;

if (!onSortChange) {
return (
<th scope="col" className="px-4 py-3 text-start text-sm font-medium">
{label}
</th>
);
}

const ariaSort: "ascending" | "descending" | "none" = isActive
? direction === "asc"
? "ascending"
: "descending"
: "none";

const handleClick = () => {
// Default to descending for a new column; toggle direction when
// clicking the already-active one.
if (isActive) {
onSortChange({ field, direction: direction === "asc" ? "desc" : "asc" });
} else {
onSortChange({ field, direction: "desc" });
}
};

const Icon = isActive ? (direction === "asc" ? CaretUp : CaretDown) : CaretUpDown;

return (
<th scope="col" aria-sort={ariaSort} className="px-4 py-3 text-start text-sm font-medium">
<button
type="button"
onClick={handleClick}
className={cn(
"inline-flex items-center gap-1 rounded hover:text-kumo-brand",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-kumo-brand",
isActive ? "text-kumo-fg" : "text-kumo-subtle",
)}
>
<span>{label}</span>
<Icon className="h-3 w-3" aria-hidden="true" />
</button>
</th>
);
}

interface ContentListItemProps {
item: ContentItem;
collection: string;
Expand Down
6 changes: 6 additions & 0 deletions packages/admin/src/lib/api/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,13 +136,19 @@ export async function fetchContentList(
limit?: number;
status?: string;
locale?: string;
/** Field name to order by, matching the server's whitelist. */
orderBy?: string;
/** Sort direction; defaults to "desc" on the server. */
order?: "asc" | "desc";
},
): Promise<FindManyResult<ContentItem>> {
const params = new URLSearchParams();
if (options?.cursor) params.set("cursor", options.cursor);
if (options?.limit) params.set("limit", String(options.limit));
if (options?.status) params.set("status", options.status);
if (options?.locale) params.set("locale", options.locale);
if (options?.orderBy) params.set("orderBy", options.orderBy);
if (options?.order) params.set("order", options.order);

const url = `${API_BASE}/content/${collection}${params.toString() ? `?${params}` : ""}`;
const response = await apiFetch(url);
Expand Down
15 changes: 13 additions & 2 deletions packages/admin/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import * as React from "react";

import { CommentInbox } from "./components/comments/CommentInbox";
import { ContentEditor } from "./components/ContentEditor";
import { ContentList } from "./components/ContentList";
import { ContentList, type ContentListSort } from "./components/ContentList";
import { ContentTypeEditor } from "./components/ContentTypeEditor";
import { ContentTypeList } from "./components/ContentTypeList";
import { Dashboard } from "./components/Dashboard";
Expand Down Expand Up @@ -295,14 +295,23 @@ function ContentListPage() {
// Default to defaultLocale when i18n is enabled and no locale specified
const activeLocale = i18n ? (localeParam ?? i18n.defaultLocale) : undefined;

// Controlled sort state — passed to the list, and included in the query
// key so changing direction invalidates the current cursor chain.
const [sort, setSort] = React.useState<ContentListSort>({
field: "updatedAt",
direction: "desc",
});

const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, error } =
useInfiniteQuery({
queryKey: ["content", collection, { locale: activeLocale }],
queryKey: ["content", collection, { locale: activeLocale, sort }],
queryFn: ({ pageParam }) =>
fetchContentList(collection, {
locale: activeLocale,
cursor: pageParam,
limit: 100,
orderBy: sort.field,
order: sort.direction,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
Expand Down Expand Up @@ -419,6 +428,8 @@ function ContentListPage() {
activeLocale={activeLocale}
onLocaleChange={handleLocaleChange}
urlPattern={collectionConfig.urlPattern}
sort={sort}
onSortChange={setSort}
/>
);
}
Expand Down
63 changes: 63 additions & 0 deletions packages/admin/tests/components/ContentList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -456,4 +456,67 @@ describe("ContentList", () => {
expect(screen.getByText("Post 0").query()).toBeNull();
});
});

describe("sortable headers", () => {
it("calls onSortChange when a header is clicked", async () => {
const onSortChange = vi.fn();
const items = [makeItem({ id: "1", data: { title: "Post" } })];
const screen = await render(
<ContentList
{...defaultProps}
items={items}
sort={{ field: "updatedAt", direction: "desc" }}
onSortChange={onSortChange}
/>,
);

await screen.getByRole("button", { name: "Title" }).click();

expect(onSortChange).toHaveBeenCalledWith({ field: "title", direction: "desc" });
});

it("toggles direction when clicking the active column", async () => {
const onSortChange = vi.fn();
const items = [makeItem({ id: "1", data: { title: "Post" } })];
const screen = await render(
<ContentList
{...defaultProps}
items={items}
sort={{ field: "title", direction: "desc" }}
onSortChange={onSortChange}
/>,
);

await screen.getByRole("button", { name: "Title" }).click();

expect(onSortChange).toHaveBeenCalledWith({ field: "title", direction: "asc" });
});

it("exposes sort state via aria-sort on the active header", async () => {
const items = [makeItem({ id: "1", data: { title: "Post" } })];
const screen = await render(
<ContentList
{...defaultProps}
items={items}
sort={{ field: "title", direction: "asc" }}
onSortChange={vi.fn()}
/>,
);

const titleHeader = screen.getByRole("columnheader", { name: "Title" });
const statusHeader = screen.getByRole("columnheader", { name: "Status" });
await expect.element(titleHeader).toHaveAttribute("aria-sort", "ascending");
// Inactive columns explicitly advertise "none" so the header still
// announces as sortable.
await expect.element(statusHeader).toHaveAttribute("aria-sort", "none");
});

it("falls back to static headers when onSortChange is not provided", async () => {
const items = [makeItem({ id: "1", data: { title: "Post" } })];
const screen = await render(<ContentList {...defaultProps} items={items} />);

// The header must not render as a button — it's just a label.
expect(screen.getByRole("button", { name: "Title" }).query()).toBeNull();
});
});
});
3 changes: 3 additions & 0 deletions packages/core/src/database/repositories/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1195,7 +1195,10 @@ export class ContentRepository {
scheduledAt: "scheduled_at",
deletedAt: "deleted_at",
title: "title",
name: "name",
slug: "slug",
status: "status",
locale: "locale",
};

const mapped = mapping[field];
Expand Down
29 changes: 29 additions & 0 deletions packages/core/tests/database/repositories/content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,35 @@ describe("ContentRepository", () => {
expect(result.items).toEqual([]);
expect(result.nextCursor).toBeUndefined();
});

describe("orderBy", () => {
// Regression guard for "table headers aren't sort controls": the
// admin now sends orderBy={field,direction} — the repo must accept
// the columns the UI wants to expose, not just dates.
it("accepts status as an order field", async () => {
const result = await repo.findMany("post", {
orderBy: { field: "status", direction: "asc" },
});

// alphabetical asc places 'draft' before 'published'
expect(result.items[0]!.status).toBe("draft");
});

it("accepts locale as an order field", async () => {
await repo.findMany("post", {
orderBy: { field: "locale", direction: "desc" },
});
// no throw = pass
});

it("rejects unknown fields to block column enumeration", async () => {
await expect(
repo.findMany("post", {
orderBy: { field: "password", direction: "asc" },
}),
).rejects.toThrow(EmDashValidationError);
});
});
});

describe("update", () => {
Expand Down
Loading