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

Add server-side filtering for indexed custom fields across repository, REST client, and plugin content list APIs.
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.
11 changes: 11 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 and filtering 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,16 @@ 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` or a field
filter. 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 and filtered reads but use additional storage and add work to content
writes. Only index fields that are used for sorting or filtering.
</Aside>

## Validation rules

The `validation` object varies by field type. Its full shape is:
Expand Down
27 changes: 27 additions & 0 deletions docs/src/content/docs/plugins/creating-plugins/api-routes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,33 @@ export default {
- `routeCtx` carries request-shaped data: `{ input, request, requestMeta }`.
- `ctx` is the same `PluginContext` you get inside hooks — `ctx.storage`, `ctx.kv`, `ctx.content`, `ctx.http`, `ctx.log`, etc.

### Filtering indexed content fields

Plugins with the `content:read` capability can filter custom fields that a collection marks as
`indexed`. Filters run in the database and combine with `AND` semantics:

```typescript
const result = await ctx.content.list("items", {
where: {
fieldFilters: {
priority: { in: ["urgent", "high"] },
score: { gte: 80 },
resolved: false,
},
},
});
```

Scalar values use exact matching. Use `null` for null matching, `{ in: [...] }` for a set of exact
values, or `gt`, `gte`, `lt`, and `lte` for range comparisons. EmDash rejects filters for fields
that are not indexed, values that do not match the field type, and more than 20 field filters per
query. An `in` filter accepts at most 100 values.

<Aside type="caution">
Filtering is intentionally limited to indexed scalar fields. Prefix, substring, and arbitrary
JSON filtering require different index strategies and are not part of this API.
</Aside>

## Route URLs

Routes mount at `/_emdash/api/plugins/<slug>/<route-name>`. Route names can include slashes for nested paths.
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 "—";
Comment on lines +1116 to +1117

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] formatListColumnValue hard-codes user-facing rendering: "—" for empty/unknown values (lines 1117, 1121, 1142, 1155), "✓" for true booleans (line 1145), a literal ", " separator for multi-select (line 1142), and date.toLocaleDateString() which uses the browser locale rather than the active Lingui catalog. AGENTS.md requires all admin UI strings to go through Lingui and admin layout to be RTL-safe; a plain em-dash/checkmark may also render oddly in RTL contexts.

formatListColumnValue is currently a module-level helper without access to the Lingui t function. Make ContentListCustomCell a proper component that calls useLingui(), then pass t (or MessageDescriptors) through to the formatter. For example:

const emptyLabel = msg`—`;
const trueLabel = msg`✓`;

and inside the component:

const text = formatListColumnValue(t, column, value);

The multi-select join and date formatting should also use Lingui/catalog-aware helpers rather than hard-coded ", " and toLocaleDateString().


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
Loading
Loading