Skip to content
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] This changeset describes a new user-facing feature, but AGENTS.md requires a prior maintainer-approved Discussion for features. The PR description acknowledges that Discussion #1717 covers admin.listColumns (#2194) only and that no Discussion has been opened for indexed custom-field sorting yet. Please open and get approval for a Discussion covering this feature before merging.

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
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
58 changes: 46 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,12 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie
required,
unique,
searchable: isSearchableType ? searchable : undefined,
// `false`, not `undefined`: the server reads an absent `indexed` as
// "keep the stored value", so switching an indexed field to a type
// that cannot be indexed would keep the flag set and then fail its
// own validation. The Indexed switch is hidden for those types, so
// the editor could not clear it either.
indexed: isIndexableType ? indexed : false,
validation: Object.keys(validation).length > 0 ? validation : null,
};

Expand Down Expand Up @@ -441,17 +472,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.js";
export { ContentEditor, type ContentEditorProps, type FieldDescriptor } from "./ContentEditor";
export { MediaLibrary, type MediaLibraryProps } from "./MediaLibrary";
export { MediaPickerModal, type MediaPickerModalProps } from "./MediaPickerModal";
Expand Down
Loading
Loading