Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
bd17740
feat(admin): show configured fields in content lists
logelog Jul 22, 2026
0f5c7c0
fix(admin): use the .js specifier on the content list export
logelog Aug 7, 2026
b3ed69f
feat(content): support indexed custom field sorting
logelog Jul 22, 2026
0475bfd
fix(content): remove redundant indexed cursor cast
logelog Jul 22, 2026
c3e3bff
test(core): replay indexed field migration
logelog Jul 22, 2026
d98ab06
fix(content): use generated custom field indexes
logelog Aug 2, 2026
b424c1a
fix(content): keep indexed cursor scans ordered
logelog Aug 2, 2026
b9d6320
test(content): capture qualified list queries
logelog Aug 2, 2026
5fb6c55
fix(admin): clear the indexed flag for non-indexable field types
logelog Aug 3, 2026
52bbe7e
test(admin): cover clearing the indexed flag on type change
logelog Aug 3, 2026
746eac6
feat(content): filter indexed custom fields
logelog Jul 22, 2026
fa6d78d
fix(content): normalize field filters before validation
logelog Jul 22, 2026
d45d3a9
test(content): cover indexed field filter contracts
logelog Aug 2, 2026
7a771e0
fix(content): cap indexed filter bind budget
logelog Aug 2, 2026
3c4bbf5
fix(content): preserve indexed filter query plans
logelog Aug 2, 2026
6d4081c
fix(content): keep the missing-collection error contract with field f…
logelog Aug 8, 2026
b05b6a9
fix(content): settle both list queries before rethrowing
logelog Aug 8, 2026
f6115ce
fix(content): resolve a missing collection before filter identifiers
logelog Aug 8, 2026
d64fc47
refactor: drop comments that address the reviewer
logelog Aug 8, 2026
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
---

Adds server-side filtering for indexed custom fields through the 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
28 changes: 28 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,34 @@ 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 50 values, and all exact values, range bounds, and `in`
members together have a 50-operand budget per query. Null matches do not consume that budget.

<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 field sorting/filtering |
| `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 or used in `fieldFilters` in content list queries. Avoid indexing fields that are not used
for sorting or filtering 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
Loading
Loading