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", () => {