feat(core): configurable displayField and dateField per collection#1973
Conversation
🦋 Changeset detectedLatest commit: 0212cb1 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
PR template validation failedPlease fix the following issues by editing your PR description:
See CONTRIBUTING.md for the full contribution policy. |
Scope checkThis PR changes 645 lines across 23 files. Large PRs are harder to review and more likely to be closed without review. If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs. See CONTRIBUTING.md for contribution guidelines. |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
There was a problem hiding this comment.
This PR implements configurable displayField and dateField collection options in a sound, additive way: new nullable columns on _emdash_collections, server-side validation of the configured slugs, a shared getEntryTitle helper used across the admin list/editor/picker, and a closed set of allowed orderBy values resolved server-side. The approach fits EmDash's architecture and the migration/seed paths look correct.
I checked the diff against AGENTS.md conventions and runtime semantics. The implementation is generally clean and well tested, but I found a few issues worth fixing before merge:
- Referential integrity on field deletion —
SchemaRegistry.deleteFielddrops theec_*column but never clears the collection'sdisplay_field/date_fieldwhen that slug is referenced. A deleted field used asdateFieldwill later crash the content list when the admin sorts by it ("no such column"). This is the most important blocker. - React
useMemodependency arrays — both the content list and the content picker filter bygetEntryTitle(item, displayField)but omitdisplayFieldfrom their memo dependencies, which can leave client-side search results stale. - API schema surface is incomplete —
updateCollectionBodyandcollectionSchemadon't includedisplayField/dateField, so the REST collection API cannot read or write them even though the registry supports it. Combined withContentTypeEditornot exposing the fields, the feature is currently seed-file-only. This matches the PR description, but wiring them through the schemas would make the backend/frontend work consistent. displayFieldtype is not validated — any existing field slug is accepted, including JSON/portableText/image/repeater fields, whose raw stored values would render poorly as entry titles.
The tests cover the happy paths and the server-side sort validation well. Fixing #1 and #2 would unblock merge from my perspective; #3 and #4 are follow-ups that can be addressed here or in a later pass.
Findings
-
[needs fixing]
packages/core/src/schema/registry.ts:777deleteFielddrops the column from theec_*table but never checks whether the slug is still referenced bydisplay_fieldordate_fieldon_emdash_collections. After deletion, sorting the content list by the configured column fails with "no such column". Clear the collection metadata when the referenced field is removed.async deleteField(collectionSlug: string, fieldSlug: string): Promise<void> { const field = await this.getField(collectionSlug, fieldSlug); if (!field) { throw new SchemaError( `Field "${fieldSlug}" not found in collection "${collectionSlug}"`, "FIELD_NOT_FOUND", ); } const collection = await this.getCollection(collectionSlug); if (collection && (collection.displayField === fieldSlug || collection.dateField === fieldSlug)) { await this.updateCollection(collectionSlug, { displayField: collection.displayField === fieldSlug ? null : undefined, dateField: collection.dateField === fieldSlug ? null : undefined, }); } let schemaMutated = false; try { await withTransaction(this.db, async (trx) => {
…e-diplay-and-date-field-in-collections # Conflicts: # packages/core/src/database/migrations/runner.ts # packages/core/tests/integration/database/migrations.test.ts # scripts/query-counts.snapshot.d1.json # scripts/query-counts.snapshot.sqlite.json
Note for maintainer: This feature has already been placed in the 1.0 milestone by a maintainer. |
There was a problem hiding this comment.
This is a re-review of PR #1973. Since the last pass, the author has fixed the three blocking/regression concerns from the prior review:
SchemaRegistry.deleteFieldnow clearsdisplay_field/date_fieldin the same transaction when the referenced field is removed, so the content list won't later crash sorting by a dropped column.- The React
useMemodependency arrays inContentListandContentPickerModalnow includedisplayField, so client-side title filtering stays reactive. SchemaRegistry.updateCollectionvalidates thatdisplayFieldis a text-like field (string,text,slug) anddateFieldis adatetimefield.
The tests added for custom-field sorting, search result titles, and collection metadata behavior cover the happy paths well.
One new issue remains: SchemaRegistry.updateField allows in-place field-type changes as long as the SQLite column type doesn't change (e.g., datetime → string, or text → url). Because all of those types map to TEXT, the change is accepted even when the field is configured as a displayField or dateField, breaking the type invariant this PR establishes. After the change, the admin may sort the "Date" column by arbitrary strings or use a URL as a title. This should be guarded the same way updateCollection guards the initial assignment.
Prior finding #3 — the REST/OpenAPI collection schemas and the admin Content Type editor don't expose displayField/dateField — is still present. It aligns with the seed-file-only scope stated in the PR description and was previously flagged as a follow-up, so I'm not blocking on it here.
Overall the approach is solid and additive; fixing the updateField invariant would make the validation complete.
Findings
-
[needs fixing]
packages/core/src/schema/registry.ts:657After this block accepts an in-place type change because the SQLite column type stayed the same, the collection's
displayField/dateFieldmetadata can become invalid. For example, adatetimefield used asdateFieldcan be changed tostring(bothTEXT), or atextfield used asdisplayFieldcan be changed tourl, leaving the metadata claiming a type thatvalidateDisplayDateFieldswould reject onupdateCollection.Re-validate against the collection's configured
displayField/dateFieldwheninput.typechanges, or block the change and require the user to clear the metadata first. Add a test that changes adateFieldfield to a non-datetime type and expects aSchemaError.let nextType = field.type; let nextColumnType = field.columnType; if (input.type !== undefined && input.type !== field.type) { const newColumnType = FIELD_TYPE_TO_COLUMN[input.type]; if (newColumnType !== field.columnType) { throw new SchemaError( `Cannot change field "${fieldSlug}" in collection "${collectionSlug}" from type ` + `"${field.type}" to "${input.type}": the underlying column type would change from ` + `${field.columnType} to ${newColumnType}, which requires a manual content migration. ` + `Drop and re-create the field, or migrate the column data, before changing its type.`, "FIELD_TYPE_COLUMN_CHANGE", ); } // A same-column-type change can still violate the displayField/dateField // invariants (#1133). Read the collection only when a type change is // actually requested. const collection = await this.getCollection(collectionSlug); if (collection?.displayField === fieldSlug && !DISPLAY_FIELD_TYPES.has(input.type)) { throw new SchemaError( `Cannot change field "${fieldSlug}" to type "${input.type}" because it is the collection's displayField. ` + `Clear displayField or change it to another field first.`, "INVALID_DISPLAY_FIELD", ); } if (collection?.dateField === fieldSlug && input.type !== "datetime") { throw new SchemaError( `Cannot change field "${fieldSlug}" to type "${input.type}" because it is the collection's dateField. ` + `Clear dateField or change it to another field first.`, "INVALID_DATE_FIELD", ); } nextType = input.type; nextColumnType = newColumnType; }
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
…ions' of https://github.com/CacheMeOwside/emdash into feat/1133-configurable-diplay-and-date-field-in-collections
What does this PR do?
Closes #1133
Adds two optional collection options, displayField and dateField, so the admin shows a meaningful title and date for collections whose data doesn't follow the title/updated-date convention (e.g. an employees collection where name is the title and a custom column is the real date to be displayed).
Follow-ups: filtering the date-range picker by the custom dateField. The date-range filter still uses system dates (Created/Updated/Published), not a custom dateField. Only display and sort use it.
Note
The newly added displayField/dateField not exposed via REST/OpenAPI or the Content Type editor. This is intentional, config-as-code scope, matching #1133's proposed API, which defines these as collection-definition properties, not admin-UI or REST-managed settings. For this PR they're seed-file / config-as-code only.
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain.AI-generated code disclosure
Screenshots / test output
Add this to the seed file (e.g.
seed/seed.json), then apply the seed and start the dev server:Then open Content → Employees in the Admin: the Title column shows names (not job titles), the Date column shows the start date, and sorting/search use those fields.