From 1e5c71b2ade8de9fb88ba43144e36b6ad4bcb8b9 Mon Sep 17 00:00:00 2001 From: logelog <194732487+logelog@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:37:03 +0200 Subject: [PATCH 1/3] feat(admin): show configured fields in content lists --- .changeset/collection-list-columns.md | 6 ++ packages/admin/src/components/ContentList.tsx | 95 ++++++++++++++++++- packages/admin/src/components/index.ts | 2 +- packages/admin/src/lib/api/client.ts | 1 + packages/admin/src/lib/api/schema.ts | 7 ++ packages/admin/src/router.tsx | 14 +++ .../tests/components/ContentList.test.tsx | 41 ++++++++ packages/core/src/api/schemas/schema.ts | 9 ++ packages/core/src/astro/types.ts | 2 + packages/core/src/cli/commands/export-seed.ts | 1 + .../migrations/054_collection_admin_config.ts | 16 ++++ .../core/src/database/migrations/runner.ts | 2 + packages/core/src/database/types.ts | 1 + packages/core/src/emdash-runtime.ts | 33 +++++++ packages/core/src/schema/registry.ts | 26 +++++ packages/core/src/schema/types.ts | 9 ++ packages/core/src/seed/apply.ts | 2 + packages/core/src/seed/types.ts | 3 +- packages/core/src/seed/validate.ts | 17 ++++ .../core/tests/database/migrations.test.ts | 1 + .../integration/database/migrations.test.ts | 1 + .../tests/unit/runtime/manifest-build.test.ts | 64 ++++++++++++- .../core/tests/unit/schema/registry.test.ts | 13 +++ 23 files changed, 362 insertions(+), 4 deletions(-) create mode 100644 .changeset/collection-list-columns.md create mode 100644 packages/core/src/database/migrations/054_collection_admin_config.ts 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/packages/admin/src/components/ContentList.tsx b/packages/admin/src/components/ContentList.tsx index 5647a57398..4fa4c193f6 100644 --- a/packages/admin/src/components/ContentList.tsx +++ b/packages/admin/src/components/ContentList.tsx @@ -42,6 +42,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"; @@ -63,6 +70,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; @@ -157,6 +166,7 @@ export function ContentList({ collection, collectionLabel, items, + listColumns = [], trashedItems = [], isLoading, isTrashedLoading, @@ -315,7 +325,7 @@ export function ContentList({ } })(); }; - const colSpan = (i18n ? 5 : 4) + (bulkEnabled ? 1 : 0); + const colSpan = (i18n ? 5 : 4) + listColumns.length + (bulkEnabled ? 1 : 0); return (
@@ -504,6 +514,15 @@ export function ContentList({ onSortChange={onSortChange} label={t`Title`} /> + {listColumns.map((column) => ( + + {column.label} + + ))} void; showLocale?: boolean; urlPattern?: string; + listColumns: ContentListColumn[]; selectable?: boolean; selected?: boolean; onToggleSelect?: (id: string) => void; @@ -955,6 +976,7 @@ function ContentListItem({ onDuplicate, showLocale, urlPattern, + listColumns, selectable, selected, onToggleSelect, @@ -984,6 +1006,9 @@ function ContentListItem({ {title} + {listColumns.map((column) => ( + + ))} + + {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/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/router.tsx b/packages/admin/src/router.tsx index 4b8fb9530f..897d813ca3 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -571,6 +571,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 +598,7 @@ function ContentListPage() { collection={collection} collectionLabel={collectionConfig.label} items={items} + listColumns={listColumns} trashedItems={trashedData?.items || []} isLoading={isLoading || isFetchingNextPage} isTrashedLoading={isTrashedLoading} diff --git a/packages/admin/tests/components/ContentList.test.tsx b/packages/admin/tests/components/ContentList.test.tsx index 67e569d589..6825e981c0 100644 --- a/packages/admin/tests/components/ContentList.test.tsx +++ b/packages/admin/tests/components/ContentList.test.tsx @@ -111,6 +111,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", () => { 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/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, From 2e1ec17bb835dcfe1ce86c7b5043e52503776da8 Mon Sep 17 00:00:00 2001 From: logelog <194732487+logelog@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:36:44 +0200 Subject: [PATCH 2/3] feat(admin): allow trusted plugins to add content list columns --- .changeset/trusted-content-list-columns.md | 6 + .../creating-native-plugins/react-admin.mdx | 49 +++- packages/admin/src/components/ContentList.tsx | 90 +++++++- packages/admin/src/index.ts | 6 + .../admin/src/lib/content-list-columns.tsx | 216 ++++++++++++++++++ packages/admin/src/lib/plugin-context.tsx | 4 + packages/admin/src/router.tsx | 4 + .../tests/components/ContentList.test.tsx | 114 +++++++++ .../tests/lib/content-list-columns.test.tsx | 96 ++++++++ packages/core/src/virtual-modules.d.ts | 2 + 10 files changed, 582 insertions(+), 5 deletions(-) create mode 100644 .changeset/trusted-content-list-columns.md create mode 100644 packages/admin/src/lib/content-list-columns.tsx create mode 100644 packages/admin/tests/lib/content-list-columns.test.tsx 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 4fa4c193f6..ec1bcad6ee 100644 --- a/packages/admin/src/components/ContentList.tsx +++ b/packages/admin/src/components/ContentList.tsx @@ -27,8 +27,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"; +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"; @@ -140,6 +152,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; @@ -197,8 +213,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); @@ -305,6 +324,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; @@ -325,7 +348,8 @@ export function ContentList({ } })(); }; - const colSpan = (i18n ? 5 : 4) + listColumns.length + (bulkEnabled ? 1 : 0); + const colSpan = + (i18n ? 5 : 4) + listColumns.length + extensionColumns.length + (bulkEnabled ? 1 : 0); return (
@@ -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 {