@@ -543,6 +567,14 @@ export function ContentList({
onSortChange={onSortChange}
label={t`Date`}
/>
+ {extensionColumns.map((column) => (
+
+ ))}
{t`Actions`}
|
@@ -598,6 +630,7 @@ export function ContentList({
selectable={bulkEnabled}
selected={selectedIds.has(item.id)}
onToggleSelect={toggleOne}
+ extensionColumns={extensionColumns}
/>
))
)}
@@ -914,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
@@ -967,6 +1031,7 @@ interface ContentListItemProps {
selectable?: boolean;
selected?: boolean;
onToggleSelect?: (id: string) => void;
+ extensionColumns?: ResolvedContentListColumn[];
}
function ContentListItem({
@@ -980,6 +1045,7 @@ function ContentListItem({
selectable,
selected,
onToggleSelect,
+ extensionColumns,
}: ContentListItemProps) {
const { t } = useLingui();
const title = getItemTitle(item);
@@ -1025,6 +1091,26 @@ function ContentListItem({
{date.toLocaleDateString()}
|
+ {extensionColumns?.map(({ pluginId, extension }) => {
+ const Cell = extension.cell;
+ return (
+
+
+ |
+
+ |
+ );
+ })}
{item.status === "published" && item.slug && (
diff --git a/packages/admin/src/index.ts b/packages/admin/src/index.ts
index 08b943ba2a..4219ba5944 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";
+
// Auth provider context (for accessing pluggable auth provider components)
export {
AuthProviderProvider,
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..1c784c27cf
--- /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";
+
+/** 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 897d813ca3..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;
@@ -627,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 6825e981c0..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/;
@@ -653,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/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[];
}
>;
}
From 8825b20ffdb8387f2017fcff4706a7555b3d0198 Mon Sep 17 00:00:00 2001
From: logelog <194732487+logelog@users.noreply.github.com>
Date: Wed, 22 Jul 2026 15:07:33 +0200
Subject: [PATCH 3/3] fix(admin): use ESM extensions for plugin columns
---
packages/admin/src/components/ContentList.tsx | 2 +-
packages/admin/src/index.ts | 2 +-
packages/admin/src/lib/content-list-columns.tsx | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/packages/admin/src/components/ContentList.tsx b/packages/admin/src/components/ContentList.tsx
index ec1bcad6ee..d73934547f 100644
--- a/packages/admin/src/components/ContentList.tsx
+++ b/packages/admin/src/components/ContentList.tsx
@@ -33,7 +33,7 @@ import type {
ContentDateField,
ContentItem,
TrashedContentItem,
-} from "../lib/api";
+} from "../lib/api.js";
import {
ContentListColumnBoundary,
resolveContentListColumns,
diff --git a/packages/admin/src/index.ts b/packages/admin/src/index.ts
index 4219ba5944..47e45da351 100644
--- a/packages/admin/src/index.ts
+++ b/packages/admin/src/index.ts
@@ -30,7 +30,7 @@ export type {
ContentListColumnHeaderContext,
ContentListColumnCellContext,
ContentListColumnExtension,
-} from "./lib/content-list-columns";
+} from "./lib/content-list-columns.js";
// Auth provider context (for accessing pluggable auth provider components)
export {
diff --git a/packages/admin/src/lib/content-list-columns.tsx b/packages/admin/src/lib/content-list-columns.tsx
index 1c784c27cf..77ed0f1162 100644
--- a/packages/admin/src/lib/content-list-columns.tsx
+++ b/packages/admin/src/lib/content-list-columns.tsx
@@ -1,7 +1,7 @@
import { Trans } from "@lingui/react/macro";
import * as React from "react";
-import type { AdminManifest, ContentItem } from "./api";
+import type { AdminManifest, ContentItem } from "./api.js";
/** Context passed to a trusted plugin's content-list column header. */
export interface ContentListColumnHeaderContext {
|