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 67e87bc7e4..3d5bb4c86a 100644 --- a/packages/admin/src/components/ContentList.tsx +++ b/packages/admin/src/components/ContentList.tsx @@ -48,6 +48,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 +76,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; @@ -163,6 +172,7 @@ export function ContentList({ collection, collectionLabel, items, + listColumns = [], trashedItems = [], isLoading, isTrashedLoading, @@ -321,7 +331,7 @@ export function ContentList({ } })(); }; - const colSpan = (i18n ? 5 : 4) + (bulkEnabled ? 1 : 0); + const colSpan = (i18n ? 5 : 4) + listColumns.length + (bulkEnabled ? 1 : 0); return (
@@ -511,6 +521,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; @@ -967,6 +988,7 @@ function ContentListItem({ onDuplicate, showLocale, urlPattern, + listColumns, selectable, selected, onToggleSelect, @@ -996,6 +1018,9 @@ function ContentListItem({ {title} + {listColumns.map((column) => ( + + ))} + + {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/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 18e7a82aba..1dabe40430 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -572,6 +572,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 +599,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 85133a941d..eae67d7c39 100644 --- a/packages/admin/tests/components/ContentList.test.tsx +++ b/packages/admin/tests/components/ContentList.test.tsx @@ -111,6 +111,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 +758,22 @@ 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(); + }); }); }); 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/055_collection_admin_config.ts b/packages/core/src/database/migrations/055_collection_admin_config.ts new file mode 100644 index 0000000000..22d51b26b4 --- /dev/null +++ b/packages/core/src/database/migrations/055_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 5c1c7764ff..d9d1a510e4 100644 --- a/packages/core/src/database/migrations/runner.ts +++ b/packages/core/src/database/migrations/runner.ts @@ -57,6 +57,7 @@ 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_media_upload_attempts.js"; +import * as m055 from "./055_collection_admin_config.js"; const MIGRATIONS: Readonly> = Object.freeze({ "001_initial": m001, @@ -112,6 +113,7 @@ const MIGRATIONS: Readonly> = Object.freeze({ "052_media_usage_read_index": m052, "053_plugin_mcp_tools": m053, "054_media_upload_attempts": m054, + "055_collection_admin_config": m055, }); /** 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 0b00056fc9..a00e5d1524 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: { enabled: boolean, weights: Record } diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index c50f363211..911a5f1181 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -229,6 +229,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 */ @@ -2360,12 +2371,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 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/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 ce2e6de4c3..d87fe8ec1c 100644 --- a/packages/core/tests/integration/database/migrations.test.ts +++ b/packages/core/tests/integration/database/migrations.test.ts @@ -141,6 +141,7 @@ describe("Database Migrations (Integration)", () => { "052_media_usage_read_index", "053_plugin_mcp_tools", "054_media_upload_attempts", + "055_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", () => {