@@ -511,6 +545,15 @@ export function ContentList({
onSortChange={onSortChange}
label={t`Title`}
/>
+ {listColumns.map((column) => (
+
+ {column.label}
+ |
+ ))}
+ {extensionColumns.map((column) => (
+
+ ))}
{t`Actions`}
|
@@ -577,14 +628,17 @@ export function ContentList({
))
)}
@@ -906,6 +960,37 @@ function SortableTh({ field, sort, onSortChange, label }: SortableThProps) {
);
}
+interface ExtensionColumnHeaderProps {
+ column: ResolvedContentListColumn;
+ collection: string;
+ locale?: string;
+}
+
+function ExtensionColumnHeader({ column, collection, locale }: ExtensionColumnHeaderProps) {
+ const { pluginId, extension } = column;
+ const Header = extension.header;
+
+ return (
+
+
+ {Header ? : extension.label}
+
+ |
+ );
+}
+
/**
* Render the row-count line above pagination. The rules are:
* - A search query always wins — say how many matches there are. In
@@ -950,26 +1035,32 @@ function renderItemCount({
interface ContentListItemProps {
item: ContentItem;
+ visibleItems: readonly ContentItem[];
collection: string;
onDelete?: (id: string) => void;
onDuplicate?: (id: string) => void;
showLocale?: boolean;
urlPattern?: string;
+ listColumns: ContentListColumn[];
selectable?: boolean;
selected?: boolean;
onToggleSelect?: (id: string) => void;
+ extensionColumns?: ResolvedContentListColumn[];
}
function ContentListItem({
item,
+ visibleItems,
collection,
onDelete,
onDuplicate,
showLocale,
urlPattern,
+ listColumns,
selectable,
selected,
onToggleSelect,
+ extensionColumns,
}: ContentListItemProps) {
const { t } = useLingui();
const title = getItemTitle(item);
@@ -996,6 +1087,9 @@ function ContentListItem({
{title}
+ {listColumns.map((column) => (
+
+ ))}
{date.toLocaleDateString()}
|
+ {extensionColumns?.map(({ pluginId, extension }) => {
+ const Cell = extension.cell;
+ return (
+
+
+ |
+
+ |
+ );
+ })}
{item.status === "published" && item.slug && (
@@ -1083,6 +1202,102 @@ function ContentListItem({
);
}
+function ContentListCustomCell({
+ column,
+ value,
+}: {
+ column: ContentListColumn;
+ value: unknown;
+}): React.ReactNode {
+ const { i18n, t } = useLingui();
+ const text = formatListColumnValue(column, value, {
+ emptyLabel: t`Not set`,
+ falseLabel: t`No`,
+ locale: i18n.locale,
+ trueLabel: t`Yes`,
+ });
+ return (
+
+
+ {text}
+
+ |
+ );
+}
+
+interface ListColumnFormatOptions {
+ emptyLabel: string;
+ falseLabel: string;
+ locale: string;
+ trueLabel: string;
+}
+
+function formatListColumnValue(
+ column: ContentListColumn,
+ value: unknown,
+ { emptyLabel, falseLabel, locale, trueLabel }: ListColumnFormatOptions,
+): string {
+ if (value === null || value === undefined || value === "") return emptyLabel;
+
+ const optionLabel = (optionValue: unknown): string => {
+ const text = scalarListColumnValue(optionValue);
+ if (text === undefined) return emptyLabel;
+ return column.options?.find((option) => option.value === text)?.label ?? text;
+ };
+
+ switch (column.kind) {
+ case "select":
+ return optionLabel(value);
+ case "multiSelect": {
+ let values: unknown[];
+ if (Array.isArray(value)) {
+ values = value;
+ } else if (typeof value === "string") {
+ try {
+ const parsed: unknown = JSON.parse(value);
+ values = Array.isArray(parsed) ? parsed : [value];
+ } catch {
+ values = [value];
+ }
+ } else {
+ values = [value];
+ }
+ return values.length > 0
+ ? new Intl.ListFormat(locale, { style: "short", type: "unit" }).format(
+ values.map(optionLabel),
+ )
+ : emptyLabel;
+ }
+ case "boolean":
+ return value === true || value === 1 || value === "1" || value === "true"
+ ? trueLabel
+ : falseLabel;
+ case "datetime": {
+ const text = scalarListColumnValue(value);
+ if (text === undefined) return emptyLabel;
+ const date = new Date(text);
+ return Number.isNaN(date.getTime()) ? text : new Intl.DateTimeFormat(locale).format(date);
+ }
+ case "number": {
+ if (typeof value === "number" || typeof value === "bigint") {
+ return new Intl.NumberFormat(locale).format(value);
+ }
+ return scalarListColumnValue(value) ?? emptyLabel;
+ }
+ case "string":
+ default:
+ return scalarListColumnValue(value) ?? emptyLabel;
+ }
+}
+
+function scalarListColumnValue(value: unknown): string | undefined {
+ if (typeof value === "string") return value;
+ if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") {
+ return String(value);
+ }
+ return undefined;
+}
+
interface TrashedListItemProps {
item: TrashedContentItem;
onRestore?: (id: string) => void;
diff --git a/packages/admin/src/components/index.ts b/packages/admin/src/components/index.ts
index 46d6ac15c1..a98f5a2b78 100644
--- a/packages/admin/src/components/index.ts
+++ b/packages/admin/src/components/index.ts
@@ -5,7 +5,7 @@ export { Header } from "./Header";
// Page components
export { Dashboard, type DashboardProps } from "./Dashboard";
-export { ContentList, type ContentListProps } from "./ContentList";
+export { ContentList, type ContentListColumn, type ContentListProps } from "./ContentList.js";
export { ContentEditor, type ContentEditorProps, type FieldDescriptor } from "./ContentEditor";
export { MediaLibrary, type MediaLibraryProps } from "./MediaLibrary";
export { MediaPickerModal, type MediaPickerModalProps } from "./MediaPickerModal";
diff --git a/packages/admin/src/index.ts b/packages/admin/src/index.ts
index 08b943ba2a..47e45da351 100644
--- a/packages/admin/src/index.ts
+++ b/packages/admin/src/index.ts
@@ -26,6 +26,12 @@ export {
type PluginAdmins,
} from "./lib/plugin-context";
+export type {
+ ContentListColumnHeaderContext,
+ ContentListColumnCellContext,
+ ContentListColumnExtension,
+} from "./lib/content-list-columns.js";
+
// Auth provider context (for accessing pluggable auth provider components)
export {
AuthProviderProvider,
diff --git a/packages/admin/src/lib/api/client.ts b/packages/admin/src/lib/api/client.ts
index 9b0ff18ef2..e557267bcc 100644
--- a/packages/admin/src/lib/api/client.ts
+++ b/packages/admin/src/lib/api/client.ts
@@ -93,6 +93,7 @@ export interface AdminManifest {
supports: string[];
hasSeo: boolean;
urlPattern?: string;
+ listColumns?: string[];
fields: Record<
string,
{
diff --git a/packages/admin/src/lib/api/schema.ts b/packages/admin/src/lib/api/schema.ts
index 1b991befa4..f0aaa8a590 100644
--- a/packages/admin/src/lib/api/schema.ts
+++ b/packages/admin/src/lib/api/schema.ts
@@ -32,6 +32,7 @@ export interface SchemaCollection {
labelSingular?: string;
description?: string;
icon?: string;
+ admin?: CollectionAdminConfig;
supports: string[];
source?: string;
urlPattern?: string;
@@ -44,6 +45,10 @@ export interface SchemaCollection {
updatedAt: string;
}
+export interface CollectionAdminConfig {
+ listColumns?: string[];
+}
+
export interface SchemaField {
id: string;
collectionId: string;
@@ -80,6 +85,7 @@ export interface CreateCollectionInput {
labelSingular?: string;
description?: string;
icon?: string;
+ admin?: CollectionAdminConfig;
supports?: string[];
urlPattern?: string;
hasSeo?: boolean;
@@ -90,6 +96,7 @@ export interface UpdateCollectionInput {
labelSingular?: string;
description?: string;
icon?: string;
+ admin?: CollectionAdminConfig;
supports?: string[];
urlPattern?: string;
hasSeo?: boolean;
diff --git a/packages/admin/src/lib/content-list-columns.tsx b/packages/admin/src/lib/content-list-columns.tsx
new file mode 100644
index 0000000000..1c1f46ec98
--- /dev/null
+++ b/packages/admin/src/lib/content-list-columns.tsx
@@ -0,0 +1,218 @@
+import { Trans } from "@lingui/react/macro";
+import * as React from "react";
+
+import type { AdminManifest, ContentItem } from "./api.js";
+
+/** Context passed to a trusted plugin's content-list column header. */
+export interface ContentListColumnHeaderContext {
+ collection: string;
+ locale?: string;
+}
+
+/** Context passed to a trusted plugin's content-list column cell. */
+export interface ContentListColumnCellContext extends ContentListColumnHeaderContext {
+ item: ContentItem;
+ /** Items rendered on the current page after host filtering and pagination. */
+ visibleItems: readonly ContentItem[];
+}
+
+/** A content-list column contributed by a trusted React plugin. */
+export interface ContentListColumnExtension {
+ /** Stable identifier, unique within this plugin's list columns. */
+ id: string;
+ /** Host-rendered fallback label for the column header. */
+ label: string;
+ cell: React.ComponentType ;
+ /** Optional custom header content. The host still owns the table header. */
+ header?: React.ComponentType;
+ /** Restrict this column to selected collections. Omit to support every collection. */
+ collections?: readonly string[] | ((collection: string) => boolean);
+ /** Minimum numeric admin role required to see this column. */
+ minRole?: number;
+ /** Lower values render first among contributed columns. */
+ order?: number;
+ align?: "start" | "end";
+}
+
+export interface ResolvedContentListColumn {
+ pluginId: string;
+ extension: ContentListColumnExtension;
+}
+
+type ContentListColumnRegistry = Record<
+ string,
+ { contentListColumns?: readonly ContentListColumnExtension[] } | undefined
+>;
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function warn(pluginId: string, message: string): void {
+ console.warn(`[content-list-columns] Plugin "${pluginId}": ${message}`);
+}
+
+function isValidCollections(value: unknown): boolean {
+ return (
+ value === undefined ||
+ typeof value === "function" ||
+ (Array.isArray(value) && value.every((collection) => typeof collection === "string"))
+ );
+}
+
+function isValidColumn(value: unknown, pluginId: string): value is ContentListColumnExtension {
+ if (!isRecord(value)) {
+ warn(pluginId, "ignored a column that is not an object.");
+ return false;
+ }
+ if (typeof value.id !== "string" || value.id.trim() === "") {
+ warn(pluginId, "ignored a column without a non-empty id.");
+ return false;
+ }
+ if (typeof value.label !== "string" || value.label.trim() === "") {
+ warn(pluginId, `ignored column "${value.id}" because its label is invalid.`);
+ return false;
+ }
+ if (typeof value.cell !== "function") {
+ warn(pluginId, `ignored column "${value.id}" because its cell is invalid.`);
+ return false;
+ }
+ if (value.header !== undefined && typeof value.header !== "function") {
+ warn(pluginId, `ignored column "${value.id}" because its header is invalid.`);
+ return false;
+ }
+ if (!isValidCollections(value.collections)) {
+ warn(
+ pluginId,
+ `ignored column "${value.id}" because collections must be an array of strings or a predicate.`,
+ );
+ return false;
+ }
+ if (value.minRole !== undefined && !Number.isFinite(value.minRole)) {
+ warn(pluginId, `ignored column "${value.id}" because minRole must be finite.`);
+ return false;
+ }
+ if (value.order !== undefined && !Number.isFinite(value.order)) {
+ warn(pluginId, `ignored column "${value.id}" because order must be finite.`);
+ return false;
+ }
+ if (value.align !== undefined && value.align !== "start" && value.align !== "end") {
+ warn(pluginId, `ignored column "${value.id}" because align is invalid.`);
+ return false;
+ }
+ return true;
+}
+
+function appliesToCollection(
+ pluginId: string,
+ column: ContentListColumnExtension,
+ collection: string,
+): boolean {
+ if (column.collections === undefined) return true;
+ if (typeof column.collections !== "function") {
+ return column.collections.includes(collection);
+ }
+
+ try {
+ return column.collections(collection);
+ } catch (error) {
+ console.error(
+ `Plugin "${pluginId}" failed while checking content-list column "${column.id}".`,
+ error,
+ );
+ return false;
+ }
+}
+
+/**
+ * Select valid trusted-plugin columns for one active content collection list.
+ * Invalid, disabled, unauthorized, and inapplicable contributions are omitted
+ * so a plugin cannot make the host list unusable.
+ */
+export function resolveContentListColumns(
+ pluginAdmins: ContentListColumnRegistry,
+ collection: string,
+ userRole: number,
+ pluginStates?: AdminManifest["plugins"],
+): ResolvedContentListColumn[] {
+ const resolved: ResolvedContentListColumn[] = [];
+ const seen = new Set();
+
+ for (const pluginId of Object.keys(pluginAdmins).toSorted()) {
+ const pluginState = pluginStates?.[pluginId];
+ if (pluginStates && (!pluginState || pluginState.enabled === false)) continue;
+
+ const columns: unknown = pluginAdmins[pluginId]?.contentListColumns;
+ if (columns === undefined) continue;
+ if (!Array.isArray(columns)) {
+ warn(pluginId, "ignored contentListColumns because it is not an array.");
+ continue;
+ }
+
+ for (const candidate of columns) {
+ if (!isValidColumn(candidate, pluginId)) continue;
+ const identity = `${pluginId}:${candidate.id}`;
+ if (seen.has(identity)) {
+ warn(pluginId, `ignored duplicate column id "${candidate.id}".`);
+ continue;
+ }
+ seen.add(identity);
+
+ if (!appliesToCollection(pluginId, candidate, collection)) continue;
+ if (candidate.minRole !== undefined && userRole < candidate.minRole) continue;
+ resolved.push({ pluginId, extension: candidate });
+ }
+ }
+
+ return resolved.toSorted(
+ (a, b) =>
+ (a.extension.order ?? 0) - (b.extension.order ?? 0) ||
+ a.pluginId.localeCompare(b.pluginId) ||
+ a.extension.id.localeCompare(b.extension.id),
+ );
+}
+
+interface ContentListColumnBoundaryProps {
+ pluginId: string;
+ columnId: string;
+ children: React.ReactNode;
+ fallback?: React.ReactNode;
+}
+
+interface ContentListColumnBoundaryState {
+ hasError: boolean;
+}
+
+/** Prevents one faulty trusted column from unmounting the content list. */
+export class ContentListColumnBoundary extends React.Component<
+ ContentListColumnBoundaryProps,
+ ContentListColumnBoundaryState
+> {
+ override state: ContentListColumnBoundaryState = { hasError: false };
+
+ static getDerivedStateFromError(): ContentListColumnBoundaryState {
+ return { hasError: true };
+ }
+
+ override componentDidCatch(error: Error, info: React.ErrorInfo): void {
+ console.error(
+ `Plugin "${this.props.pluginId}" failed while rendering content-list column "${this.props.columnId}".`,
+ error,
+ info,
+ );
+ }
+
+ override render(): React.ReactNode {
+ if (!this.state.hasError) return this.props.children;
+ if (this.props.fallback !== undefined) return this.props.fallback;
+
+ return (
+
+ —
+
+ Plugin column unavailable
+
+
+ );
+ }
+}
diff --git a/packages/admin/src/lib/plugin-context.tsx b/packages/admin/src/lib/plugin-context.tsx
index f07e807830..30654ad28d 100644
--- a/packages/admin/src/lib/plugin-context.tsx
+++ b/packages/admin/src/lib/plugin-context.tsx
@@ -9,11 +9,15 @@
import * as React from "react";
import { createContext, useContext } from "react";
+import type { ContentListColumnExtension } from "./content-list-columns.js";
+
/** Shape of a plugin's admin exports */
export interface PluginAdminModule {
widgets?: Record;
pages?: Record;
fields?: Record;
+ /** Columns contributed to active content collection lists by a trusted plugin. */
+ contentListColumns?: readonly ContentListColumnExtension[];
}
/** All plugin admin modules keyed by plugin ID */
diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx
index 789e6e2d12..c631a42d45 100644
--- a/packages/admin/src/router.tsx
+++ b/packages/admin/src/router.tsx
@@ -105,6 +105,7 @@ import {
unpublishContent,
discardDraft,
fetchRevision,
+ useCurrentUser,
type CreateCollectionInput,
type UpdateCollectionInput,
type CreateFieldInput,
@@ -331,6 +332,7 @@ function ContentListPage() {
queryKey: ["manifest"],
queryFn: fetchManifest,
});
+ const { data: currentUser } = useCurrentUser();
const i18n = manifest?.i18n;
@@ -572,6 +574,19 @@ function ContentListPage() {
return ;
}
+ const listColumns = (collectionConfig.listColumns ?? []).flatMap((slug) => {
+ const field = collectionConfig.fields[slug];
+ if (!field) return [];
+ return [
+ {
+ slug,
+ label: field.label ?? slug,
+ kind: field.kind,
+ options: Array.isArray(field.options) ? field.options : undefined,
+ },
+ ];
+ });
+
const handleLocaleChange = (locale: string) => {
// Update URL search params without full navigation
void navigate({
@@ -586,6 +601,7 @@ function ContentListPage() {
collection={collection}
collectionLabel={collectionConfig.label}
items={items}
+ listColumns={listColumns}
trashedItems={trashedData?.items || []}
isLoading={isLoading || isFetchingNextPage}
isTrashedLoading={isTrashedLoading}
@@ -614,6 +630,8 @@ function ContentListPage() {
onBulkPublish={(ids) => bulkPublishMutation.mutateAsync(ids).then((r) => r.failedIds)}
onBulkUnpublish={(ids) => bulkUnpublishMutation.mutateAsync(ids).then((r) => r.failedIds)}
onBulkDelete={(ids) => bulkDeleteMutation.mutateAsync(ids).then((r) => r.failedIds)}
+ pluginStates={manifest.plugins}
+ userRole={currentUser?.role ?? 0}
/>
);
}
diff --git a/packages/admin/tests/components/ContentList.test.tsx b/packages/admin/tests/components/ContentList.test.tsx
index 85133a941d..3a0f2a445a 100644
--- a/packages/admin/tests/components/ContentList.test.tsx
+++ b/packages/admin/tests/components/ContentList.test.tsx
@@ -3,6 +3,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { ContentList } from "../../src/components/ContentList";
import type { ContentItem, TrashedContentItem } from "../../src/lib/api";
+import type {
+ ContentListColumnCellContext,
+ ContentListColumnExtension,
+} from "../../src/lib/content-list-columns.js";
+import { PluginAdminProvider, type PluginAdmins } from "../../src/lib/plugin-context.js";
import { render } from "../utils/render.tsx";
const NO_RESULTS_PATTERN = /No results for/;
@@ -111,6 +116,98 @@ describe("ContentList", () => {
await expect.element(screen.getByText("Second")).toBeInTheDocument();
await expect.element(screen.getByText("Third")).toBeInTheDocument();
});
+
+ it("renders configured custom fields using their field metadata", async () => {
+ const items = [
+ makeItem({
+ data: {
+ title: "Support request",
+ ticket_number: "SUP-1042",
+ priority: "urgent",
+ labels: ["bug", "unknown"],
+ vip: true,
+ },
+ }),
+ ];
+ const screen = await render(
+ ,
+ );
+
+ await expect.element(screen.getByText("SUP-1042")).toBeInTheDocument();
+ await expect.element(screen.getByText("Urgent")).toBeInTheDocument();
+ await expect.element(screen.getByText("Bug, unknown")).toBeInTheDocument();
+ await expect.element(screen.getByText("Yes")).toBeInTheDocument();
+ });
+
+ it("formats numeric, datetime, boolean, and missing custom-field values", async () => {
+ const openedAt = "2025-01-02T00:00:00.000Z";
+ const screen = await render(
+ ,
+ );
+
+ await expect
+ .element(screen.getByText(new Intl.NumberFormat("en").format(12345.67)))
+ .toBeInTheDocument();
+ await expect
+ .element(screen.getByText(new Intl.DateTimeFormat("en").format(new Date(openedAt))))
+ .toBeInTheDocument();
+ await expect.element(screen.getByText("No", { exact: true })).toBeInTheDocument();
+ await expect.element(screen.getByText("Not set")).toBeInTheDocument();
+ });
+
+ it("does not expose unconfigured custom-field data", async () => {
+ const screen = await render(
+ ,
+ );
+
+ await expect.element(screen.getByText("urgent")).toBeInTheDocument();
+ expect(screen.getByText("Hidden").query()).toBeNull();
+ });
});
describe("empty states", () => {
@@ -666,5 +763,186 @@ describe("ContentList", () => {
// The header must not render as a button — it's just a label.
expect(screen.getByRole("button", { name: "Title" }).query()).toBeNull();
});
+
+ it("keeps configured custom columns display-only", async () => {
+ const screen = await render(
+ ,
+ );
+
+ await expect
+ .element(screen.getByRole("columnheader", { name: "Priority" }))
+ .toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Priority" }).query()).toBeNull();
+ });
+ });
+
+ describe("trusted plugin columns", () => {
+ function listWithColumns(
+ columns: readonly ContentListColumnExtension[],
+ props: Partial> = {},
+ ) {
+ const pluginAdmins: PluginAdmins = { seo: { contentListColumns: columns } };
+ return (
+
+
+
+ );
+ }
+
+ function renderWithColumns(
+ columns: readonly ContentListColumnExtension[],
+ props: Partial> = {},
+ ) {
+ return render(listWithColumns(columns, props));
+ }
+
+ it("renders contributed headers and cells inside the host table", async () => {
+ function ScoreCell({ item }: { item: ContentItem }) {
+ return {String(item.data.score)};
+ }
+
+ const screen = await renderWithColumns([
+ { id: "score", label: "SEO score", align: "end", cell: ScoreCell },
+ ]);
+
+ await expect.element(screen.getByRole("columnheader", { name: "SEO score" })).toBeVisible();
+ await expect.element(screen.getByText("82")).toBeVisible();
+ await expect.element(screen.getByText("Plugin Post")).toBeVisible();
+ });
+
+ it("passes the current visible page to contributed cells", async () => {
+ const pages: string[][] = [];
+ const items = Array.from({ length: 21 }, (_, index) =>
+ makeItem({
+ id: `item-${index}`,
+ data: { title: `Post ${index + 1}` },
+ }),
+ );
+ function PageCell({ item, visibleItems }: ContentListColumnCellContext) {
+ if (item.id === "item-0" || item.id === "item-20") {
+ pages.push(visibleItems.map((visibleItem) => visibleItem.id));
+ }
+ return null;
+ }
+
+ const screen = await renderWithColumns(
+ [{ id: "page", label: "Page context", cell: PageCell }],
+ { items },
+ );
+
+ expect(pages).toContainEqual(items.slice(0, 20).map((item) => item.id));
+ await screen.getByRole("button", { name: "Next page" }).click();
+ await expect.element(screen.getByText("Post 21")).toBeVisible();
+ expect(pages).toContainEqual(["item-20"]);
+ });
+
+ it("keeps configured and plugin columns aligned in rows and empty states", async () => {
+ function ScoreCell({ item }: { item: ContentItem }) {
+ return {String(item.data.score)};
+ }
+ const columns = [{ id: "score", label: "SEO score", align: "end", cell: ScoreCell }] as const;
+ const listColumns = [{ slug: "ticket_number", label: "Ticket", kind: "string" }];
+ const screen = await renderWithColumns(columns, {
+ items: [
+ makeItem({
+ data: { title: "Plugin Post", ticket_number: "SUP-1042", score: 82 },
+ }),
+ ],
+ listColumns,
+ });
+
+ await expect.element(screen.getByRole("columnheader", { name: "Ticket" })).toBeVisible();
+ await expect.element(screen.getByRole("columnheader", { name: "SEO score" })).toBeVisible();
+ await expect.element(screen.getByText("SUP-1042")).toBeVisible();
+ await expect.element(screen.getByText("82")).toBeVisible();
+
+ await screen.rerender(listWithColumns(columns, { items: [], isLoading: true, listColumns }));
+ await expect
+ .element(screen.getByRole("cell", { name: "Loading..." }))
+ .toHaveAttribute("colspan", "6");
+ });
+
+ it("isolates broken headers and cells while healthy columns and core rows remain", async () => {
+ const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ function Broken(): React.ReactNode {
+ throw new Error("render failed");
+ }
+ function HealthyCell() {
+ return Healthy value;
+ }
+
+ const screen = await renderWithColumns([
+ { id: "broken", label: "Broken fallback", header: Broken, cell: Broken },
+ { id: "healthy", label: "Healthy", cell: HealthyCell },
+ ]);
+
+ await expect.element(screen.getByText("Broken fallback")).toBeVisible();
+ await expect.element(screen.getByText("Plugin column unavailable")).toBeInTheDocument();
+ await expect.element(screen.getByText("Healthy value")).toBeVisible();
+ await expect.element(screen.getByText("Plugin Post")).toBeVisible();
+ expect(error).toHaveBeenCalled();
+ error.mockRestore();
+ });
+
+ it("retries a failed cell when the row locale changes", async () => {
+ const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ let shouldThrow = true;
+ function LocaleCell({ item }: { item: ContentItem }) {
+ if (shouldThrow) throw new Error("render failed");
+ return {item.locale};
+ }
+ const columns = [{ id: "locale", label: "Locale", cell: LocaleCell }] as const;
+ const screen = await renderWithColumns(columns, {
+ items: [makeItem({ locale: "en" })],
+ });
+
+ await expect.element(screen.getByText("Plugin column unavailable")).toBeInTheDocument();
+ shouldThrow = false;
+ await screen.rerender(listWithColumns(columns, { items: [makeItem({ locale: "fr" })] }));
+
+ await expect.element(screen.getByText("fr")).toBeVisible();
+ error.mockRestore();
+ });
+
+ it("extends loading and empty-state colspans", async () => {
+ function Cell(): React.ReactNode {
+ return null;
+ }
+ const screen = await renderWithColumns([{ id: "score", label: "Score", cell: Cell }], {
+ items: [],
+ isLoading: true,
+ });
+
+ await expect
+ .element(screen.getByRole("cell", { name: "Loading..." }))
+ .toHaveAttribute("colspan", "5");
+ });
+
+ it("does not render contributed columns in Trash", async () => {
+ function Cell() {
+ return Plugin value;
+ }
+ const screen = await renderWithColumns([{ id: "score", label: "Score", cell: Cell }], {
+ trashedItems: [makeTrashedItem({ data: { title: "Deleted" } })],
+ });
+
+ await screen.getByText("Trash").click();
+ await expect
+ .element(screen.getByRole("cell", { name: "Deleted", exact: true }))
+ .toBeVisible();
+ expect(screen.getByRole("columnheader", { name: "Score" }).query()).toBeNull();
+ expect(screen.getByText("Plugin value").query()).toBeNull();
+ });
});
});
diff --git a/packages/admin/tests/lib/content-list-columns.test.tsx b/packages/admin/tests/lib/content-list-columns.test.tsx
new file mode 100644
index 0000000000..b64f39932b
--- /dev/null
+++ b/packages/admin/tests/lib/content-list-columns.test.tsx
@@ -0,0 +1,96 @@
+import * as React from "react";
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ resolveContentListColumns,
+ type ContentListColumnExtension,
+} from "../../src/lib/content-list-columns.js";
+
+function Cell(): React.ReactNode {
+ return null;
+}
+
+function column(
+ id: string,
+ overrides: Partial = {},
+): ContentListColumnExtension {
+ return { id, label: id, cell: Cell, ...overrides };
+}
+
+describe("resolveContentListColumns", () => {
+ it("filters by manifest, collection, and role and orders deterministically", () => {
+ const plugins = {
+ zeta: { contentListColumns: [column("same", { order: 1 })] },
+ alpha: {
+ contentListColumns: [
+ column("second", { order: 2 }),
+ column("same", { order: 1 }),
+ column("pages", { collections: ["pages"] }),
+ column("admin", { minRole: 100 }),
+ ],
+ },
+ disabled: { contentListColumns: [column("disabled")] },
+ stale: { contentListColumns: [column("stale")] },
+ };
+
+ const result = resolveContentListColumns(plugins, "posts", 10, {
+ alpha: { enabled: true },
+ zeta: { enabled: true },
+ disabled: { enabled: false },
+ });
+
+ expect(result.map(({ pluginId, extension }) => `${pluginId}:${extension.id}`)).toEqual([
+ "alpha:same",
+ "zeta:same",
+ "alpha:second",
+ ]);
+ });
+
+ it("ignores malformed exports and duplicate ids within one plugin", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
+ const plugins = {
+ alpha: {
+ contentListColumns: [
+ column("score"),
+ column("score"),
+ ["array-is-not-a-column"],
+ { id: "broken", label: "Broken" },
+ ],
+ },
+ beta: { contentListColumns: "not-an-array" },
+ };
+
+ const result = resolveContentListColumns(
+ plugins as unknown as Parameters[0],
+ "posts",
+ 0,
+ );
+
+ expect(result).toHaveLength(1);
+ expect(result[0]?.extension.id).toBe("score");
+ expect(warn).toHaveBeenCalled();
+ warn.mockRestore();
+ });
+
+ it("contains collection predicate failures", () => {
+ const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ const plugins = {
+ alpha: {
+ contentListColumns: [
+ column("broken", {
+ collections: () => {
+ throw new Error("predicate failed");
+ },
+ }),
+ column("healthy"),
+ ],
+ },
+ };
+
+ const result = resolveContentListColumns(plugins, "posts", 0);
+
+ expect(result.map(({ extension }) => extension.id)).toEqual(["healthy"]);
+ expect(error).toHaveBeenCalled();
+ error.mockRestore();
+ });
+});
diff --git a/packages/core/src/api/schemas/schema.ts b/packages/core/src/api/schemas/schema.ts
index f2922289e8..de085f4a49 100644
--- a/packages/core/src/api/schemas/schema.ts
+++ b/packages/core/src/api/schemas/schema.ts
@@ -10,6 +10,12 @@ const collectionSupportValues = z.enum(["drafts", "revisions", "preview", "sched
const collectionSourcePattern = /^(template:.+|import:.+|manual|discovered|seed)$/;
+const collectionAdminConfig = z.object({
+ listColumns: z
+ .array(z.string().min(1).max(63).regex(slugPattern, "Invalid field slug format"))
+ .optional(),
+});
+
const fieldTypeValues = z.enum([
"string",
"text",
@@ -82,6 +88,7 @@ export const createCollectionBody = z
labelSingular: z.string().optional(),
description: z.string().optional(),
icon: z.string().optional(),
+ admin: collectionAdminConfig.optional(),
supports: z.array(collectionSupportValues).optional(),
source: z.string().regex(collectionSourcePattern).optional(),
urlPattern: z.string().optional(),
@@ -95,6 +102,7 @@ export const updateCollectionBody = z
labelSingular: z.string().optional(),
description: z.string().optional(),
icon: z.string().optional(),
+ admin: collectionAdminConfig.optional(),
supports: z.array(collectionSupportValues).optional(),
urlPattern: z.string().nullish(),
hasSeo: z.boolean().optional(),
@@ -175,6 +183,7 @@ export const collectionSchema = z
labelSingular: z.string().nullable(),
description: z.string().nullable(),
icon: z.string().nullable(),
+ admin: collectionAdminConfig.optional(),
supports: z.array(z.string()),
source: z.string().nullable(),
urlPattern: z.string().nullable(),
diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts
index 3ed85d1e3c..cccb74f6a6 100644
--- a/packages/core/src/astro/types.ts
+++ b/packages/core/src/astro/types.ts
@@ -31,6 +31,8 @@ export interface ManifestCollection {
supports: string[];
hasSeo: boolean;
urlPattern?: string;
+ /** Valid custom field slugs to render in the admin content list. */
+ listColumns?: string[];
fields: Record<
string,
{
diff --git a/packages/core/src/cli/commands/export-seed.ts b/packages/core/src/cli/commands/export-seed.ts
index 707a8a122c..015e8751c6 100644
--- a/packages/core/src/cli/commands/export-seed.ts
+++ b/packages/core/src/cli/commands/export-seed.ts
@@ -314,6 +314,7 @@ async function exportCollections(db: Kysely): Promise 0 ? collection.supports : undefined,
urlPattern: collection.urlPattern || undefined,
fields: fields.map(
diff --git a/packages/core/src/database/migrations/056_collection_admin_config.ts b/packages/core/src/database/migrations/056_collection_admin_config.ts
new file mode 100644
index 0000000000..22d51b26b4
--- /dev/null
+++ b/packages/core/src/database/migrations/056_collection_admin_config.ts
@@ -0,0 +1,16 @@
+import type { Kysely } from "kysely";
+
+import { columnExists } from "../dialect-helpers.js";
+
+/** Persist collection-level admin presentation options. */
+export async function up(db: Kysely): Promise {
+ if (!(await columnExists(db, "_emdash_collections", "admin_config"))) {
+ await db.schema.alterTable("_emdash_collections").addColumn("admin_config", "text").execute();
+ }
+}
+
+export async function down(db: Kysely): Promise {
+ if (await columnExists(db, "_emdash_collections", "admin_config")) {
+ await db.schema.alterTable("_emdash_collections").dropColumn("admin_config").execute();
+ }
+}
diff --git a/packages/core/src/database/migrations/runner.ts b/packages/core/src/database/migrations/runner.ts
index 33b8eecfde..006d9459f4 100644
--- a/packages/core/src/database/migrations/runner.ts
+++ b/packages/core/src/database/migrations/runner.ts
@@ -58,6 +58,7 @@ import * as m052 from "./052_media_usage_read_index.js";
import * as m053 from "./053_plugin_mcp_tools.js";
import * as m054 from "./054_media_upload_attempts.js";
import * as m055 from "./055_content_translation_group_locale_index.js";
+import * as m056 from "./056_collection_admin_config.js";
const MIGRATIONS: Readonly> = Object.freeze({
"001_initial": m001,
@@ -114,6 +115,7 @@ const MIGRATIONS: Readonly> = Object.freeze({
"053_plugin_mcp_tools": m053,
"054_media_upload_attempts": m054,
"055_content_translation_group_locale_index": m055,
+ "056_collection_admin_config": m056,
});
/** 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 b1c54418d2..3c5e6e4b93 100644
--- a/packages/core/src/database/types.ts
+++ b/packages/core/src/database/types.ts
@@ -300,6 +300,7 @@ export interface CollectionTable {
label_singular: string | null;
description: string | null;
icon: string | null;
+ admin_config: Generated; // JSON: { listColumns?: string[] }
supports: string | null; // JSON array
source: string | null;
search_config: string | null; // JSON: SearchConfig
diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts
index be159cd179..2753a6cb14 100644
--- a/packages/core/src/emdash-runtime.ts
+++ b/packages/core/src/emdash-runtime.ts
@@ -231,6 +231,17 @@ const FIELD_TYPE_TO_KIND: Record = {
const DRAFT_ONLY_UPDATE_KEYS = new Set(["data", "slug", "locale", "skipRevision"]);
const MAX_DRAFT_STAGE_ATTEMPTS = 32;
+const LIST_COLUMN_FIELD_TYPES: ReadonlySet = new Set([
+ "string",
+ "number",
+ "integer",
+ "boolean",
+ "datetime",
+ "select",
+ "multiSelect",
+]);
+const MAX_LIST_COLUMNS = 4;
+
/**
* Sandboxed plugin entry from virtual module
*/
@@ -2413,12 +2424,34 @@ export class EmDashRuntime {
fields[field.slug] = entry;
}
+ const configuredListColumns = collection.admin?.listColumns ?? [];
+ const fieldTypes = new Map(collection.fields.map((field) => [field.slug, field.type]));
+ const listColumns: string[] = [];
+ for (const slug of configuredListColumns) {
+ if (listColumns.includes(slug)) continue;
+ const fieldType = fieldTypes.get(slug);
+ if (!fieldType || !LIST_COLUMN_FIELD_TYPES.has(fieldType)) {
+ console.warn(
+ `EmDash: Ignoring unsupported or unknown list column "${slug}" in collection "${collection.slug}".`,
+ );
+ continue;
+ }
+ if (listColumns.length >= MAX_LIST_COLUMNS) {
+ console.warn(
+ `EmDash: Collection "${collection.slug}" declares more than ${MAX_LIST_COLUMNS} list columns; extra columns are ignored.`,
+ );
+ break;
+ }
+ listColumns.push(slug);
+ }
+
manifestCollections[collection.slug] = {
label: collection.label,
labelSingular: collection.labelSingular || collection.label,
supports: collection.supports || [],
hasSeo: collection.hasSeo,
urlPattern: collection.urlPattern,
+ listColumns: listColumns.length > 0 ? listColumns : undefined,
fields,
};
}
diff --git a/packages/core/src/schema/registry.ts b/packages/core/src/schema/registry.ts
index b53a497f6a..91ba76ac19 100644
--- a/packages/core/src/schema/registry.ts
+++ b/packages/core/src/schema/registry.ts
@@ -14,6 +14,7 @@ import { FTSManager } from "../search/fts-manager.js";
import { chunks, SQL_BATCH_SIZE } from "../utils/chunks.js";
import {
type Collection,
+ type CollectionAdminConfig,
type CollectionSource,
type CollectionSupport,
type ColumnType,
@@ -92,6 +93,22 @@ function parseSupports(raw: string | null | undefined): CollectionSupport[] {
return parsed.filter(isCollectionSupport);
}
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function parseCollectionAdmin(raw: string | null | undefined): CollectionAdminConfig | undefined {
+ if (!raw) return undefined;
+ const parsed: unknown = JSON.parse(raw);
+ if (!isRecord(parsed)) return undefined;
+ const listColumns = parsed.listColumns;
+ return {
+ listColumns: Array.isArray(listColumns)
+ ? listColumns.filter((value): value is string => typeof value === "string")
+ : undefined,
+ };
+}
+
/**
* Error thrown when a schema operation fails
*/
@@ -253,6 +270,7 @@ export class SchemaRegistry {
label_singular: input.labelSingular ?? null,
description: input.description ?? null,
icon: input.icon ?? null,
+ admin_config: input.admin ? JSON.stringify(input.admin) : null,
supports: JSON.stringify(supports),
source: input.source ?? "manual",
has_seo: hasSeo ? 1 : 0,
@@ -351,6 +369,7 @@ export class SchemaRegistry {
label_singular: input.labelSingular ?? null,
description: input.description ?? null,
icon: input.icon ?? null,
+ admin_config: input.admin ? JSON.stringify(input.admin) : null,
supports: JSON.stringify(supports),
source: "seed",
has_seo: hasSeo ? 1 : 0,
@@ -408,6 +427,12 @@ export class SchemaRegistry {
label_singular: input.labelSingular ?? existing.labelSingular ?? null,
description: input.description ?? existing.description ?? null,
icon: input.icon ?? existing.icon ?? null,
+ admin_config:
+ input.admin !== undefined
+ ? JSON.stringify(input.admin)
+ : existing.admin
+ ? JSON.stringify(existing.admin)
+ : null,
supports: input.supports
? JSON.stringify(input.supports)
: JSON.stringify(existing.supports),
@@ -1238,6 +1263,7 @@ export class SchemaRegistry {
labelSingular: row.label_singular ?? undefined,
description: row.description ?? undefined,
icon: row.icon ?? undefined,
+ admin: parseCollectionAdmin(row.admin_config),
supports: parseSupports(row.supports),
source: row.source && isCollectionSource(row.source) ? row.source : undefined,
hasSeo: row.has_seo === 1,
diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts
index 6d5055e981..94ac9091a1 100644
--- a/packages/core/src/schema/types.ts
+++ b/packages/core/src/schema/types.ts
@@ -155,6 +155,12 @@ export interface FieldWidgetOptions {
[key: string]: unknown;
}
+/** Collection-level admin presentation options. */
+export interface CollectionAdminConfig {
+ /** Custom field slugs to show in the content list. */
+ listColumns?: string[];
+}
+
/**
* A collection definition
*/
@@ -165,6 +171,7 @@ export interface Collection {
labelSingular?: string;
description?: string;
icon?: string;
+ admin?: CollectionAdminConfig;
supports: CollectionSupport[];
source?: CollectionSource;
/** Whether this collection has SEO metadata fields enabled */
@@ -215,6 +222,7 @@ export interface CreateCollectionInput {
labelSingular?: string;
description?: string;
icon?: string;
+ admin?: CollectionAdminConfig;
supports?: CollectionSupport[];
source?: CollectionSource;
urlPattern?: string;
@@ -230,6 +238,7 @@ export interface UpdateCollectionInput {
labelSingular?: string;
description?: string;
icon?: string;
+ admin?: CollectionAdminConfig;
supports?: CollectionSupport[];
urlPattern?: string;
hasSeo?: boolean;
diff --git a/packages/core/src/seed/apply.ts b/packages/core/src/seed/apply.ts
index efe5ed0060..fb6ca045c9 100644
--- a/packages/core/src/seed/apply.ts
+++ b/packages/core/src/seed/apply.ts
@@ -177,6 +177,7 @@ export async function applySeed(
labelSingular: collection.labelSingular,
description: collection.description,
icon: collection.icon,
+ admin: collection.admin,
supports: collection.supports || [],
urlPattern: collection.urlPattern,
commentsEnabled: collection.commentsEnabled,
@@ -245,6 +246,7 @@ export async function applySeed(
labelSingular: collection.labelSingular,
description: collection.description,
icon: collection.icon,
+ admin: collection.admin,
supports: collection.supports || [],
urlPattern: collection.urlPattern,
commentsEnabled: collection.commentsEnabled,
diff --git a/packages/core/src/seed/types.ts b/packages/core/src/seed/types.ts
index a106bfcd51..5d7e97f140 100644
--- a/packages/core/src/seed/types.ts
+++ b/packages/core/src/seed/types.ts
@@ -5,7 +5,7 @@
* collections, fields, menus, settings, taxonomies, redirects, widget areas, and optional sample content.
*/
-import type { FieldType } from "../schema/types.js";
+import type { CollectionAdminConfig, FieldType } from "../schema/types.js";
import type { SiteSettings } from "../settings/types.js";
import type { Storage } from "../storage/types.js";
@@ -72,6 +72,7 @@ export interface SeedCollection {
labelSingular?: string;
description?: string;
icon?: string;
+ admin?: CollectionAdminConfig;
supports?: ("drafts" | "revisions" | "preview" | "scheduling" | "search" | "seo")[];
urlPattern?: string;
/** Enable comments on this collection */
diff --git a/packages/core/src/seed/validate.ts b/packages/core/src/seed/validate.ts
index d4396d6c14..55ef5d4a18 100644
--- a/packages/core/src/seed/validate.ts
+++ b/packages/core/src/seed/validate.ts
@@ -108,6 +108,35 @@ export function validateSeed(data: unknown): ValidationResult {
errors.push(`${prefix}: label is required`);
}
+ const declaredFieldSlugs = new Set(
+ Array.isArray(collection.fields)
+ ? collection.fields.flatMap((field) =>
+ isRecord(field) && typeof field.slug === "string" ? [field.slug] : [],
+ )
+ : [],
+ );
+
+ if (collection.admin !== undefined) {
+ if (!isRecord(collection.admin)) {
+ errors.push(`${prefix}.admin: must be an object`);
+ } else if (collection.admin.listColumns !== undefined) {
+ if (!Array.isArray(collection.admin.listColumns)) {
+ errors.push(`${prefix}.admin.listColumns: must be an array`);
+ } else {
+ for (let j = 0; j < collection.admin.listColumns.length; j++) {
+ const slug = collection.admin.listColumns[j];
+ if (typeof slug !== "string" || !COLLECTION_FIELD_SLUG_PATTERN.test(slug)) {
+ errors.push(`${prefix}.admin.listColumns[${j}]: must be a valid field slug`);
+ } else if (!declaredFieldSlugs.has(slug)) {
+ errors.push(
+ `${prefix}.admin.listColumns[${j}]: references unknown field "${slug}"`,
+ );
+ }
+ }
+ }
+ }
+ }
+
// Validate fields
if (!Array.isArray(collection.fields)) {
errors.push(`${prefix}.fields: must be an array`);
diff --git a/packages/core/src/virtual-modules.d.ts b/packages/core/src/virtual-modules.d.ts
index 9ce6519842..9ae95f92cc 100644
--- a/packages/core/src/virtual-modules.d.ts
+++ b/packages/core/src/virtual-modules.d.ts
@@ -189,6 +189,7 @@ declare module "virtual:emdash/admin-registry" {
* - pages: Record
* - widgets: Record
* - fields: Record (field widget renderers)
+ * - contentListColumns: trusted-plugin content list column definitions
*/
export const pluginAdmins: Record<
string,
@@ -196,6 +197,7 @@ declare module "virtual:emdash/admin-registry" {
pages?: Record;
widgets?: Record;
fields?: Record;
+ contentListColumns?: readonly unknown[];
}
>;
}
diff --git a/packages/core/tests/database/migrations.test.ts b/packages/core/tests/database/migrations.test.ts
index 3afbfed073..892aa262f5 100644
--- a/packages/core/tests/database/migrations.test.ts
+++ b/packages/core/tests/database/migrations.test.ts
@@ -136,6 +136,7 @@ describe("Database Migrations", () => {
expect(columns).toContain("label_singular");
expect(columns).toContain("description");
expect(columns).toContain("icon");
+ expect(columns).toContain("admin_config");
expect(columns).toContain("supports");
expect(columns).toContain("source");
expect(columns).toContain("created_at");
diff --git a/packages/core/tests/integration/database/migrations.test.ts b/packages/core/tests/integration/database/migrations.test.ts
index df9af0f17a..de6458f4ce 100644
--- a/packages/core/tests/integration/database/migrations.test.ts
+++ b/packages/core/tests/integration/database/migrations.test.ts
@@ -142,6 +142,7 @@ describe("Database Migrations (Integration)", () => {
"053_plugin_mcp_tools",
"054_media_upload_attempts",
"055_content_translation_group_locale_index",
+ "056_collection_admin_config",
];
await db.deleteFrom("_emdash_migrations").where("name", "in", trailing).execute();
diff --git a/packages/core/tests/unit/cli/seed-commands.test.ts b/packages/core/tests/unit/cli/seed-commands.test.ts
index 5ac70d9eab..4fca3708eb 100644
--- a/packages/core/tests/unit/cli/seed-commands.test.ts
+++ b/packages/core/tests/unit/cli/seed-commands.test.ts
@@ -8,6 +8,7 @@ import { join } from "node:path";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { exportSeed } from "../../../src/cli/commands/export-seed.js";
import { createDatabase } from "../../../src/database/connection.js";
import { runMigrations } from "../../../src/database/migrations/runner.js";
import { applySeed } from "../../../src/seed/apply.js";
@@ -213,6 +214,39 @@ describe("CLI Seed Commands", () => {
});
describe("export-seed output", () => {
+ it("preserves collection list columns through apply and export", async () => {
+ const dbPath = join(tempDir, "admin-config.db");
+ const db = createDatabase({ url: `file:${dbPath}` });
+
+ try {
+ await runMigrations(db);
+ await applySeed(db, {
+ version: "1",
+ collections: [
+ {
+ slug: "tickets",
+ label: "Tickets",
+ admin: { listColumns: ["ticket_number", "priority"] },
+ fields: [
+ { slug: "ticket_number", label: "Ticket number", type: "string" },
+ { slug: "priority", label: "Priority", type: "select" },
+ ],
+ },
+ ],
+ });
+
+ const exported = await exportSeed(db);
+ const tickets = exported.collections?.find((collection) => collection.slug === "tickets");
+
+ expect(tickets?.admin).toEqual({
+ listColumns: ["ticket_number", "priority"],
+ });
+ expect(validateSeed(exported)).toMatchObject({ valid: true, errors: [] });
+ } finally {
+ await db.destroy();
+ }
+ });
+
it("should produce valid seed from exported data", async () => {
const dbPath = join(tempDir, "test.db");
const db = createDatabase({ url: `file:${dbPath}` });
diff --git a/packages/core/tests/unit/runtime/manifest-build.test.ts b/packages/core/tests/unit/runtime/manifest-build.test.ts
index ee9e379005..17284d83f8 100644
--- a/packages/core/tests/unit/runtime/manifest-build.test.ts
+++ b/packages/core/tests/unit/runtime/manifest-build.test.ts
@@ -15,7 +15,7 @@
*/
import type { Kysely } from "kysely";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { EmDashConfig } from "../../../src/astro/integration/runtime.js";
import type { Database } from "../../../src/database/types.js";
@@ -72,6 +72,7 @@ describe("EmDashRuntime.getManifest()", () => {
});
afterEach(async () => {
+ vi.restoreAllMocks();
await teardownTestDatabase(db);
});
@@ -158,4 +159,66 @@ describe("EmDashRuntime.getManifest()", () => {
expect(manifest.collections[`coll_${i}`]?.fields.title?.kind).toBe("string");
}
});
+
+ it("publishes only supported, existing list columns and caps them at four", async () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
+ const registry = new SchemaRegistry(db);
+ await registry.createCollection({
+ slug: "tickets",
+ label: "Tickets",
+ admin: {
+ listColumns: [
+ "ticket_number",
+ "ticket_number",
+ "details",
+ "missing",
+ "priority",
+ "urgent",
+ "queue",
+ "opened_at",
+ ],
+ },
+ });
+ await registry.createField("tickets", {
+ slug: "ticket_number",
+ label: "Ticket number",
+ type: "string",
+ });
+ await registry.createField("tickets", {
+ slug: "details",
+ label: "Details",
+ type: "json",
+ });
+ await registry.createField("tickets", {
+ slug: "priority",
+ label: "Priority",
+ type: "select",
+ });
+ await registry.createField("tickets", {
+ slug: "urgent",
+ label: "Urgent",
+ type: "boolean",
+ });
+ await registry.createField("tickets", {
+ slug: "queue",
+ label: "Queue",
+ type: "string",
+ });
+ await registry.createField("tickets", {
+ slug: "opened_at",
+ label: "Opened",
+ type: "datetime",
+ });
+
+ const runtime = buildRuntime(db);
+ const manifest = await runtime.getManifest();
+
+ expect(manifest.collections.tickets?.listColumns).toEqual([
+ "ticket_number",
+ "priority",
+ "urgent",
+ "queue",
+ ]);
+ expect(warn).toHaveBeenCalledTimes(3);
+ });
});
diff --git a/packages/core/tests/unit/schema/registry.test.ts b/packages/core/tests/unit/schema/registry.test.ts
index 0991d0d9ce..0159b97425 100644
--- a/packages/core/tests/unit/schema/registry.test.ts
+++ b/packages/core/tests/unit/schema/registry.test.ts
@@ -128,6 +128,19 @@ describe("SchemaRegistry", () => {
expect(updated.supports).toEqual(["drafts"]);
});
+ it("persists collection admin list columns", async () => {
+ const created = await registry.createCollection({
+ slug: "tickets",
+ label: "Tickets",
+ admin: { listColumns: ["ticket_number", "priority"] },
+ });
+
+ expect(created.admin?.listColumns).toEqual(["ticket_number", "priority"]);
+
+ const updated = await registry.updateCollection("tickets", { label: "Support tickets" });
+ expect(updated.admin?.listColumns).toEqual(["ticket_number", "priority"]);
+ });
+
it("should throw when updating non-existent collection", async () => {
await expect(registry.updateCollection("nonexistent", { label: "Test" })).rejects.toThrow(
SchemaError,
diff --git a/packages/core/tests/unit/seed/validate.test.ts b/packages/core/tests/unit/seed/validate.test.ts
index bca4c0e019..c793879baa 100644
--- a/packages/core/tests/unit/seed/validate.test.ts
+++ b/packages/core/tests/unit/seed/validate.test.ts
@@ -197,6 +197,25 @@ describe("validateSeed", () => {
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
+
+ it("should reject list columns that do not reference collection fields", () => {
+ const result = validateSeed({
+ version: "1",
+ collections: [
+ {
+ slug: "posts",
+ label: "Posts",
+ admin: { listColumns: ["priority"] },
+ fields: [{ slug: "title", label: "Title", type: "string" }],
+ },
+ ],
+ });
+
+ expect(result.valid).toBe(false);
+ expect(result.errors).toContain(
+ 'collections[0].admin.listColumns[0]: references unknown field "priority"',
+ );
+ });
});
describe("taxonomy validation", () => {
|