@@ -504,6 +538,15 @@ export function ContentList({
onSortChange={onSortChange}
label={t`Title`}
/>
+ {listColumns.map((column) => (
+
+ {column.label}
+ |
+ ))}
+ {extensionColumns.map((column) => (
+
+ ))}
{t`Actions`}
|
@@ -575,9 +626,11 @@ export function ContentList({
onDuplicate={onDuplicate}
showLocale={!!i18n}
urlPattern={urlPattern}
+ listColumns={listColumns}
selectable={bulkEnabled}
selected={selectedIds.has(item.id)}
onToggleSelect={toggleOne}
+ extensionColumns={extensionColumns}
/>
))
)}
@@ -894,6 +947,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
@@ -943,9 +1027,11 @@ interface ContentListItemProps {
onDuplicate?: (id: string) => void;
showLocale?: boolean;
urlPattern?: string;
+ listColumns: ContentListColumn[];
selectable?: boolean;
selected?: boolean;
onToggleSelect?: (id: string) => void;
+ extensionColumns?: ResolvedContentListColumn[];
}
function ContentListItem({
@@ -955,9 +1041,11 @@ function ContentListItem({
onDuplicate,
showLocale,
urlPattern,
+ listColumns,
selectable,
selected,
onToggleSelect,
+ extensionColumns,
}: ContentListItemProps) {
const { t } = useLingui();
const title = getItemTitle(item);
@@ -984,6 +1072,9 @@ function ContentListItem({
{title}
+ {listColumns.map((column) => (
+
+ ))}
{date.toLocaleDateString()}
|
+ {extensionColumns?.map(({ pluginId, extension }) => {
+ const Cell = extension.cell;
+ return (
+
+
+ |
+
+ |
+ );
+ })}
{item.status === "published" && item.slug && (
@@ -1071,6 +1182,74 @@ function ContentListItem({
);
}
+function ContentListCustomCell({
+ column,
+ value,
+}: {
+ column: ContentListColumn;
+ value: unknown;
+}): React.ReactNode {
+ const text = formatListColumnValue(column, value);
+ return (
+
+
+ {text}
+
+ |
+ );
+}
+
+function formatListColumnValue(column: ContentListColumn, value: unknown): string {
+ if (value === null || value === undefined || value === "") return "—";
+
+ const optionLabel = (optionValue: unknown): string => {
+ const text = scalarListColumnValue(optionValue);
+ if (text === undefined) return "—";
+ 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 ? values.map(optionLabel).join(", ") : "—";
+ }
+ case "boolean":
+ return value === true || value === 1 || value === "1" || value === "true" ? "✓" : "—";
+ case "datetime": {
+ const text = scalarListColumnValue(value);
+ if (text === undefined) return "—";
+ const date = new Date(text);
+ return Number.isNaN(date.getTime()) ? text : date.toLocaleDateString();
+ }
+ case "number":
+ case "string":
+ default:
+ return scalarListColumnValue(value) ?? "—";
+ }
+}
+
+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..4c90f305d9 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";
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..77ed0f1162
--- /dev/null
+++ b/packages/admin/src/lib/content-list-columns.tsx
@@ -0,0 +1,216 @@
+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;
+}
+
+/** 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 4b8fb9530f..165f0123f6 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,
@@ -330,6 +331,7 @@ function ContentListPage() {
queryKey: ["manifest"],
queryFn: fetchManifest,
});
+ const { data: currentUser } = useCurrentUser();
const i18n = manifest?.i18n;
@@ -571,6 +573,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({
@@ -585,6 +600,7 @@ function ContentListPage() {
collection={collection}
collectionLabel={collectionConfig.label}
items={items}
+ listColumns={listColumns}
trashedItems={trashedData?.items || []}
isLoading={isLoading || isFetchingNextPage}
isTrashedLoading={isTrashedLoading}
@@ -613,6 +629,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 67e569d589..b0965e3b07 100644
--- a/packages/admin/tests/components/ContentList.test.tsx
+++ b/packages/admin/tests/components/ContentList.test.tsx
@@ -3,6 +3,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { ContentList } from "../../src/components/ContentList";
import type { ContentItem, TrashedContentItem } from "../../src/lib/api";
+import type { 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 +113,47 @@ 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("✓")).toBeInTheDocument();
+ });
});
describe("empty states", () => {
@@ -612,4 +655,116 @@ describe("ContentList", () => {
expect(screen.getByRole("button", { name: "Title" }).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("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 042313ece0..409b3219c2 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/054_collection_admin_config.ts b/packages/core/src/database/migrations/054_collection_admin_config.ts
new file mode 100644
index 0000000000..22d51b26b4
--- /dev/null
+++ b/packages/core/src/database/migrations/054_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 a3ce955d45..61ca01097a 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_admin_config.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_admin_config": 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..8480f20217 100644
--- a/packages/core/src/database/types.ts
+++ b/packages/core/src/database/types.ts
@@ -291,6 +291,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: { enabled: boolean, weights: Record }
diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts
index 5e4a247255..de4114cfab 100644
--- a/packages/core/src/emdash-runtime.ts
+++ b/packages/core/src/emdash-runtime.ts
@@ -225,6 +225,17 @@ const FIELD_TYPE_TO_KIND: Record = {
repeater: "repeater",
};
+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
*/
@@ -2356,12 +2367,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 a2608f45a2..f68bd71bd6 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),
@@ -1224,6 +1249,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 6c1da34284..947c342efc 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..6401f11581 100644
--- a/packages/core/src/seed/validate.ts
+++ b/packages/core/src/seed/validate.ts
@@ -108,6 +108,23 @@ export function validateSeed(data: unknown): ValidationResult {
errors.push(`${prefix}: label is required`);
}
+ 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`);
+ }
+ }
+ }
+ }
+ }
+
// 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 6767b491d5..e4ef33181a 100644
--- a/packages/core/tests/integration/database/migrations.test.ts
+++ b/packages/core/tests/integration/database/migrations.test.ts
@@ -140,6 +140,7 @@ describe("Database Migrations (Integration)", () => {
"051_content_taxonomies_denorm",
"052_media_usage_read_index",
"053_plugin_mcp_tools",
+ "054_collection_admin_config",
];
await db.deleteFrom("_emdash_migrations").where("name", "in", trailing).execute();
diff --git a/packages/core/tests/unit/runtime/manifest-build.test.ts b/packages/core/tests/unit/runtime/manifest-build.test.ts
index ee9e379005..0afe3ee451 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,65 @@ 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",
+ "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,
|