diff --git a/.changeset/collection-list-columns.md b/.changeset/collection-list-columns.md new file mode 100644 index 0000000000..a9c058ef0e --- /dev/null +++ b/.changeset/collection-list-columns.md @@ -0,0 +1,6 @@ +--- +"emdash": minor +"@emdash-cms/admin": minor +--- + +Adds collection-configured custom field columns to admin content lists. Collection seeds and schema APIs can declare up to four supported fields through `admin.listColumns`; EmDash validates them when building the manifest and renders their stored values between the title and status columns. diff --git a/.changeset/trusted-content-list-columns.md b/.changeset/trusted-content-list-columns.md new file mode 100644 index 0000000000..630ad22249 --- /dev/null +++ b/.changeset/trusted-content-list-columns.md @@ -0,0 +1,6 @@ +--- +"@emdash-cms/admin": minor +"emdash": minor +--- + +Allow trusted React plugins to add manifest-aware, role-filtered content-list columns without taking ownership of the host table, pagination, or row actions. diff --git a/docs/src/content/docs/plugins/creating-native-plugins/react-admin.mdx b/docs/src/content/docs/plugins/creating-native-plugins/react-admin.mdx index a44c92c934..eb504d253b 100644 --- a/docs/src/content/docs/plugins/creating-native-plugins/react-admin.mdx +++ b/docs/src/content/docs/plugins/creating-native-plugins/react-admin.mdx @@ -5,7 +5,7 @@ description: Ship custom React components for plugin admin pages and dashboard w import { Aside, Tabs, TabItem } from "@astrojs/starlight/components"; -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. +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. 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. @@ -224,15 +224,53 @@ export function SEOWidget() { Widgets wrap automatically based on screen width. +## Content-list columns + +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. + +```typescript title="src/admin.tsx" +import type { + ContentListColumnCellContext, + ContentListColumnExtension, +} from "@emdash-cms/admin"; + +function ScoreCell({ item }: ContentListColumnCellContext) { + const score = item.data.seoScore; + return typeof score === "number" ? {score} : ; +} + +export const contentListColumns = [ + { + id: "score", + label: "SEO score", + collections: ["posts", "pages"], + order: 10, + align: "end", + cell: ScoreCell, + }, +] satisfies readonly ContentListColumnExtension[]; +``` + +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: + +- `header`: a custom header component; `label` remains the host-rendered fallback. +- `collections`: an array or predicate restricting where the column appears. +- `minRole`: a visibility filter for the admin UI. This is not authorization; plugin API routes must still enforce access. + +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. + +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. + ## Export structure -The admin entry point exports two objects: +The admin entry point exports each contribution directly: ```typescript title="src/admin.tsx" import { SettingsPage } from "./components/SettingsPage"; import { ReportsPage } from "./components/ReportsPage"; import { StatusWidget } from "./components/StatusWidget"; import { OverviewWidget } from "./components/OverviewWidget"; +import { ScoreCell } from "./components/ScoreCell"; export const pages = { "/settings": SettingsPage, @@ -243,10 +281,14 @@ export const widgets = { status: StatusWidget, overview: OverviewWidget, }; + +export const contentListColumns = [ + { id: "score", label: "Score", collections: ["posts"], cell: ScoreCell }, +]; ``` ## Using admin components @@ -344,6 +386,7 @@ When a plugin is disabled in the admin: - Sidebar links are hidden. - Dashboard widgets are not rendered. +- Content-list columns are not rendered. - Admin pages return 404. - Backend hooks still execute (for data safety). diff --git a/packages/admin/src/components/ContentList.tsx b/packages/admin/src/components/ContentList.tsx index 67e87bc7e4..1326613769 100644 --- a/packages/admin/src/components/ContentList.tsx +++ b/packages/admin/src/components/ContentList.tsx @@ -28,8 +28,20 @@ import { import { Link } from "@tanstack/react-router"; import * as React from "react"; -import type { ContentAuthor, ContentDateField, ContentItem, TrashedContentItem } from "../lib/api"; +import type { + AdminManifest, + ContentAuthor, + ContentDateField, + ContentItem, + TrashedContentItem, +} from "../lib/api.js"; +import { + ContentListColumnBoundary, + resolveContentListColumns, + type ResolvedContentListColumn, +} from "../lib/content-list-columns.js"; import { useDebouncedValue } from "../lib/hooks.js"; +import { usePluginAdmins } from "../lib/plugin-context.js"; import { contentUrl } from "../lib/url.js"; import { cn } from "../lib/utils"; import { CaretNext, CaretPrev } from "./ArrowIcons.js"; @@ -48,6 +60,13 @@ export interface ContentListSort { direction: "asc" | "desc"; } +export interface ContentListColumn { + slug: string; + label: string; + kind: string; + options?: Array<{ value: string; label: string }>; +} + /** Status filter values. `"all"` clears the status filter. */ export type ContentStatusFilter = "all" | "published" | "draft" | "scheduled" | "archived"; @@ -69,6 +88,8 @@ export interface ContentListProps { collection: string; collectionLabel: string; items: ContentItem[]; + /** Validated custom-field columns from the collection manifest. */ + listColumns?: ContentListColumn[]; trashedItems?: TrashedContentItem[]; isLoading?: boolean; isTrashedLoading?: boolean; @@ -137,6 +158,10 @@ export interface ContentListProps { onBulkPublish?: BulkActionHandler; onBulkUnpublish?: BulkActionHandler; onBulkDelete?: BulkActionHandler; + /** Current role used only for contributed-column visibility, not authorization. */ + userRole?: number; + /** Manifest state used to omit disabled or stale trusted-plugin contributions. */ + pluginStates?: AdminManifest["plugins"]; } type BulkActionHandler = (ids: string[]) => Promise; @@ -163,6 +188,7 @@ export function ContentList({ collection, collectionLabel, items, + listColumns = [], trashedItems = [], isLoading, isTrashedLoading, @@ -193,8 +219,11 @@ export function ContentList({ onBulkPublish, onBulkUnpublish, onBulkDelete, + userRole = 0, + pluginStates, }: ContentListProps) { const { t } = useLingui(); + const pluginAdmins = usePluginAdmins(); const [activeTab, setActiveTab] = React.useState("all"); const [searchQuery, setSearchQuery] = React.useState(""); const [page, setPage] = React.useState(0); @@ -301,6 +330,10 @@ export function ContentList({ return next; }); const selectedCount = selectedIds.size; + const extensionColumns = React.useMemo( + () => resolveContentListColumns(pluginAdmins, collection, userRole, pluginStates), + [collection, pluginAdmins, pluginStates, userRole], + ); const [bulkBusy, setBulkBusy] = React.useState(false); const runBulk = (fn?: BulkActionHandler) => { if (!fn || selectedCount === 0 || bulkBusy) return; @@ -321,7 +354,8 @@ export function ContentList({ } })(); }; - const colSpan = (i18n ? 5 : 4) + (bulkEnabled ? 1 : 0); + const colSpan = + (i18n ? 5 : 4) + listColumns.length + extensionColumns.length + (bulkEnabled ? 1 : 0); return (
@@ -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", () => {