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.
6 changes: 6 additions & 0 deletions .changeset/indexed-custom-field-sorting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"emdash": minor
"@emdash-cms/admin": minor
---

Adds opt-in database indexes for scalar custom fields and stable cursor pagination when ordering content lists by those fields.
10 changes: 10 additions & 0 deletions docs/src/content/docs/concepts/collections.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ Every field supports these properties:
| `type` | `FieldType` | One of the 16 field types |
| `required` | `boolean` | Whether the field must have a value |
| `unique` | `boolean` | Whether values must be unique across entries |
| `indexed` | `boolean` | Allow efficient sorting by this field |
| `defaultValue` | `unknown` | Default value for new entries |
| `validation` | `object` | Type-specific validation rules |
| `widget` | `string` | Custom widget identifier |
Expand All @@ -228,6 +229,15 @@ Every field supports these properties:
`version`, `live_revision_id`, `draft_revision_id`, `terms`, `bylines`, `byline`.
</Aside>

Set `indexed: true` when a collection query needs to use a custom field in `orderBy`. Indexes are
supported for `string`, `url`, `number`, `integer`, `boolean`, `datetime`, `select`, `reference`,
and `slug` fields. EmDash rejects indexed JSON, rich content, and other non-scalar field types.

<Aside type="caution">
Indexes improve ordered reads but use additional storage and add work to content writes. Only index
fields that are used for sorting.
</Aside>

## Validation rules

The `validation` object varies by field type. Its full shape is:
Expand Down
6 changes: 6 additions & 0 deletions docs/src/content/docs/reference/field-types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -386,12 +386,18 @@ All fields support these common properties:
| `type` | `FieldType` | Field type (required) |
| `required` | `boolean` | Require a value (default: false) |
| `unique` | `boolean` | Enforce uniqueness (default: false) |
| `indexed` | `boolean` | Enable indexed custom-field sorting |
| `defaultValue` | `unknown` | Default value for new entries |
| `validation` | `object` | Type-specific validation rules |
| `widget` | `string` | Custom widget override |
| `options` | `object` | Widget configuration |
| `sortOrder` | `number` | Display order in admin |

`indexed` is available for scalar fields: `string`, `url`, `number`, `integer`, `boolean`,
`datetime`, `select`, `reference`, and `slug`. An indexed field can be passed as the `orderBy`
field in content list queries. Avoid indexing fields that are not used for sorting because every
index adds storage and write overhead.

## Reserved field slugs

These slugs are reserved and cannot be used:
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/reference/mcp-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@ Add a new field to a collection's schema. This adds a column to the database tab
| `validation` | `object` | No | Constraints: `min`, `max`, `minLength`, `maxLength`, `pattern`, `options` |
| `options` | `object` | No | Widget config: `collection` (for references), `rows` (for textarea) |
| `searchable` | `boolean` | No | Include in full-text search index |
| `indexed` | `boolean` | No | Enable indexed sorting by this field |
| `translatable` | `boolean` | No | Whether this field is translatable (default true) |

Field types: `string`, `text`, `number`, `integer`, `boolean`, `datetime`, `select`, `multiSelect`, `portableText`, `image`, `file`, `reference`, `json`, `slug`.
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/themes/seed-files.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ Each collection definition creates a content type in the database:
| `type` | `string` | Yes | Field type |
| `required` | `boolean` | No | Validation: field must have a value |
| `unique` | `boolean` | No | Validation: value must be unique |
| `indexed` | `boolean` | No | Enable indexed sorting by this field |
| `defaultValue` | `any` | No | Default value for new entries |
| `validation` | `object` | No | Additional validation rules |
| `widget` | `string` | No | Admin UI widget override |
Expand Down
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
53 changes: 41 additions & 12 deletions packages/admin/src/components/FieldEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,32 @@ import { AllowedTypesEditor } from "./AllowedTypesEditor";

const SLUG_INVALID_CHARS_REGEX = /[^a-z0-9]+/g;
const SLUG_LEADING_TRAILING_REGEX = /^_|_$/g;
const SEARCHABLE_FIELD_TYPES = new Set<FieldType>([
"string",
"text",
"portableText",
"slug",
"url",
]);
const INDEXABLE_FIELD_TYPES = new Set<FieldType>([
"string",
"url",
"number",
"integer",
"boolean",
"datetime",
"select",
"reference",
"slug",
]);

function isSearchableFieldType(type: FieldType | null): type is FieldType {
return type !== null && SEARCHABLE_FIELD_TYPES.has(type);
}

function isIndexableFieldType(type: FieldType | null): type is FieldType {
return type !== null && INDEXABLE_FIELD_TYPES.has(type);
}

// ============================================================================
// Types
Expand Down Expand Up @@ -67,6 +93,7 @@ interface FieldFormState {
required: boolean;
unique: boolean;
searchable: boolean;
indexed: boolean;
minLength: string;
maxLength: string;
min: string;
Expand All @@ -89,6 +116,7 @@ function getInitialFormState(field?: SchemaField): FieldFormState {
required: field.required,
unique: field.unique,
searchable: field.searchable,
indexed: field.indexed ?? false,
minLength: field.validation?.minLength?.toString() ?? "",
maxLength: field.validation?.maxLength?.toString() ?? "",
min: field.validation?.min?.toString() ?? "",
Expand All @@ -111,6 +139,7 @@ function getInitialFormState(field?: SchemaField): FieldFormState {
required: false,
unique: false,
searchable: false,
indexed: false,
minLength: "",
maxLength: "",
min: "",
Expand Down Expand Up @@ -138,7 +167,7 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie
}
}, [open, field]);

const { step, selectedType, slug, label, required, unique, searchable } = formState;
const { step, selectedType, slug, label, required, unique, searchable, indexed } = formState;
const { minLength, maxLength, min, max, pattern, options } = formState;
const setField = <K extends keyof FieldFormState>(key: K, value: FieldFormState[K]) =>
setFormState((prev) => ({ ...prev, [key]: value }));
Expand Down Expand Up @@ -312,12 +341,8 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie
}

// Only include searchable for text-based fields
const isSearchableType =
selectedType === "string" ||
selectedType === "text" ||
selectedType === "portableText" ||
selectedType === "slug" ||
selectedType === "url";
const isSearchableType = isSearchableFieldType(selectedType);
const isIndexableType = isIndexableFieldType(selectedType);

const input: CreateFieldInput = {
slug,
Expand All @@ -326,6 +351,7 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie
required,
unique,
searchable: isSearchableType ? searchable : undefined,
indexed: isIndexableType ? indexed : undefined,
validation: Object.keys(validation).length > 0 ? validation : null,
};

Expand Down Expand Up @@ -441,17 +467,20 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie
onCheckedChange={(checked) => setField("unique", checked)}
label={<span className="text-sm">{t`Unique`}</span>}
/>
{(selectedType === "string" ||
selectedType === "text" ||
selectedType === "portableText" ||
selectedType === "slug" ||
selectedType === "url") && (
{isSearchableFieldType(selectedType) && (
<Switch
checked={searchable}
onCheckedChange={(checked) => setField("searchable", checked)}
label={<span className="text-sm">{t`Searchable`}</span>}
/>
)}
{isIndexableFieldType(selectedType) && (
<Switch
checked={indexed}
onCheckedChange={(checked) => setField("indexed", checked)}
label={<span className="text-sm">{t`Indexed`}</span>}
/>
)}
</div>

{/* Type-specific validation */}
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
Loading
Loading