Skip to content

Commit 2e1ec17

Browse files
committed
feat(admin): allow trusted plugins to add content list columns
1 parent 1e5c71b commit 2e1ec17

10 files changed

Lines changed: 582 additions & 5 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@emdash-cms/admin": minor
3+
"emdash": minor
4+
---
5+
6+
Allow trusted React plugins to add manifest-aware, role-filtered content-list columns without taking ownership of the host table, pagination, or row actions.

docs/src/content/docs/plugins/creating-native-plugins/react-admin.mdx

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description: Ship custom React components for plugin admin pages and dashboard w
55

66
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
77

8-
Native plugins can extend the admin panel with custom React pages and dashboard widgets — sandboxed plugins describe their UI as [Block Kit](/plugins/creating-plugins/block-kit/) instead, because shipping plugin JavaScript into the admin would break sandbox isolation.
8+
Native plugins can extend the admin panel with custom React pages, dashboard widgets, field widgets, and content-list columns — sandboxed plugins describe their UI as [Block Kit](/plugins/creating-plugins/block-kit/) instead, because shipping plugin JavaScript into the admin would break sandbox isolation.
99

1010
If your plugin only needs a settings form, the auto-generated `admin.settingsSchema` form (see [Your first native plugin](/plugins/creating-native-plugins/your-first-native-plugin/#settings-ui)) covers most cases without writing any React. Reach for custom components when you need richer UI than `settingsSchema` provides.
1111

@@ -224,15 +224,53 @@ export function SEOWidget() {
224224

225225
Widgets wrap automatically based on screen width.
226226

227+
## Content-list columns
228+
229+
Trusted React plugins can add read-only columns to active content collection lists. EmDash keeps ownership of the table, pagination, row actions, and loading and empty states; the plugin supplies only the header metadata and cell content.
230+
231+
```typescript title="src/admin.tsx"
232+
import type {
233+
ContentListColumnCellContext,
234+
ContentListColumnExtension,
235+
} from "@emdash-cms/admin";
236+
237+
function ScoreCell({ item }: ContentListColumnCellContext) {
238+
const score = item.data.seoScore;
239+
return typeof score === "number" ? <span>{score}</span> : <span>—</span>;
240+
}
241+
242+
export const contentListColumns = [
243+
{
244+
id: "score",
245+
label: "SEO score",
246+
collections: ["posts", "pages"],
247+
order: 10,
248+
align: "end",
249+
cell: ScoreCell,
250+
},
251+
] satisfies readonly ContentListColumnExtension[];
252+
```
253+
254+
Column ids only need to be unique within the plugin. Contributions are ordered by `order`, then plugin id and column id. A plugin can also provide:
255+
256+
- `header`: a custom header component; `label` remains the host-rendered fallback.
257+
- `collections`: an array or predicate restricting where the column appears.
258+
- `minRole`: a visibility filter for the admin UI. This is not authorization; plugin API routes must still enforce access.
259+
260+
Disabled or missing plugins are omitted. Invalid definitions, collection predicates that throw, and render failures are isolated so the host content list remains usable. Columns are not shown in Trash.
261+
262+
Content-list columns are display-only. Server-backed sorting and filtering require a separate host data-provider contract; a browser comparator would only sort the currently loaded cursor pages and is therefore not supported by this API.
263+
227264
## Export structure
228265

229-
The admin entry point exports two objects:
266+
The admin entry point exports each contribution directly:
230267

231268
```typescript title="src/admin.tsx"
232269
import { SettingsPage } from "./components/SettingsPage";
233270
import { ReportsPage } from "./components/ReportsPage";
234271
import { StatusWidget } from "./components/StatusWidget";
235272
import { OverviewWidget } from "./components/OverviewWidget";
273+
import { ScoreCell } from "./components/ScoreCell";
236274

237275
export const pages = {
238276
"/settings": SettingsPage,
@@ -243,10 +281,14 @@ export const widgets = {
243281
status: StatusWidget,
244282
overview: OverviewWidget,
245283
};
284+
285+
export const contentListColumns = [
286+
{ id: "score", label: "Score", collections: ["posts"], cell: ScoreCell },
287+
];
246288
```
247289

248290
<Aside type="caution">
249-
Page paths in `pages` should match the `path` values in `admin.pages`. A trailing slash is treated as equivalent, so `/settings` and `/settings/` resolve to the same page. Opening a plugin at its root resolves to the first registered page. Widget keys must match the `id` values in `admin.widgets`.
291+
Page paths in `pages` should match the `path` values in `admin.pages`. A trailing slash is treated as equivalent, so `/settings` and `/settings/` resolve to the same page. Opening a plugin at its root resolves to the first registered page. Widget keys must match the `id` values in `admin.widgets`. Content-list columns are discovered directly from the trusted admin entry point and do not need a separate descriptor entry.
250292
</Aside>
251293

252294
## Using admin components
@@ -344,6 +386,7 @@ When a plugin is disabled in the admin:
344386

345387
- Sidebar links are hidden.
346388
- Dashboard widgets are not rendered.
389+
- Content-list columns are not rendered.
347390
- Admin pages return 404.
348391
- Backend hooks still execute (for data safety).
349392

packages/admin/src/components/ContentList.tsx

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,20 @@ import {
2727
import { Link } from "@tanstack/react-router";
2828
import * as React from "react";
2929

30-
import type { ContentAuthor, ContentDateField, ContentItem, TrashedContentItem } from "../lib/api";
30+
import type {
31+
AdminManifest,
32+
ContentAuthor,
33+
ContentDateField,
34+
ContentItem,
35+
TrashedContentItem,
36+
} from "../lib/api";
37+
import {
38+
ContentListColumnBoundary,
39+
resolveContentListColumns,
40+
type ResolvedContentListColumn,
41+
} from "../lib/content-list-columns.js";
3142
import { useDebouncedValue } from "../lib/hooks.js";
43+
import { usePluginAdmins } from "../lib/plugin-context.js";
3244
import { contentUrl } from "../lib/url.js";
3345
import { cn } from "../lib/utils";
3446
import { CaretNext, CaretPrev } from "./ArrowIcons.js";
@@ -140,6 +152,10 @@ export interface ContentListProps {
140152
onBulkPublish?: BulkActionHandler;
141153
onBulkUnpublish?: BulkActionHandler;
142154
onBulkDelete?: BulkActionHandler;
155+
/** Current role used only for contributed-column visibility, not authorization. */
156+
userRole?: number;
157+
/** Manifest state used to omit disabled or stale trusted-plugin contributions. */
158+
pluginStates?: AdminManifest["plugins"];
143159
}
144160

145161
type BulkActionHandler = (ids: string[]) => Promise<string[]>;
@@ -197,8 +213,11 @@ export function ContentList({
197213
onBulkPublish,
198214
onBulkUnpublish,
199215
onBulkDelete,
216+
userRole = 0,
217+
pluginStates,
200218
}: ContentListProps) {
201219
const { t } = useLingui();
220+
const pluginAdmins = usePluginAdmins();
202221
const [activeTab, setActiveTab] = React.useState<ViewTab>("all");
203222
const [searchQuery, setSearchQuery] = React.useState("");
204223
const [page, setPage] = React.useState(0);
@@ -305,6 +324,10 @@ export function ContentList({
305324
return next;
306325
});
307326
const selectedCount = selectedIds.size;
327+
const extensionColumns = React.useMemo(
328+
() => resolveContentListColumns(pluginAdmins, collection, userRole, pluginStates),
329+
[collection, pluginAdmins, pluginStates, userRole],
330+
);
308331
const [bulkBusy, setBulkBusy] = React.useState(false);
309332
const runBulk = (fn?: BulkActionHandler) => {
310333
if (!fn || selectedCount === 0 || bulkBusy) return;
@@ -325,7 +348,8 @@ export function ContentList({
325348
}
326349
})();
327350
};
328-
const colSpan = (i18n ? 5 : 4) + listColumns.length + (bulkEnabled ? 1 : 0);
351+
const colSpan =
352+
(i18n ? 5 : 4) + listColumns.length + extensionColumns.length + (bulkEnabled ? 1 : 0);
329353

330354
return (
331355
<div className="space-y-4">
@@ -543,6 +567,14 @@ export function ContentList({
543567
onSortChange={onSortChange}
544568
label={t`Date`}
545569
/>
570+
{extensionColumns.map((column) => (
571+
<ExtensionColumnHeader
572+
key={`${column.pluginId}:${column.extension.id}`}
573+
column={column}
574+
collection={collection}
575+
locale={activeLocale}
576+
/>
577+
))}
546578
<th scope="col" className="px-4 py-3 text-end text-sm font-medium">
547579
{t`Actions`}
548580
</th>
@@ -598,6 +630,7 @@ export function ContentList({
598630
selectable={bulkEnabled}
599631
selected={selectedIds.has(item.id)}
600632
onToggleSelect={toggleOne}
633+
extensionColumns={extensionColumns}
601634
/>
602635
))
603636
)}
@@ -914,6 +947,37 @@ function SortableTh({ field, sort, onSortChange, label }: SortableThProps) {
914947
);
915948
}
916949

950+
interface ExtensionColumnHeaderProps {
951+
column: ResolvedContentListColumn;
952+
collection: string;
953+
locale?: string;
954+
}
955+
956+
function ExtensionColumnHeader({ column, collection, locale }: ExtensionColumnHeaderProps) {
957+
const { pluginId, extension } = column;
958+
const Header = extension.header;
959+
960+
return (
961+
<th
962+
scope="col"
963+
aria-label={extension.label}
964+
className={cn(
965+
"px-4 py-3 text-sm font-medium",
966+
extension.align === "end" ? "text-end" : "text-start",
967+
)}
968+
>
969+
<ContentListColumnBoundary
970+
key={`${collection}:${locale ?? ""}:${pluginId}:${extension.id}`}
971+
pluginId={pluginId}
972+
columnId={extension.id}
973+
fallback={extension.label}
974+
>
975+
{Header ? <Header collection={collection} locale={locale} /> : extension.label}
976+
</ContentListColumnBoundary>
977+
</th>
978+
);
979+
}
980+
917981
/**
918982
* Render the row-count line above pagination. The rules are:
919983
* - A search query always wins — say how many matches there are. In
@@ -967,6 +1031,7 @@ interface ContentListItemProps {
9671031
selectable?: boolean;
9681032
selected?: boolean;
9691033
onToggleSelect?: (id: string) => void;
1034+
extensionColumns?: ResolvedContentListColumn[];
9701035
}
9711036

9721037
function ContentListItem({
@@ -980,6 +1045,7 @@ function ContentListItem({
9801045
selectable,
9811046
selected,
9821047
onToggleSelect,
1048+
extensionColumns,
9831049
}: ContentListItemProps) {
9841050
const { t } = useLingui();
9851051
const title = getItemTitle(item);
@@ -1025,6 +1091,26 @@ function ContentListItem({
10251091
<td data-testid="content-updated" className="px-4 py-3 text-sm text-kumo-subtle">
10261092
{date.toLocaleDateString()}
10271093
</td>
1094+
{extensionColumns?.map(({ pluginId, extension }) => {
1095+
const Cell = extension.cell;
1096+
return (
1097+
<td
1098+
key={`${pluginId}:${extension.id}`}
1099+
className={cn(
1100+
"px-4 py-3 text-sm",
1101+
extension.align === "end" ? "text-end" : "text-start",
1102+
)}
1103+
>
1104+
<ContentListColumnBoundary
1105+
key={`${collection}:${item.locale}:${item.id}:${item.updatedAt}:${pluginId}:${extension.id}`}
1106+
pluginId={pluginId}
1107+
columnId={extension.id}
1108+
>
1109+
<Cell collection={collection} item={item} locale={item.locale} />
1110+
</ContentListColumnBoundary>
1111+
</td>
1112+
);
1113+
})}
10281114
<td className="px-4 py-3 text-end">
10291115
<div className="flex items-center justify-end space-x-1">
10301116
{item.status === "published" && item.slug && (

packages/admin/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ export {
2626
type PluginAdmins,
2727
} from "./lib/plugin-context";
2828

29+
export type {
30+
ContentListColumnHeaderContext,
31+
ContentListColumnCellContext,
32+
ContentListColumnExtension,
33+
} from "./lib/content-list-columns";
34+
2935
// Auth provider context (for accessing pluggable auth provider components)
3036
export {
3137
AuthProviderProvider,

0 commit comments

Comments
 (0)