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.
123 changes: 122 additions & 1 deletion packages/admin/src/components/ContentList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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;
Expand Down Expand Up @@ -163,6 +172,7 @@ export function ContentList({
collection,
collectionLabel,
items,
listColumns = [],
trashedItems = [],
isLoading,
isTrashedLoading,
Expand Down Expand Up @@ -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 (
<div className="space-y-4">
Expand Down Expand Up @@ -511,6 +521,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 @@ -582,6 +601,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 @@ -955,6 +975,7 @@ interface ContentListItemProps {
onDuplicate?: (id: string) => void;
showLocale?: boolean;
urlPattern?: string;
listColumns: ContentListColumn[];
selectable?: boolean;
selected?: boolean;
onToggleSelect?: (id: string) => void;
Expand All @@ -967,6 +988,7 @@ function ContentListItem({
onDuplicate,
showLocale,
urlPattern,
listColumns,
selectable,
selected,
onToggleSelect,
Expand Down Expand Up @@ -996,6 +1018,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 @@ -1083,6 +1108,102 @@ function ContentListItem({
);
}

function ContentListCustomCell({
column,
value,
}: {
column: ContentListColumn;
value: unknown;
}): React.ReactNode {
const { i18n, t } = useLingui();
const text = formatListColumnValue(column, value, {
emptyLabel: t`Not set`,
falseLabel: t`No`,
locale: i18n.locale,
trueLabel: t`Yes`,
});
return (
<td className="max-w-48 px-4 py-3 text-sm">
<span className="block truncate" title={text}>
{text}
</span>
</td>
);
}

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;
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.js";
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 @@ -572,6 +572,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 @@ -586,6 +599,7 @@ function ContentListPage() {
collection={collection}
collectionLabel={collectionConfig.label}
items={items}
listColumns={listColumns}
trashedItems={trashedData?.items || []}
isLoading={isLoading || isFetchingNextPage}
isTrashedLoading={isTrashedLoading}
Expand Down
Loading
Loading