Skip to content

Commit 1e5c71b

Browse files
committed
feat(admin): show configured fields in content lists
1 parent 53dbf22 commit 1e5c71b

23 files changed

Lines changed: 362 additions & 4 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"emdash": minor
3+
"@emdash-cms/admin": minor
4+
---
5+
6+
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.

packages/admin/src/components/ContentList.tsx

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ export interface ContentListSort {
4242
direction: "asc" | "desc";
4343
}
4444

45+
export interface ContentListColumn {
46+
slug: string;
47+
label: string;
48+
kind: string;
49+
options?: Array<{ value: string; label: string }>;
50+
}
51+
4552
/** Status filter values. `"all"` clears the status filter. */
4653
export type ContentStatusFilter = "all" | "published" | "draft" | "scheduled" | "archived";
4754

@@ -63,6 +70,8 @@ export interface ContentListProps {
6370
collection: string;
6471
collectionLabel: string;
6572
items: ContentItem[];
73+
/** Validated custom-field columns from the collection manifest. */
74+
listColumns?: ContentListColumn[];
6675
trashedItems?: TrashedContentItem[];
6776
isLoading?: boolean;
6877
isTrashedLoading?: boolean;
@@ -157,6 +166,7 @@ export function ContentList({
157166
collection,
158167
collectionLabel,
159168
items,
169+
listColumns = [],
160170
trashedItems = [],
161171
isLoading,
162172
isTrashedLoading,
@@ -315,7 +325,7 @@ export function ContentList({
315325
}
316326
})();
317327
};
318-
const colSpan = (i18n ? 5 : 4) + (bulkEnabled ? 1 : 0);
328+
const colSpan = (i18n ? 5 : 4) + listColumns.length + (bulkEnabled ? 1 : 0);
319329

320330
return (
321331
<div className="space-y-4">
@@ -504,6 +514,15 @@ export function ContentList({
504514
onSortChange={onSortChange}
505515
label={t`Title`}
506516
/>
517+
{listColumns.map((column) => (
518+
<th
519+
key={column.slug}
520+
scope="col"
521+
className="px-4 py-3 text-start text-sm font-medium"
522+
>
523+
{column.label}
524+
</th>
525+
))}
507526
<SortableTh
508527
field="status"
509528
sort={sort}
@@ -575,6 +594,7 @@ export function ContentList({
575594
onDuplicate={onDuplicate}
576595
showLocale={!!i18n}
577596
urlPattern={urlPattern}
597+
listColumns={listColumns}
578598
selectable={bulkEnabled}
579599
selected={selectedIds.has(item.id)}
580600
onToggleSelect={toggleOne}
@@ -943,6 +963,7 @@ interface ContentListItemProps {
943963
onDuplicate?: (id: string) => void;
944964
showLocale?: boolean;
945965
urlPattern?: string;
966+
listColumns: ContentListColumn[];
946967
selectable?: boolean;
947968
selected?: boolean;
948969
onToggleSelect?: (id: string) => void;
@@ -955,6 +976,7 @@ function ContentListItem({
955976
onDuplicate,
956977
showLocale,
957978
urlPattern,
979+
listColumns,
958980
selectable,
959981
selected,
960982
onToggleSelect,
@@ -984,6 +1006,9 @@ function ContentListItem({
9841006
{title}
9851007
</Link>
9861008
</td>
1009+
{listColumns.map((column) => (
1010+
<ContentListCustomCell key={column.slug} column={column} value={item.data[column.slug]} />
1011+
))}
9871012
<td className="px-4 py-3">
9881013
<StatusBadge
9891014
status={item.status}
@@ -1071,6 +1096,74 @@ function ContentListItem({
10711096
);
10721097
}
10731098

1099+
function ContentListCustomCell({
1100+
column,
1101+
value,
1102+
}: {
1103+
column: ContentListColumn;
1104+
value: unknown;
1105+
}): React.ReactNode {
1106+
const text = formatListColumnValue(column, value);
1107+
return (
1108+
<td className="max-w-48 px-4 py-3 text-sm">
1109+
<span className="block truncate" title={text}>
1110+
{text}
1111+
</span>
1112+
</td>
1113+
);
1114+
}
1115+
1116+
function formatListColumnValue(column: ContentListColumn, value: unknown): string {
1117+
if (value === null || value === undefined || value === "") return "—";
1118+
1119+
const optionLabel = (optionValue: unknown): string => {
1120+
const text = scalarListColumnValue(optionValue);
1121+
if (text === undefined) return "—";
1122+
return column.options?.find((option) => option.value === text)?.label ?? text;
1123+
};
1124+
1125+
switch (column.kind) {
1126+
case "select":
1127+
return optionLabel(value);
1128+
case "multiSelect": {
1129+
let values: unknown[];
1130+
if (Array.isArray(value)) {
1131+
values = value;
1132+
} else if (typeof value === "string") {
1133+
try {
1134+
const parsed: unknown = JSON.parse(value);
1135+
values = Array.isArray(parsed) ? parsed : [value];
1136+
} catch {
1137+
values = [value];
1138+
}
1139+
} else {
1140+
values = [value];
1141+
}
1142+
return values.length > 0 ? values.map(optionLabel).join(", ") : "—";
1143+
}
1144+
case "boolean":
1145+
return value === true || value === 1 || value === "1" || value === "true" ? "✓" : "—";
1146+
case "datetime": {
1147+
const text = scalarListColumnValue(value);
1148+
if (text === undefined) return "—";
1149+
const date = new Date(text);
1150+
return Number.isNaN(date.getTime()) ? text : date.toLocaleDateString();
1151+
}
1152+
case "number":
1153+
case "string":
1154+
default:
1155+
return scalarListColumnValue(value) ?? "—";
1156+
}
1157+
}
1158+
1159+
function scalarListColumnValue(value: unknown): string | undefined {
1160+
if (typeof value === "string") return value;
1161+
if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") {
1162+
return String(value);
1163+
}
1164+
return undefined;
1165+
}
1166+
10741167
interface TrashedListItemProps {
10751168
item: TrashedContentItem;
10761169
onRestore?: (id: string) => void;

packages/admin/src/components/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export { Header } from "./Header";
55

66
// Page components
77
export { Dashboard, type DashboardProps } from "./Dashboard";
8-
export { ContentList, type ContentListProps } from "./ContentList";
8+
export { ContentList, type ContentListColumn, type ContentListProps } from "./ContentList";
99
export { ContentEditor, type ContentEditorProps, type FieldDescriptor } from "./ContentEditor";
1010
export { MediaLibrary, type MediaLibraryProps } from "./MediaLibrary";
1111
export { MediaPickerModal, type MediaPickerModalProps } from "./MediaPickerModal";

packages/admin/src/lib/api/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export interface AdminManifest {
9393
supports: string[];
9494
hasSeo: boolean;
9595
urlPattern?: string;
96+
listColumns?: string[];
9697
fields: Record<
9798
string,
9899
{

packages/admin/src/lib/api/schema.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export interface SchemaCollection {
3232
labelSingular?: string;
3333
description?: string;
3434
icon?: string;
35+
admin?: CollectionAdminConfig;
3536
supports: string[];
3637
source?: string;
3738
urlPattern?: string;
@@ -44,6 +45,10 @@ export interface SchemaCollection {
4445
updatedAt: string;
4546
}
4647

48+
export interface CollectionAdminConfig {
49+
listColumns?: string[];
50+
}
51+
4752
export interface SchemaField {
4853
id: string;
4954
collectionId: string;
@@ -80,6 +85,7 @@ export interface CreateCollectionInput {
8085
labelSingular?: string;
8186
description?: string;
8287
icon?: string;
88+
admin?: CollectionAdminConfig;
8389
supports?: string[];
8490
urlPattern?: string;
8591
hasSeo?: boolean;
@@ -90,6 +96,7 @@ export interface UpdateCollectionInput {
9096
labelSingular?: string;
9197
description?: string;
9298
icon?: string;
99+
admin?: CollectionAdminConfig;
93100
supports?: string[];
94101
urlPattern?: string;
95102
hasSeo?: boolean;

packages/admin/src/router.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,19 @@ function ContentListPage() {
571571
return <ErrorScreen error={error.message} />;
572572
}
573573

574+
const listColumns = (collectionConfig.listColumns ?? []).flatMap((slug) => {
575+
const field = collectionConfig.fields[slug];
576+
if (!field) return [];
577+
return [
578+
{
579+
slug,
580+
label: field.label ?? slug,
581+
kind: field.kind,
582+
options: Array.isArray(field.options) ? field.options : undefined,
583+
},
584+
];
585+
});
586+
574587
const handleLocaleChange = (locale: string) => {
575588
// Update URL search params without full navigation
576589
void navigate({
@@ -585,6 +598,7 @@ function ContentListPage() {
585598
collection={collection}
586599
collectionLabel={collectionConfig.label}
587600
items={items}
601+
listColumns={listColumns}
588602
trashedItems={trashedData?.items || []}
589603
isLoading={isLoading || isFetchingNextPage}
590604
isTrashedLoading={isTrashedLoading}

packages/admin/tests/components/ContentList.test.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,47 @@ describe("ContentList", () => {
111111
await expect.element(screen.getByText("Second")).toBeInTheDocument();
112112
await expect.element(screen.getByText("Third")).toBeInTheDocument();
113113
});
114+
115+
it("renders configured custom fields using their field metadata", async () => {
116+
const items = [
117+
makeItem({
118+
data: {
119+
title: "Support request",
120+
ticket_number: "SUP-1042",
121+
priority: "urgent",
122+
labels: ["bug", "unknown"],
123+
vip: true,
124+
},
125+
}),
126+
];
127+
const screen = await render(
128+
<ContentList
129+
{...defaultProps}
130+
items={items}
131+
listColumns={[
132+
{ slug: "ticket_number", label: "Ticket", kind: "string" },
133+
{
134+
slug: "priority",
135+
label: "Priority",
136+
kind: "select",
137+
options: [{ value: "urgent", label: "Urgent" }],
138+
},
139+
{
140+
slug: "labels",
141+
label: "Labels",
142+
kind: "multiSelect",
143+
options: [{ value: "bug", label: "Bug" }],
144+
},
145+
{ slug: "vip", label: "VIP", kind: "boolean" },
146+
]}
147+
/>,
148+
);
149+
150+
await expect.element(screen.getByText("SUP-1042")).toBeInTheDocument();
151+
await expect.element(screen.getByText("Urgent")).toBeInTheDocument();
152+
await expect.element(screen.getByText("Bug, unknown")).toBeInTheDocument();
153+
await expect.element(screen.getByText("✓")).toBeInTheDocument();
154+
});
114155
});
115156

116157
describe("empty states", () => {

packages/core/src/api/schemas/schema.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ const collectionSupportValues = z.enum(["drafts", "revisions", "preview", "sched
1010

1111
const collectionSourcePattern = /^(template:.+|import:.+|manual|discovered|seed)$/;
1212

13+
const collectionAdminConfig = z.object({
14+
listColumns: z
15+
.array(z.string().min(1).max(63).regex(slugPattern, "Invalid field slug format"))
16+
.optional(),
17+
});
18+
1319
const fieldTypeValues = z.enum([
1420
"string",
1521
"text",
@@ -82,6 +88,7 @@ export const createCollectionBody = z
8288
labelSingular: z.string().optional(),
8389
description: z.string().optional(),
8490
icon: z.string().optional(),
91+
admin: collectionAdminConfig.optional(),
8592
supports: z.array(collectionSupportValues).optional(),
8693
source: z.string().regex(collectionSourcePattern).optional(),
8794
urlPattern: z.string().optional(),
@@ -95,6 +102,7 @@ export const updateCollectionBody = z
95102
labelSingular: z.string().optional(),
96103
description: z.string().optional(),
97104
icon: z.string().optional(),
105+
admin: collectionAdminConfig.optional(),
98106
supports: z.array(collectionSupportValues).optional(),
99107
urlPattern: z.string().nullish(),
100108
hasSeo: z.boolean().optional(),
@@ -175,6 +183,7 @@ export const collectionSchema = z
175183
labelSingular: z.string().nullable(),
176184
description: z.string().nullable(),
177185
icon: z.string().nullable(),
186+
admin: collectionAdminConfig.optional(),
178187
supports: z.array(z.string()),
179188
source: z.string().nullable(),
180189
urlPattern: z.string().nullable(),

packages/core/src/astro/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export interface ManifestCollection {
3131
supports: string[];
3232
hasSeo: boolean;
3333
urlPattern?: string;
34+
/** Valid custom field slugs to render in the admin content list. */
35+
listColumns?: string[];
3436
fields: Record<
3537
string,
3638
{

packages/core/src/cli/commands/export-seed.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,7 @@ async function exportCollections(db: Kysely<Database>): Promise<SeedCollection[]
314314
labelSingular: collection.labelSingular || undefined,
315315
description: collection.description || undefined,
316316
icon: collection.icon || undefined,
317+
admin: collection.admin,
317318
supports: collection.supports.length > 0 ? collection.supports : undefined,
318319
urlPattern: collection.urlPattern || undefined,
319320
fields: fields.map(

0 commit comments

Comments
 (0)