Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/collection-list-columns.md
Original file line number Diff line number Diff line change
@@ -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.
95 changes: 94 additions & 1 deletion packages/admin/src/components/ContentList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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;
Expand Down Expand Up @@ -157,6 +166,7 @@ export function ContentList({
collection,
collectionLabel,
items,
listColumns = [],
trashedItems = [],
isLoading,
isTrashedLoading,
Expand Down Expand Up @@ -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 (
<div className="space-y-4">
Expand Down Expand Up @@ -504,6 +514,15 @@ export function ContentList({
onSortChange={onSortChange}
label={t`Title`}
/>
{listColumns.map((column) => (
<th
key={column.slug}
scope="col"
className="px-4 py-3 text-start text-sm font-medium"
>
{column.label}
</th>
))}
<SortableTh
field="status"
sort={sort}
Expand Down Expand Up @@ -575,6 +594,7 @@ export function ContentList({
onDuplicate={onDuplicate}
showLocale={!!i18n}
urlPattern={urlPattern}
listColumns={listColumns}
selectable={bulkEnabled}
selected={selectedIds.has(item.id)}
onToggleSelect={toggleOne}
Expand Down Expand Up @@ -943,6 +963,7 @@ interface ContentListItemProps {
onDuplicate?: (id: string) => void;
showLocale?: boolean;
urlPattern?: string;
listColumns: ContentListColumn[];
selectable?: boolean;
selected?: boolean;
onToggleSelect?: (id: string) => void;
Expand All @@ -955,6 +976,7 @@ function ContentListItem({
onDuplicate,
showLocale,
urlPattern,
listColumns,
selectable,
selected,
onToggleSelect,
Expand Down Expand Up @@ -984,6 +1006,9 @@ function ContentListItem({
{title}
</Link>
</td>
{listColumns.map((column) => (
<ContentListCustomCell key={column.slug} column={column} value={item.data[column.slug]} />
))}
<td className="px-4 py-3">
<StatusBadge
status={item.status}
Expand Down Expand Up @@ -1071,6 +1096,74 @@ function ContentListItem({
);
}

function ContentListCustomCell({
column,
value,
}: {
column: ContentListColumn;
value: unknown;
}): React.ReactNode {
const text = formatListColumnValue(column, value);
return (
<td className="max-w-48 px-4 py-3 text-sm">
<span className="block truncate" title={text}>
{text}
</span>
</td>
);
}

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;
Expand Down
2 changes: 1 addition & 1 deletion packages/admin/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions packages/admin/src/lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export interface AdminManifest {
supports: string[];
hasSeo: boolean;
urlPattern?: string;
listColumns?: string[];
fields: Record<
string,
{
Expand Down
7 changes: 7 additions & 0 deletions packages/admin/src/lib/api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface SchemaCollection {
labelSingular?: string;
description?: string;
icon?: string;
admin?: CollectionAdminConfig;
supports: string[];
source?: string;
urlPattern?: string;
Expand All @@ -44,6 +45,10 @@ export interface SchemaCollection {
updatedAt: string;
}

export interface CollectionAdminConfig {
listColumns?: string[];
}

export interface SchemaField {
id: string;
collectionId: string;
Expand Down Expand Up @@ -80,6 +85,7 @@ export interface CreateCollectionInput {
labelSingular?: string;
description?: string;
icon?: string;
admin?: CollectionAdminConfig;
supports?: string[];
urlPattern?: string;
hasSeo?: boolean;
Expand All @@ -90,6 +96,7 @@ export interface UpdateCollectionInput {
labelSingular?: string;
description?: string;
icon?: string;
admin?: CollectionAdminConfig;
supports?: string[];
urlPattern?: string;
hasSeo?: boolean;
Expand Down
14 changes: 14 additions & 0 deletions packages/admin/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,19 @@ function ContentListPage() {
return <ErrorScreen error={error.message} />;
}

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({
Expand All @@ -585,6 +598,7 @@ function ContentListPage() {
collection={collection}
collectionLabel={collectionConfig.label}
items={items}
listColumns={listColumns}
trashedItems={trashedData?.items || []}
isLoading={isLoading || isFetchingNextPage}
isTrashedLoading={isTrashedLoading}
Expand Down
41 changes: 41 additions & 0 deletions packages/admin/tests/components/ContentList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ContentList
{...defaultProps}
items={items}
listColumns={[
{ slug: "ticket_number", label: "Ticket", kind: "string" },
{
slug: "priority",
label: "Priority",
kind: "select",
options: [{ value: "urgent", label: "Urgent" }],
},
{
slug: "labels",
label: "Labels",
kind: "multiSelect",
options: [{ value: "bug", label: "Bug" }],
},
{ slug: "vip", label: "VIP", kind: "boolean" },
]}
/>,
);

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", () => {
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/api/schemas/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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(),
Expand All @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/astro/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
{
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/cli/commands/export-seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ async function exportCollections(db: Kysely<Database>): Promise<SeedCollection[]
labelSingular: collection.labelSingular || undefined,
description: collection.description || undefined,
icon: collection.icon || undefined,
admin: collection.admin,
supports: collection.supports.length > 0 ? collection.supports : undefined,
urlPattern: collection.urlPattern || undefined,
fields: fields.map(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<unknown>): Promise<void> {
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<unknown>): Promise<void> {
if (await columnExists(db, "_emdash_collections", "admin_config")) {
await db.schema.alterTable("_emdash_collections").dropColumn("admin_config").execute();
}
}
2 changes: 2 additions & 0 deletions packages/core/src/database/migrations/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, Migration>> = Object.freeze({
"001_initial": m001,
Expand Down Expand Up @@ -110,6 +111,7 @@ const MIGRATIONS: Readonly<Record<string, Migration>> = 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. */
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/database/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ export interface CollectionTable {
label_singular: string | null;
description: string | null;
icon: string | null;
admin_config: Generated<string | null>; // JSON: { listColumns?: string[] }
supports: string | null; // JSON array
source: string | null;
search_config: string | null; // JSON: { enabled: boolean, weights: Record<string, number> }
Expand Down
Loading
Loading