From 0acd3469f600542ba55e8dfbd9f3dd4579fa13fa Mon Sep 17 00:00:00 2001 From: CacheMeOwside Date: Sun, 12 Jul 2026 14:43:15 +0530 Subject: [PATCH 01/13] feat(core): configurable displayField and dateField per collection --- .changeset/collection-display-date-fields.md | 6 ++ .../admin/src/components/ContentEditor.tsx | 8 +- packages/admin/src/components/ContentList.tsx | 69 +++++++++---- .../src/components/ContentPickerModal.tsx | 31 +++--- packages/admin/src/lib/api/client.ts | 2 + packages/admin/src/lib/entryTitle.ts | 20 ++++ packages/admin/src/router.tsx | 17 +++- packages/core/src/api/handlers/content.ts | 18 +++- packages/core/src/astro/types.ts | 2 + .../051_collection_display_date_fields.ts | 32 +++++++ .../core/src/database/migrations/runner.ts | 2 + .../core/src/database/repositories/content.ts | 57 +++++++---- .../core/src/database/repositories/types.ts | 6 ++ packages/core/src/database/types.ts | 2 + packages/core/src/emdash-runtime.ts | 2 + packages/core/src/schema/registry.ts | 63 ++++++++++++ packages/core/src/schema/types.ts | 8 ++ packages/core/src/search/query.ts | 42 +++++++- packages/core/src/seed/apply.ts | 21 ++++ packages/core/src/seed/types.ts | 4 + .../content/display-date-sort.test.ts | 74 ++++++++++++++ .../search/display-field-title.test.ts | 63 ++++++++++++ .../collection-display-date-fields.test.ts | 96 +++++++++++++++++++ 23 files changed, 584 insertions(+), 61 deletions(-) create mode 100644 .changeset/collection-display-date-fields.md create mode 100644 packages/admin/src/lib/entryTitle.ts create mode 100644 packages/core/src/database/migrations/051_collection_display_date_fields.ts create mode 100644 packages/core/tests/integration/content/display-date-sort.test.ts create mode 100644 packages/core/tests/integration/search/display-field-title.test.ts create mode 100644 packages/core/tests/unit/schema/collection-display-date-fields.test.ts diff --git a/.changeset/collection-display-date-fields.md b/.changeset/collection-display-date-fields.md new file mode 100644 index 0000000000..6b5e0cfa60 --- /dev/null +++ b/.changeset/collection-display-date-fields.md @@ -0,0 +1,6 @@ +--- +"emdash": minor +"@emdash-cms/admin": minor +--- + +Adds `displayField` and `dateField` optional collection options to choose which field is used as an entry's title and which date the content list shows and sorts by. diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index 806aca20a6..c8594899cd 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -33,6 +33,7 @@ import type { } from "../lib/api"; import { getPreviewUrl, getDraftStatus } from "../lib/api"; import { fromDatetimeLocalInputValue, toDatetimeLocalInputValue } from "../lib/datetime-local.js"; +import { getEntryTitle } from "../lib/entryTitle.js"; import { formatFileSize, getFileIcon } from "../lib/media-utils"; import { usePluginAdmins } from "../lib/plugin-context.js"; import { contentUrl, isSafeUrl } from "../lib/url.js"; @@ -512,6 +513,11 @@ export function ContentEditor({ const urlPattern = manifest?.collections[collection]?.urlPattern; + // The editor header shows the entry's display title (honoring displayField) + // for existing entries (#1133). + const displayField = manifest?.collections[collection]?.displayField; + const entryTitle = item ? getEntryTitle(item, displayField) : ""; + const handlePreview = async () => { if (!item?.id) return; @@ -648,7 +654,7 @@ export function ContentEditor({ )}

- {isNew ? t`New ${collectionLabel}` : t`Edit ${collectionLabel}`} + {isNew ? t`New ${collectionLabel}` : entryTitle || t`Edit ${collectionLabel}`}

{i18n && item?.locale && ( diff --git a/packages/admin/src/components/ContentList.tsx b/packages/admin/src/components/ContentList.tsx index 165df63d72..88f3675244 100644 --- a/packages/admin/src/components/ContentList.tsx +++ b/packages/admin/src/components/ContentList.tsx @@ -28,6 +28,7 @@ import { Link } from "@tanstack/react-router"; import * as React from "react"; import type { ContentAuthor, ContentDateField, ContentItem, TrashedContentItem } from "../lib/api"; +import { getEntryTitle } from "../lib/entryTitle.js"; import { useDebouncedValue } from "../lib/hooks.js"; import { contentUrl } from "../lib/url.js"; import { cn } from "../lib/utils"; @@ -35,8 +36,12 @@ import { CaretNext, CaretPrev } from "./ArrowIcons.js"; import { LocaleSwitcher } from "./LocaleSwitcher"; import { RouterLinkButton } from "./RouterLinkButton.js"; -/** Sortable content list columns. Maps to the server's order field whitelist. */ -export type ContentListSortField = "title" | "status" | "locale" | "updatedAt"; +/** + * Sortable content list columns. The named values map to the server's system + * order fields; a collection's configured displayField/dateField slug is also + * accepted (#1133), which the server validates against the collection. + */ +export type ContentListSortField = "title" | "status" | "locale" | "updatedAt" | (string & {}); export interface ContentListSort { field: ContentListSortField; direction: "asc" | "desc"; @@ -83,6 +88,10 @@ export interface ContentListProps { onLocaleChange?: (locale: string) => void; /** URL pattern for published content links (e.g. `/blog/{slug}`) */ urlPattern?: string; + /** Collection field slug powering the Title column (falls back to the title chain). */ + displayField?: string; + /** Collection field slug (datetime) powering the Date column (falls back to updated date). */ + dateField?: string; /** * Controlled sort state. When `onSortChange` is also provided, the column * headers become sort controls that invoke it. Uncontrolled sort keeps @@ -139,15 +148,19 @@ type ViewTab = "all" | "trash"; const PAGE_SIZE = 20; -function getItemTitle(item: { data: Record; slug: string | null; id: string }) { - const rawTitle = item.data.title; - const rawName = item.data.name; - return ( - (typeof rawTitle === "string" ? rawTitle : "") || - (typeof rawName === "string" ? rawName : "") || - item.slug || - item.id - ); +const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** + * Parse a dateField value for the Date column. Returns null if missing or + * unparseable (so the caller falls back to a system date instead of showing + * "Invalid Date"). Bare `YYYY-MM-DD` is read as local midnight to avoid a + * previous-day shift in negative-UTC timezones. + */ +function parseListDate(value: unknown): Date | null { + if (typeof value !== "string" || !value) return null; + const normalized = DATE_ONLY_RE.test(value) ? `${value}T00:00:00` : value; + const parsed = new Date(normalized); + return Number.isNaN(parsed.getTime()) ? null : parsed; } /** @@ -173,6 +186,8 @@ export function ContentList({ activeLocale, onLocaleChange, urlPattern, + displayField, + dateField, sort, onSortChange, total, @@ -217,7 +232,7 @@ export function ContentList({ const filteredItems = React.useMemo(() => { if (serverSearch || !searchQuery) return items; const query = searchQuery.toLowerCase(); - return items.filter((item) => getItemTitle(item).toLowerCase().includes(query)); + return items.filter((item) => getEntryTitle(item, displayField).toLowerCase().includes(query)); }, [items, searchQuery, serverSearch]); // The query the current `items` reflect: server-side filtering lags behind @@ -498,8 +513,10 @@ export function ContentList({ /> )} + {/* The Title/Date columns sort by the collection's configured + displayField/dateField when set (#1133) */} )} @@ -943,6 +963,8 @@ interface ContentListItemProps { onDuplicate?: (id: string) => void; showLocale?: boolean; urlPattern?: string; + displayField?: string; + dateField?: string; selectable?: boolean; selected?: boolean; onToggleSelect?: (id: string) => void; @@ -955,13 +977,18 @@ function ContentListItem({ onDuplicate, showLocale, urlPattern, + displayField, + dateField, selectable, selected, onToggleSelect, }: ContentListItemProps) { const { t } = useLingui(); - const title = getItemTitle(item); - const date = new Date(item.updatedAt || item.createdAt); + const title = getEntryTitle(item, displayField); + // A configured dateField drives the Date column; fall back to the + // last-updated / created date when it's unset, empty, or unparseable. + const customDate = dateField ? parseListDate(item.data[dateField]) : null; + const date = customDate ?? new Date(item.updatedAt || item.createdAt); return ( @@ -1071,13 +1098,19 @@ function ContentListItem({ interface TrashedListItemProps { item: TrashedContentItem; + displayField?: string; onRestore?: (id: string) => void; onPermanentDelete?: (id: string) => void; } -function TrashedListItem({ item, onRestore, onPermanentDelete }: TrashedListItemProps) { +function TrashedListItem({ + item, + displayField, + onRestore, + onPermanentDelete, +}: TrashedListItemProps) { const { t } = useLingui(); - const title = getItemTitle(item); + const title = getEntryTitle(item, displayField); const deletedDate = new Date(item.deletedAt); return ( diff --git a/packages/admin/src/components/ContentPickerModal.tsx b/packages/admin/src/components/ContentPickerModal.tsx index 112219a2cf..b6877239cc 100644 --- a/packages/admin/src/components/ContentPickerModal.tsx +++ b/packages/admin/src/components/ContentPickerModal.tsx @@ -11,8 +11,9 @@ import { MagnifyingGlass, FolderOpen, X } from "@phosphor-icons/react"; import { useQuery } from "@tanstack/react-query"; import * as React from "react"; -import { fetchCollections, fetchContentList, getDraftStatus } from "../lib/api"; +import { fetchCollections, fetchContentList, fetchManifest, getDraftStatus } from "../lib/api"; import type { ContentItem } from "../lib/api"; +import { getEntryTitle } from "../lib/entryTitle.js"; import { useDebouncedValue } from "../lib/hooks"; import { cn } from "../lib/utils"; @@ -22,17 +23,6 @@ interface ContentPickerModalProps { onSelect: (item: { collection: string; id: string; title: string }) => void; } -function getItemTitle(item: { data: Record; slug: string | null; id: string }) { - const rawTitle = item.data.title; - const rawName = item.data.name; - return ( - (typeof rawTitle === "string" ? rawTitle : "") || - (typeof rawName === "string" ? rawName : "") || - item.slug || - item.id - ); -} - export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPickerModalProps) { const { t } = useLingui(); const [searchQuery, setSearchQuery] = React.useState(""); @@ -48,6 +38,15 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick enabled: open, }); + // Reuse the cached manifest (same query key as the rest of the admin) to + // resolve the selected collection's displayField for entry titles (#1133). + const { data: manifest } = useQuery({ + queryKey: ["manifest"], + queryFn: fetchManifest, + enabled: open, + }); + const displayField = manifest?.collections[selectedCollection]?.displayField; + // Default to first collection when collections load React.useEffect(() => { if (collections.length > 0 && !selectedCollection) { @@ -87,7 +86,9 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick const filteredItems = React.useMemo(() => { if (!debouncedSearch) return allItems; const query = debouncedSearch.toLowerCase(); - return allItems.filter((item) => getItemTitle(item).toLowerCase().includes(query)); + return allItems.filter((item) => + getEntryTitle(item, displayField).toLowerCase().includes(query), + ); }, [allItems, debouncedSearch]); // Reset state when modal opens or collection changes @@ -104,7 +105,7 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick onSelect({ collection: selectedCollection, id: item.id, - title: getItemTitle(item), + title: getEntryTitle(item, displayField), }); onOpenChange(false); }; @@ -193,7 +194,7 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick "focus:outline-none focus:ring-2 focus:ring-kumo-ring focus:ring-offset-2", )} > -
{getItemTitle(item)}
+
{getEntryTitle(item, displayField)}
; slug: string | null; id: string }, + displayField?: string, +): string { + const preferred = displayField ? item.data[displayField] : undefined; + const rawTitle = item.data.title; + const rawName = item.data.name; + return ( + (typeof preferred === "string" ? preferred : "") || + (typeof rawTitle === "string" ? rawTitle : "") || + (typeof rawName === "string" ? rawName : "") || + item.slug || + item.id + ); +} diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index 280ebadb20..9c65503809 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -334,13 +334,18 @@ function ContentListPage() { // Default to defaultLocale when i18n is enabled and no locale specified const activeLocale = i18n ? (localeParam ?? i18n.defaultLocale) : undefined; - + // Controlled sort state — passed to the list, and included in the query // key so changing direction invalidates the current cursor chain. - const [sort, setSort] = React.useState({ - field: "updatedAt", + // Default sorts by the collection's dateField (#1133), else last-updated. + // `sortOverride` is the user's explicit choice (null until they click a + // column), keeping the default reactive as the manifest loads and per-collection. + const [sortOverride, setSortOverride] = React.useState(null); + const sort: ContentListSort = sortOverride ?? { + field: manifest?.collections[collection]?.dateField ?? "updatedAt", direction: "desc", - }); + }; + React.useEffect(() => setSortOverride(null), [collection]); // Server-side search term (debounced inside ContentList). Part of the query // key so a new term restarts the cursor chain from a filtered first page. @@ -598,8 +603,10 @@ function ContentListPage() { activeLocale={activeLocale} onLocaleChange={handleLocaleChange} urlPattern={collectionConfig.urlPattern} + displayField={collectionConfig.displayField} + dateField={collectionConfig.dateField} sort={sort} - onSortChange={setSort} + onSortChange={setSortOverride} total={total} onSearchChange={setSearchTerm} statusFilter={statusFilter} diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index e4eccc4389..64fd1a2ec9 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -9,7 +9,7 @@ import { isSqlite } from "../../database/dialect-helpers.js"; import { BylineRepository } from "../../database/repositories/byline.js"; import type { ContentBylineInput } from "../../database/repositories/byline.js"; import { CommentRepository } from "../../database/repositories/comment.js"; -import { ContentRepository } from "../../database/repositories/content.js"; +import { ContentRepository, isSystemOrderField } from "../../database/repositories/content.js"; import { RedirectRepository } from "../../database/repositories/redirect.js"; import { RevisionRepository } from "../../database/repositories/revision.js"; import { SeoRepository } from "../../database/repositories/seo.js"; @@ -447,6 +447,21 @@ export async function handleContentList( where.useFts = await canUseFtsForListFilter(db, collection, where.searchColumns); } + // Sorting by a non-system field (a collection's displayField/dateField, + // #1133) needs the collection's *actual* sort fields resolved server-side, + // so the orderBy set stays closed. Only query when it's not a system field. + let sortableExtras: string[] | undefined; + if (params.orderBy && !isSystemOrderField(params.orderBy)) { + const coll = await db + .selectFrom("_emdash_collections") + .select(["display_field", "date_field"]) + .where("slug", "=", collection) + .executeTakeFirst(); + sortableExtras = [coll?.display_field, coll?.date_field].filter( + (slug): slug is string => !!slug, + ); + } + const result = await repo.findMany(collection, { cursor: params.cursor, limit: params.limit || 50, @@ -454,6 +469,7 @@ export async function handleContentList( orderBy: params.orderBy ? { field: params.orderBy, direction: params.order || "desc" } : undefined, + sortableExtras, }); // Hydrate SEO data if the collection has SEO enabled diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index f3cd045a5e..48f3580252 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -29,6 +29,8 @@ export interface ManifestCollection { supports: string[]; hasSeo: boolean; urlPattern?: string; + displayField?: string; + dateField?: string; fields: Record< string, { diff --git a/packages/core/src/database/migrations/051_collection_display_date_fields.ts b/packages/core/src/database/migrations/051_collection_display_date_fields.ts new file mode 100644 index 0000000000..7c171c8846 --- /dev/null +++ b/packages/core/src/database/migrations/051_collection_display_date_fields.ts @@ -0,0 +1,32 @@ +import type { Kysely } from "kysely"; +import { sql } from "kysely"; + +/** + * Migration: configurable display and date fields for collections + * + * Adds `display_field` and `date_field` columns to `_emdash_collections` so a + * collection can override which field powers the Title column and which field + * powers the Date column in the admin content list. Both are nullable — NULL + * preserves the current defaults (title-style display, last-updated date). + */ +export async function up(db: Kysely): Promise { + await sql` + ALTER TABLE _emdash_collections + ADD COLUMN display_field TEXT + `.execute(db); + await sql` + ALTER TABLE _emdash_collections + ADD COLUMN date_field TEXT + `.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql` + ALTER TABLE _emdash_collections + DROP COLUMN date_field + `.execute(db); + await sql` + ALTER TABLE _emdash_collections + DROP COLUMN display_field + `.execute(db); +} diff --git a/packages/core/src/database/migrations/runner.ts b/packages/core/src/database/migrations/runner.ts index e2f0e63d58..729b505ca4 100644 --- a/packages/core/src/database/migrations/runner.ts +++ b/packages/core/src/database/migrations/runner.ts @@ -53,6 +53,7 @@ import * as m047 from "./047_restore_taxonomy_parent_index.js"; import * as m048 from "./048_restore_content_taxonomies_term_index.js"; import * as m049 from "./049_taxonomies_name_locale_index.js"; import * as m050 from "./050_media_usage_index_status.js"; +import * as m051 from "./051_collection_display_date_fields.js"; const MIGRATIONS: Readonly> = Object.freeze({ "001_initial": m001, @@ -104,6 +105,7 @@ const MIGRATIONS: Readonly> = Object.freeze({ "048_restore_content_taxonomies_term_index": m048, "049_taxonomies_name_locale_index": m049, "050_media_usage_index_status": m050, + "051_collection_display_date_fields": m051, }); /** Total number of registered migrations. Exported for use in tests. */ diff --git a/packages/core/src/database/repositories/content.ts b/packages/core/src/database/repositories/content.ts index 7e0ded0877..78c25746f2 100644 --- a/packages/core/src/database/repositories/content.ts +++ b/packages/core/src/database/repositories/content.ts @@ -42,6 +42,29 @@ const DATE_FILTER_COLUMNS: Record = { + createdAt: "created_at", + updatedAt: "updated_at", + publishedAt: "published_at", + scheduledAt: "scheduled_at", + deletedAt: "deleted_at", + title: "title", + name: "name", + slug: "slug", + status: "status", + locale: "locale", +}; + +/** True when `field` maps to a system column and needs no per-collection resolution. */ +export function isSystemOrderField(field: string): boolean { + return field in ORDER_FIELD_COLUMNS; +} + /** * System columns that exist in every ec_* table */ @@ -501,7 +524,7 @@ export class ContentRepository { // Determine ordering const orderField = options.orderBy?.field || "createdAt"; const orderDirection = options.orderBy?.direction || "desc"; - const dbField = this.mapOrderField(orderField); + const dbField = this.mapOrderField(orderField, options.sortableExtras); // Validate order direction to prevent injection const safeOrderDirection = orderDirection.toLowerCase() === "asc" ? "ASC" : "DESC"; @@ -1600,24 +1623,20 @@ export class ContentRepository { * Map order field names to database columns. * Only allows known fields to prevent column enumeration via crafted orderBy values. */ - private mapOrderField(field: string): string { - const mapping: Record = { - createdAt: "created_at", - updatedAt: "updated_at", - publishedAt: "published_at", - scheduledAt: "scheduled_at", - deletedAt: "deleted_at", - title: "title", - name: "name", - slug: "slug", - status: "status", - locale: "locale", - }; - - const mapped = mapping[field]; - if (!mapped) { - throw new EmDashValidationError(`Invalid order field: ${field}`); + private mapOrderField(field: string, sortableExtras: readonly string[] = []): string { + const mapped = ORDER_FIELD_COLUMNS[field]; + if (mapped) return mapped; + + // A collection's configured displayField/dateField (#1133) are allowed as + // sort columns. The caller passes the collection's *actual* values (resolved + // server-side, never client-supplied), so this stays a closed set per + // request and doesn't reopen the column-enumeration hole. The slug is a + // validated identifier that maps directly to the column. + if (sortableExtras.includes(field)) { + validateIdentifier(field, "order field"); + return field; } - return mapped; + + throw new EmDashValidationError(`Invalid order field: ${field}`); } } diff --git a/packages/core/src/database/repositories/types.ts b/packages/core/src/database/repositories/types.ts index 5d7bc1d468..6685b8f879 100644 --- a/packages/core/src/database/repositories/types.ts +++ b/packages/core/src/database/repositories/types.ts @@ -173,6 +173,12 @@ export interface FindManyOptions { field: string; direction: "asc" | "desc"; }; + /** + * Extra field slugs allowed as `orderBy` beyond the system columns — the + * collection's configured displayField/dateField (#1133). Resolved by the + * handler server-side so `orderBy` stays a closed set per request. + */ + sortableExtras?: string[]; limit?: number; cursor?: string; // Base64-encoded JSON: {orderValue: string, id: string} } diff --git a/packages/core/src/database/types.ts b/packages/core/src/database/types.ts index 9b5eeac3f4..16a6bcc1e1 100644 --- a/packages/core/src/database/types.ts +++ b/packages/core/src/database/types.ts @@ -274,6 +274,8 @@ export interface CollectionTable { source: string | null; search_config: string | null; // JSON: { enabled: boolean, weights: Record } has_seo: number; // 0 or 1 — opt-in SEO fields for this collection + display_field: string | null; // field slug for the admin list Title column (NULL = default) + date_field: string | null; // field slug (datetime) for the admin list Date column (NULL = default) url_pattern: string | null; // URL pattern with {slug} placeholder (e.g. "/blog/{slug}") comments_enabled: Generated; // 0 or 1 comments_moderation: Generated; // 'all' | 'first_time' | 'none' diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index ecf6fa0c82..7198d7eae9 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -2273,6 +2273,8 @@ export class EmDashRuntime { supports: collection.supports || [], hasSeo: collection.hasSeo, urlPattern: collection.urlPattern, + displayField: collection.displayField, + dateField: collection.dateField, fields, }; } diff --git a/packages/core/src/schema/registry.ts b/packages/core/src/schema/registry.ts index a98672eefc..74391f4aae 100644 --- a/packages/core/src/schema/registry.ts +++ b/packages/core/src/schema/registry.ts @@ -203,6 +203,51 @@ export class SchemaRegistry { })); } + /** + * Validate `displayField`/`dateField` against the collection's fields: + * `displayField` must be a real field; `dateField` must be a `datetime` field. + * Only truthy values are checked (undefined = unchanged, null/"" = cleared). + */ + private async validateDisplayDateFields( + collectionId: string, + collectionSlug: string, + input: { displayField?: string | null; dateField?: string | null }, + ): Promise { + const slugs = [input.displayField, input.dateField].filter((slug): slug is string => !!slug); + if (slugs.length === 0) return; + + const rows = await this.db + .selectFrom("_emdash_fields") + .where("collection_id", "=", collectionId) + .where("slug", "in", slugs) + .select(["slug", "type"]) + .execute(); + const typeBySlug = new Map(rows.map((row) => [row.slug, row.type])); + + if (input.displayField && !typeBySlug.has(input.displayField)) { + throw new SchemaError( + `displayField "${input.displayField}" is not a field on "${collectionSlug}"`, + "INVALID_DISPLAY_FIELD", + ); + } + + if (input.dateField) { + const type = typeBySlug.get(input.dateField); + if (type === undefined) { + throw new SchemaError( + `dateField "${input.dateField}" is not a field on "${collectionSlug}"`, + "INVALID_DATE_FIELD", + ); + } + if (type !== "datetime") { + throw new SchemaError( + `dateField "${input.dateField}" must be a datetime field (got "${type}")`, + "INVALID_DATE_FIELD", + ); + } + } + } + /** * Create a new collection */ @@ -273,6 +318,13 @@ export class SchemaRegistry { throw new SchemaError(`Collection "${slug}" not found`, "COLLECTION_NOT_FOUND"); } + // Fields exist by update time, so this is where display/date fields are + // strictly validated (fail fast, before opening the transaction). + await this.validateDisplayDateFields(existing.id, slug, { + displayField: input.displayField, + dateField: input.dateField, + }); + const now = new Date().toISOString(); // Derive hasSeo from supports array if supports is being updated and hasSeo not explicitly set @@ -299,6 +351,13 @@ export class SchemaRegistry { input.urlPattern !== undefined ? (input.urlPattern ?? null) : (existing.urlPattern ?? null), + // `|| null` (not `?? null`): "" clears back to the default. + display_field: + input.displayField !== undefined + ? input.displayField || null + : (existing.displayField ?? null), + date_field: + input.dateField !== undefined ? input.dateField || null : (existing.dateField ?? null), has_seo: hasSeo ? 1 : 0, comments_enabled: input.commentsEnabled !== undefined @@ -1091,6 +1150,10 @@ export class SchemaRegistry { supports: parseSupports(row.supports), source: row.source && isCollectionSource(row.source) ? row.source : undefined, hasSeo: row.has_seo === 1, + // Raw value; undefined when unset. The admin list resolves the + // default (title fallback chain / updatedAt) at the point of use. + displayField: row.display_field ?? undefined, + dateField: row.date_field ?? undefined, urlPattern: row.url_pattern ?? undefined, commentsEnabled: row.comments_enabled === 1, commentsModeration: diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 6d5055e981..2448d8bc22 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -169,6 +169,10 @@ export interface Collection { source?: CollectionSource; /** Whether this collection has SEO metadata fields enabled */ hasSeo: boolean; + /** Field slug powering the admin list Title column. Defaults to the standard title display. */ + displayField?: string; + /** Field slug powering the admin list Date column. Must be a `datetime` field. Defaults to last-updated. */ + dateField?: string; /** URL pattern with {slug} placeholder (e.g. "/{slug}", "/blog/{slug}") */ urlPattern?: string; /** Whether comments are enabled for this collection */ @@ -237,6 +241,10 @@ export interface UpdateCollectionInput { commentsModeration?: "all" | "first_time" | "none"; commentsClosedAfterDays?: number; commentsAutoApproveUsers?: boolean; + /** Field slug for the Title column; `null`/`""` clears back to the default. */ + displayField?: string | null; + /** Datetime field slug for the Date column; `null`/`""` clears back to the default. */ + dateField?: string | null; } /** diff --git a/packages/core/src/search/query.ts b/packages/core/src/search/query.ts index 8b1f01236d..74e670fd6d 100644 --- a/packages/core/src/search/query.ts +++ b/packages/core/src/search/query.ts @@ -146,6 +146,7 @@ export async function searchWithDb( // Search each collection and merge results const allResults: SearchResult[] = []; const titleColumns = await ftsManager.getCollectionsWithTitleColumn(collections); + const displayFields = await getDisplayFieldMap(db, collections); for (const collection of collections) { const config = await ftsManager.getSearchConfig(collection); @@ -164,6 +165,7 @@ export async function searchWithDb( }, config.weights, titleColumns.has(collection), + displayFields.get(collection), ); allResults.push(...collectionResults); @@ -213,6 +215,8 @@ export async function searchCollection( const limit = options.limit ?? 20; const offset = options.cursor ? decodeSearchOffset(options.cursor) : 0; + const displayField = (await getDisplayFieldMap(db, [collection])).get(collection); + // Over-fetch the [offset, offset + limit) window plus one row to detect a // further page, then slice. Keeps the FTS SQL (LIMIT-only) unchanged and // the cursor contract identical to the cross-collection path. @@ -222,6 +226,8 @@ export async function searchCollection( query, { status: options.status, locale: options.locale, limit: offset + limit + 1 }, config.weights, + undefined, + displayField, ); const items = fetched.slice(offset, offset + limit); @@ -241,6 +247,7 @@ async function searchSingleCollection( options: CollectionSearchOptions, weights?: Record, hasTitle?: boolean, + displayField?: string, ): Promise { // Validate before any raw SQL interpolation validateIdentifier(collection, "collection slug"); @@ -271,8 +278,17 @@ async function searchSingleCollection( // errors with "no such column: c.title" (#1178). Multi-collection callers // precompute this in bulk and pass it in; single-collection callers fall // back to the per-collection check. - const collectionHasTitle = hasTitle ?? (await ftsManager.hasTitleColumn(collection)); - const titleExpr = collectionHasTitle ? sql`c.title` : sql`NULL`; + // A configured displayField (#1133) drives the result title so search shows + // the same value as the content list. It's a validated existing field, so + // the column exists; otherwise fall back to the optional `title` column. + let titleExpr; + if (displayField) { + validateIdentifier(displayField, "display field"); + titleExpr = sql`c.${sql.ref(displayField)}`; + } else { + const collectionHasTitle = hasTitle ?? (await ftsManager.hasTitleColumn(collection)); + titleExpr = collectionHasTitle ? sql`c.title` : sql`NULL`; + } // Build weight string for bm25 if weights provided // Format: bm25(table, weight1, weight2, ...) @@ -503,6 +519,28 @@ export async function getSearchStats(db: Kysely): Promise return stats; } +/** + * Map each collection to its configured `displayField` (#1133), so search + * results show the same title as the content list. One query for all + * collections in the search set; collections without a displayField are omitted. + */ +async function getDisplayFieldMap( + db: Kysely, + collections: string[], +): Promise> { + const map = new Map(); + if (collections.length === 0) return map; + const rows = await db + .selectFrom("_emdash_collections") + .select(["slug", "display_field"]) + .where("slug", "in", collections) + .execute(); + for (const row of rows) { + if (row.display_field) map.set(row.slug, row.display_field); + } + return map; +} + /** * Get list of collections with search enabled */ diff --git a/packages/core/src/seed/apply.ts b/packages/core/src/seed/apply.ts index 091c7e2500..dd97dfadc9 100644 --- a/packages/core/src/seed/apply.ts +++ b/packages/core/src/seed/apply.ts @@ -30,6 +30,7 @@ import type { SeedFile, SeedApplyOptions, SeedApplyResult, + SeedCollection, SeedTaxonomyTerm, SeedMenuItem, SeedWidget, @@ -37,6 +38,21 @@ import type { SeedBylineAvatar, } from "./types.js"; +/** + * Set a collection's `displayField`/`dateField`: a separate write run after the + * fields exist, so `updateCollection` can validate them. No-op when neither is set. + */ +async function applyDisplayDateFields( + registry: SchemaRegistry, + collection: SeedCollection, +): Promise { + if (collection.displayField === undefined && collection.dateField === undefined) return; + await registry.updateCollection(collection.slug, { + displayField: collection.displayField, + dateField: collection.dateField, + }); +} + const FILE_EXTENSION_PATTERN = /\.([a-z0-9]+)(?:\?|$)/i; import { validateSeed } from "./validate.js"; @@ -215,6 +231,9 @@ export async function applySeed( result.fields.created++; } } + + // Second write: display/date fields, now that fields exist. + await applyDisplayDateFields(registry, collection); continue; } @@ -254,6 +273,8 @@ export async function applySeed( }); result.fields.created++; } + + await applyDisplayDateFields(registry, collection); } } diff --git a/packages/core/src/seed/types.ts b/packages/core/src/seed/types.ts index a106bfcd51..20925b6812 100644 --- a/packages/core/src/seed/types.ts +++ b/packages/core/src/seed/types.ts @@ -76,6 +76,10 @@ export interface SeedCollection { urlPattern?: string; /** Enable comments on this collection */ commentsEnabled?: boolean; + /** Field slug powering the admin list Title column (defaults to title display) */ + displayField?: string; + /** Field slug (a datetime field) powering the admin list Date column (defaults to last-updated) */ + dateField?: string; fields: SeedField[]; } diff --git a/packages/core/tests/integration/content/display-date-sort.test.ts b/packages/core/tests/integration/content/display-date-sort.test.ts new file mode 100644 index 0000000000..b036cce31f --- /dev/null +++ b/packages/core/tests/integration/content/display-date-sort.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, afterEach, expect, it } from "vitest"; + +import { handleContentCreate, handleContentList } from "../../../src/api/handlers/content.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +// #1133: a collection's configured `dateField` drives the admin list's default +// sort, and its displayField/dateField are the only non-system fields allowed +// as `orderBy`: a closed, server-resolved set (no column enumeration). +describeEachDialect("content list custom-field sort (#1133)", (dialect) => { + let ctx: DialectTestContext; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "events", label: "Events", labelSingular: "Event" }); + await registry.createField("events", { slug: "title", label: "Title", type: "string" }); + await registry.createField("events", { slug: "event_date", label: "Date", type: "datetime" }); + await registry.createField("events", { slug: "location", label: "Location", type: "string" }); + await registry.updateCollection("events", { dateField: "event_date" }); + + // createdAt order (e1 newest) is deliberately the reverse of event_date + // order, so sorting by event_date can't be confused with the default. + const seed = [ + { slug: "e1", event_date: "2020-01-01T00:00:00.000Z", createdAt: "2025-06-01T00:00:00.000Z" }, + { slug: "e2", event_date: "2022-01-01T00:00:00.000Z", createdAt: "2024-06-01T00:00:00.000Z" }, + { slug: "e3", event_date: "2021-01-01T00:00:00.000Z", createdAt: "2023-06-01T00:00:00.000Z" }, + ]; + for (const s of seed) { + const created = await handleContentCreate(ctx.db, "events", { + slug: s.slug, + data: { title: s.slug, event_date: s.event_date, location: "HQ" }, + createdAt: s.createdAt, + }); + if (!created.success) throw new Error(`seed ${s.slug} failed`); + } + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + function slugsOf(result: Awaited>): string[] { + if (!result.success) throw new Error(`list failed: ${result.error.code}`); + return result.data.items.map((i) => i.slug ?? ""); + } + + it("sorts by the configured dateField, not createdAt", async () => { + const desc = await handleContentList(ctx.db, "events", { + orderBy: "event_date", + order: "desc", + }); + expect(slugsOf(desc)).toEqual(["e2", "e3", "e1"]); // 2022, 2021, 2020 + + const asc = await handleContentList(ctx.db, "events", { orderBy: "event_date", order: "asc" }); + expect(slugsOf(asc)).toEqual(["e1", "e3", "e2"]); + }); + + it("rejects ordering by a field that isn't displayField/dateField", async () => { + // `location` is a real field but not a configured sort field. + const result = await handleContentList(ctx.db, "events", { orderBy: "location" }); + expect(result.success).toBe(false); + }); + + it("rejects ordering by an unknown field", async () => { + const result = await handleContentList(ctx.db, "events", { orderBy: "not_a_field" }); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/core/tests/integration/search/display-field-title.test.ts b/packages/core/tests/integration/search/display-field-title.test.ts new file mode 100644 index 0000000000..c084638ed9 --- /dev/null +++ b/packages/core/tests/integration/search/display-field-title.test.ts @@ -0,0 +1,63 @@ +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { ContentRepository } from "../../../src/database/repositories/content.js"; +import type { Database } from "../../../src/database/types.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { FTSManager } from "../../../src/search/fts-manager.js"; +import { searchWithDb } from "../../../src/search/query.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +/** + * #1133: search results should show the same title as the content list. When a + * collection sets `displayField`, the result title comes from that field's + * column, not the physical `title` column. + */ +describe("search: displayField drives the result title (#1133)", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabase(); + const registry = new SchemaRegistry(db); + const fts = new FTSManager(db); + + await registry.createCollection({ + slug: "employees", + label: "Employees", + supports: ["search"], + }); + await registry.createField("employees", { + slug: "name", + label: "Name", + type: "string", + searchable: true, + }); + await registry.createField("employees", { + slug: "title", + label: "Job Title", + type: "string", + searchable: true, + }); + await registry.updateCollection("employees", { displayField: "name" }); + await fts.enableSearch("employees"); + + const repo = new ContentRepository(db); + await repo.create({ + type: "employees", + slug: "amy", + status: "published", + data: { name: "Amy Morse", title: "Commercial Lines Agent" }, + }); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + }); + + it("returns the displayField value as the result title, not the title column", async () => { + const res = await searchWithDb(db, "Amy"); + const hit = res.items.find((i) => i.collection === "employees"); + expect(hit?.title).toBe("Amy Morse"); + expect(hit?.title).not.toBe("Commercial Lines Agent"); + }); +}); diff --git a/packages/core/tests/unit/schema/collection-display-date-fields.test.ts b/packages/core/tests/unit/schema/collection-display-date-fields.test.ts new file mode 100644 index 0000000000..3b3d8bfedd --- /dev/null +++ b/packages/core/tests/unit/schema/collection-display-date-fields.test.ts @@ -0,0 +1,96 @@ +/** + * #1133: a collection's `displayField`/`dateField` override the admin list's + * Title and Date columns. Update-only (fields must exist first), so + * `updateCollection` validates: displayField = a real field, dateField = a + * `datetime` field; `null`/`""` clears to default; unset stays undefined. + */ +import Database from "better-sqlite3"; +import { Kysely, SqliteDialect } from "kysely"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; + +import { runMigrations } from "../../../src/database/migrations/runner.js"; +import type { Database as EmDashDatabase } from "../../../src/database/types.js"; +import { SchemaRegistry, SchemaError } from "../../../src/schema/registry.js"; + +describe("collection displayField/dateField (#1133)", () => { + let db: Kysely; + let registry: SchemaRegistry; + + beforeEach(async () => { + const sqlite = new Database(":memory:"); + db = new Kysely({ dialect: new SqliteDialect({ database: sqlite }) }); + await runMigrations(db); + registry = new SchemaRegistry(db); + + await registry.createCollection({ + slug: "employees", + label: "Employees", + supports: ["drafts"], + }); + await registry.createField("employees", { slug: "name", label: "Name", type: "string" }); + await registry.createField("employees", { slug: "title", label: "Job Title", type: "string" }); + await registry.createField("employees", { + slug: "pub_date", + label: "Start Date", + type: "datetime", + }); + }); + + afterEach(async () => { + await db.destroy(); + }); + + it("defaults to undefined when unset", async () => { + const collection = await registry.getCollection("employees"); + expect(collection?.displayField).toBeUndefined(); + expect(collection?.dateField).toBeUndefined(); + }); + + it("updateCollection sets displayField and dateField to valid fields", async () => { + const updated = await registry.updateCollection("employees", { + displayField: "name", + dateField: "pub_date", + }); + expect(updated.displayField).toBe("name"); + expect(updated.dateField).toBe("pub_date"); + + const reread = await registry.getCollection("employees"); + expect(reread?.displayField).toBe("name"); + expect(reread?.dateField).toBe("pub_date"); + }); + + it("clears displayField/dateField back to default with null", async () => { + await registry.updateCollection("employees", { displayField: "name", dateField: "pub_date" }); + const cleared = await registry.updateCollection("employees", { + displayField: null, + dateField: null, + }); + expect(cleared.displayField).toBeUndefined(); + expect(cleared.dateField).toBeUndefined(); + }); + + it("leaves displayField/dateField unchanged on an unrelated update", async () => { + await registry.updateCollection("employees", { displayField: "name", dateField: "pub_date" }); + const updated = await registry.updateCollection("employees", { label: "Team" }); + expect(updated.displayField).toBe("name"); + expect(updated.dateField).toBe("pub_date"); + }); + + it("updateCollection rejects a displayField that does not exist", async () => { + await expect( + registry.updateCollection("employees", { displayField: "nonexistent" }), + ).rejects.toBeInstanceOf(SchemaError); + }); + + it("updateCollection rejects a dateField that is not a datetime field", async () => { + await expect( + registry.updateCollection("employees", { dateField: "title" }), + ).rejects.toBeInstanceOf(SchemaError); + }); + + it("updateCollection rejects a dateField that does not exist", async () => { + await expect( + registry.updateCollection("employees", { dateField: "nope" }), + ).rejects.toBeInstanceOf(SchemaError); + }); +}); From 3d5b62ba333291af95f6fbce941e12cbbe2c2de9 Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Sun, 12 Jul 2026 09:48:13 +0000 Subject: [PATCH 02/13] style: format --- packages/admin/src/router.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index 9c65503809..b36f6e905d 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -334,7 +334,7 @@ function ContentListPage() { // Default to defaultLocale when i18n is enabled and no locale specified const activeLocale = i18n ? (localeParam ?? i18n.defaultLocale) : undefined; - + // Controlled sort state — passed to the list, and included in the query // key so changing direction invalidates the current cursor chain. // Default sorts by the collection's dateField (#1133), else last-updated. From b7d853f78a1b3e91639f74f7556ac526dc76ea28 Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Sun, 12 Jul 2026 09:52:25 +0000 Subject: [PATCH 03/13] ci: update query-count snapshots --- scripts/query-counts.queries.d1.json | 2 ++ scripts/query-counts.queries.sqlite.json | 2 ++ scripts/query-counts.snapshot.d1.json | 4 ++-- scripts/query-counts.snapshot.sqlite.json | 4 ++-- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/scripts/query-counts.queries.d1.json b/scripts/query-counts.queries.d1.json index 8817345ae2..a350f56b9a 100644 --- a/scripts/query-counts.queries.d1.json +++ b/scripts/query-counts.queries.d1.json @@ -226,6 +226,7 @@ "select \"plugin_id\", \"status\" from \"_plugin_state\"": 1, "select \"search_config\" from \"_emdash_collections\" where \"slug\" = ?": 1, "select \"slug\" from \"_emdash_fields\" where \"collection_id\" = ? and \"searchable\" = ?": 1, + "select \"slug\", \"display_field\" from \"_emdash_collections\" where \"slug\" in (...)": 1, "select \"value\" from \"options\" where \"name\" = ?": 2, "select * from \"_emdash_byline_fields\" order by \"sort_order\" asc, \"created_at\" asc": 1, "select * from \"_emdash_menu_items\" where \"menu_id\" = ? order by \"sort_order\" asc": 1, @@ -245,6 +246,7 @@ "select \"id\" from \"_emdash_collections\" where \"slug\" = ?": 1, "select \"search_config\" from \"_emdash_collections\" where \"slug\" = ?": 1, "select \"slug\" from \"_emdash_fields\" where \"collection_id\" = ? and \"searchable\" = ?": 1, + "select \"slug\", \"display_field\" from \"_emdash_collections\" where \"slug\" in (...)": 1, "select \"value\" from \"options\" where \"name\" = ?": 1, "select * from \"_emdash_menu_items\" where \"menu_id\" = ? order by \"sort_order\" asc": 1, "select * from \"_emdash_menus\" where \"name\" = ? order by \"locale\" asc": 1, diff --git a/scripts/query-counts.queries.sqlite.json b/scripts/query-counts.queries.sqlite.json index 293d6a8839..484153ca75 100644 --- a/scripts/query-counts.queries.sqlite.json +++ b/scripts/query-counts.queries.sqlite.json @@ -152,6 +152,7 @@ "select \"id\" from \"_emdash_collections\" where \"slug\" = ?": 1, "select \"search_config\" from \"_emdash_collections\" where \"slug\" = ?": 1, "select \"slug\" from \"_emdash_fields\" where \"collection_id\" = ? and \"searchable\" = ?": 1, + "select \"slug\", \"display_field\" from \"_emdash_collections\" where \"slug\" in (...)": 1, "select \"value\" from \"options\" where \"name\" = ?": 1, "select * from \"_emdash_menu_items\" where \"menu_id\" = ? order by \"sort_order\" asc": 1, "select * from \"_emdash_menus\" where \"name\" = ? order by \"locale\" asc": 1, @@ -165,6 +166,7 @@ "select \"id\" from \"_emdash_collections\" where \"slug\" = ?": 1, "select \"search_config\" from \"_emdash_collections\" where \"slug\" = ?": 1, "select \"slug\" from \"_emdash_fields\" where \"collection_id\" = ? and \"searchable\" = ?": 1, + "select \"slug\", \"display_field\" from \"_emdash_collections\" where \"slug\" in (...)": 1, "select \"value\" from \"options\" where \"name\" = ?": 1, "select * from \"_emdash_menu_items\" where \"menu_id\" = ? order by \"sort_order\" asc": 1, "select * from \"_emdash_menus\" where \"name\" = ? order by \"locale\" asc": 1, diff --git a/scripts/query-counts.snapshot.d1.json b/scripts/query-counts.snapshot.d1.json index b93ebe8f6d..b60ecb4af7 100644 --- a/scripts/query-counts.snapshot.d1.json +++ b/scripts/query-counts.snapshot.d1.json @@ -15,8 +15,8 @@ "GET /posts/building-for-the-long-term (warm)": 18, "GET /rss.xml (cold)": 12, "GET /rss.xml (warm)": 2, - "GET /search (cold)": 22, - "GET /search (warm)": 11, + "GET /search (cold)": 23, + "GET /search (warm)": 12, "GET /tag/webdev (cold)": 22, "GET /tag/webdev (warm)": 10 } diff --git a/scripts/query-counts.snapshot.sqlite.json b/scripts/query-counts.snapshot.sqlite.json index 222f7d5ddf..2094bbf1f3 100644 --- a/scripts/query-counts.snapshot.sqlite.json +++ b/scripts/query-counts.snapshot.sqlite.json @@ -15,8 +15,8 @@ "GET /posts/building-for-the-long-term (warm)": 18, "GET /rss.xml (cold)": 2, "GET /rss.xml (warm)": 2, - "GET /search (cold)": 11, - "GET /search (warm)": 11, + "GET /search (cold)": 12, + "GET /search (warm)": 12, "GET /tag/webdev (cold)": 10, "GET /tag/webdev (warm)": 10 } From 562c0b5eca83108d360a301d69ea65403ab81ed7 Mon Sep 17 00:00:00 2001 From: CacheMeOwside Date: Sun, 12 Jul 2026 16:25:59 +0530 Subject: [PATCH 04/13] add fixes as per PR comments --- .../admin/src/components/ContentEditor.tsx | 7 +-- packages/admin/src/components/ContentList.tsx | 2 +- .../src/components/ContentPickerModal.tsx | 2 +- .../051_collection_display_date_fields.ts | 45 ++++++++++++------- packages/core/src/schema/registry.ts | 43 +++++++++++++++--- packages/core/src/search/fts-manager.ts | 3 +- packages/core/src/search/query.ts | 29 +----------- packages/core/src/search/types.ts | 2 + .../integration/database/migrations.test.ts | 1 + .../collection-display-date-fields.test.ts | 22 +++++++++ 10 files changed, 101 insertions(+), 55 deletions(-) diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index c8594899cd..8d0d63ed16 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -513,10 +513,11 @@ export function ContentEditor({ const urlPattern = manifest?.collections[collection]?.urlPattern; - // The editor header shows the entry's display title (honoring displayField) - // for existing entries (#1133). + // When the collection configures a displayField (#1133), the editor header + // shows the entry's title for existing entries; otherwise it keeps the + // generic "Edit