diff --git a/.changeset/fix-content-list-sortable-headers.md b/.changeset/fix-content-list-sortable-headers.md new file mode 100644 index 000000000..080a7a4d3 --- /dev/null +++ b/.changeset/fix-content-list-sortable-headers.md @@ -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 `` 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 `` that don't pass `onSortChange` render the previous static-label headers, so legacy integrations (e.g. the content picker) are unaffected. diff --git a/packages/admin/src/components/ContentList.tsx b/packages/admin/src/components/ContentList.tsx index a3f7fdbcc..7a0aef050 100644 --- a/packages/admin/src/components/ContentList.tsx +++ b/packages/admin/src/components/ContentList.tsx @@ -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"; @@ -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; @@ -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"; @@ -84,6 +102,8 @@ export function ContentList({ activeLocale, onLocaleChange, urlPattern, + sort, + onSortChange, }: ContentListProps) { const { t } = useLingui(); const [activeTab, setActiveTab] = React.useState("all"); @@ -186,20 +206,32 @@ export function ContentList({ - - + + {i18n && ( - + )} - + @@ -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 ` + ); + } + + 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 ( + + ); +} + interface ContentListItemProps { item: ContentItem; collection: string; diff --git a/packages/admin/src/lib/api/content.ts b/packages/admin/src/lib/api/content.ts index 809efe386..746beb3fd 100644 --- a/packages/admin/src/lib/api/content.ts +++ b/packages/admin/src/lib/api/content.ts @@ -136,6 +136,10 @@ 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> { const params = new URLSearchParams(); @@ -143,6 +147,8 @@ export async function fetchContentList( 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); diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index 9fabc9ba9..ca6bb8512 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -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"; @@ -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({ + 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, @@ -419,6 +428,8 @@ function ContentListPage() { activeLocale={activeLocale} onLocaleChange={handleLocaleChange} urlPattern={collectionConfig.urlPattern} + sort={sort} + onSortChange={setSort} /> ); } diff --git a/packages/admin/tests/components/ContentList.test.tsx b/packages/admin/tests/components/ContentList.test.tsx index 0ddb2b35f..abb9bb85e 100644 --- a/packages/admin/tests/components/ContentList.test.tsx +++ b/packages/admin/tests/components/ContentList.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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(); + + // The header must not render as a button — it's just a label. + expect(screen.getByRole("button", { name: "Title" }).query()).toBeNull(); + }); + }); }); diff --git a/packages/core/src/database/repositories/content.ts b/packages/core/src/database/repositories/content.ts index d3f9d240d..4cb842801 100644 --- a/packages/core/src/database/repositories/content.ts +++ b/packages/core/src/database/repositories/content.ts @@ -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]; diff --git a/packages/core/tests/database/repositories/content.test.ts b/packages/core/tests/database/repositories/content.test.ts index 0095f1d6f..50d52dcab 100644 --- a/packages/core/tests/database/repositories/content.test.ts +++ b/packages/core/tests/database/repositories/content.test.ts @@ -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", () => {
- {t`Title`} - - {t`Status`} - - {t`Locale`} - - {t`Date`} - {t`Actions`} ` 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 , 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 ( + + {label} + + +