diff --git a/.changeset/record-title-drop-undeclared-objectdef-titlefield-6531.md b/.changeset/record-title-drop-undeclared-objectdef-titlefield-6531.md new file mode 100644 index 000000000..c9b1b8e39 --- /dev/null +++ b/.changeset/record-title-drop-undeclared-objectdef-titlefield-6531.md @@ -0,0 +1,34 @@ +--- +'@object-ui/core': patch +--- + +`getRecordDisplayName`: stop consulting the undeclared object-level +`titleField`, restoring `nameField` as the top of the object ladder + +Step 0 of the unified record-title resolver read +`options?.titleField ?? objectDef?.titleField`. The second leg ranked an +object-level `titleField` above `nameField` — the pointer ADR-0079 Phase 2 made +canonical — and above the deprecated `displayNameField` alias and the legacy +`titleFormat` template. + +`@objectstack/spec`'s object schema does not declare that key, and it is not +merely undeclared: the schema is a `strictObject`, so +`ObjectSchema.safeParse({ …, titleField: 'x' })` fails with `unrecognized_keys` +— the same code a nonsense key gets — while `nameField`, `displayNameField` and +`titleFormat` all parse and survive. A producer census across both repos found +nothing that puts the key on an object-shaped payload: not the metadata, not any +`getObjectSchema` implementation (the ObjectStack adapter stamps only reference +keys and field-widget hints), not the lookup-chip path, not the +search-candidate path, and not the platform's own server-side resolver +(`@objectstack/objectql#titleFieldOf` reads `nameField` → `displayNameField`). +Reading a key no producer can ship is a consumer-side alias — the shape +Commandment #0.1 bans — and it inverted the governed-authority default on top of +that. + +No authoring surface changes and no view loses its author-chosen title field: +`titleField` remains a real, declared VIEW key (`ui/CalendarConfig`, +`ui/GalleryConfig`, `ui/GanttConfig`, `ui/ListMapConfig`, +`ui/ObjectKanbanProps`, `ui/TimelineConfig`), views hand it in as +`options.titleField`, and that half of step 0 still wins over everything. +The behaviour change is confined to an object payload that carried a key the +contract rejects: it now resolves through the declared ladder instead. diff --git a/packages/core/src/utils/__tests__/record-title.test.ts b/packages/core/src/utils/__tests__/record-title.test.ts index 871eec473..342daad00 100644 --- a/packages/core/src/utils/__tests__/record-title.test.ts +++ b/packages/core/src/utils/__tests__/record-title.test.ts @@ -140,11 +140,20 @@ describe('getRecordDisplayName — precedence', () => { expect(getRecordDisplayName({}, { _id: 'abc' })).toBe('Record #abc'); }); - // Regression: a lightweight object (search candidate) that declares only - // `titleField`/`label` and carries no `fields` map must still resolve a - // standard `name`/`first_name` record value, NOT fall to `Record #`. - it("honors objectDef.titleField when set (object-level title hint)", () => { - const obj = { name: 'account', label: 'Account', titleField: 'name' }; + // Regression: a lightweight object (search candidate) that carries no + // `fields` map must still resolve a standard `name`/`full_name` record + // value, NOT fall to `Record #`. + // + // objectui#6531 — this pin used to be spelled + // `{ name: 'account', label: 'Account', titleField: 'name' }` under the name + // "honors objectDef.titleField when set". It never measured that: `name` is + // also a {@link NAME_ISH_RECORD_KEYS} entry, so step 4b answered it + // identically with the step-0 `objectDef.titleField` read deleted. A pin that + // passes for a reason unrelated to its own name is how an undeclared key + // survives a rewrite, so the key is gone from the fixture and the real + // negative pin lives in the "undeclared object-level titleField" block below. + it('resolves a lightweight object with no `fields` map via the record name keys', () => { + const obj = { name: 'account', label: 'Account' }; expect(getRecordDisplayName(obj, { id: 'a1', name: 'Acme Corp' })).toBe('Acme Corp'); }); @@ -201,6 +210,89 @@ describe('getRecordDisplayName — ADR-0079 Phase 2 (nameField canonical)', () = }); }); +describe('getRecordDisplayName — the undeclared object-level `titleField` (objectui#6531)', () => { + // Producer census (objectui#6531): NOTHING puts `titleField` on an + // object-shaped payload. `@objectstack/spec`'s object schema is a + // `strictObject`, so the key is not merely undeclared — `ObjectSchema` + // REJECTS it with `unrecognized_keys`, the same code a nonsense key gets, + // while `nameField` / `displayNameField` / `titleFormat` parse. Reading it + // here was therefore a consumer-side alias for a key no producer can ship + // (AGENTS.md Commandment #0.1), ranked ABOVE the canonical `nameField` that + // ADR-0079 Phase 2 made the record-title pointer. + // + // Undeclared at the object level ≠ undeclared everywhere: `titleField` is a + // real, spec-declared VIEW key (`ui/CalendarConfig`, `ui/GalleryConfig`, + // `ui/GanttConfig`, `ui/ListMapConfig`, `ui/ObjectKanbanProps`, + // `ui/TimelineConfig`), and views hand it in as `options.titleField`. That + // leg of step 0 is untouched — the second pin below is what keeps the + // removal from over-reaching. + + it('does NOT consult `objectDef.titleField` — the declared `nameField` is the top of the ladder', () => { + const obj = { + name: 'account', + nameField: 'name', + // Undeclared, and rejected by `ObjectSchema.safeParse`. Before #6531 this + // was read at step 0 and beat `nameField` outright. + titleField: 'legacy_title', + fields: { name: { type: 'text' }, legacy_title: { type: 'text' } }, + }; + const rec = { id: 'a1', name: 'Canonical Name', legacy_title: 'Undeclared Alias' }; + expect(getRecordDisplayName(obj, rec)).toBe('Canonical Name'); + }); + + it('does NOT consult `objectDef.titleField` even when it is the ONLY pointer the object carries', () => { + // No `nameField`, no `displayNameField`, no `titleFormat`, no `fields` map, + // and `headline` is name-ish by NEITHER step-4b rung — not a + // {@link NAME_ISH_RECORD_KEYS} entry, and no `*_name` / `*_title` / `name_*` + // affix. So the undeclared key is the only thing left that could produce a + // title. It must not: the resolver floors instead, which is the honest + // answer for metadata the contract would have rejected outright. + // + // The field name matters and is load-bearing. This pin first used + // `legacy_title`, which step 4b(ii)'s `*_title` affix rule answers on its + // own — so it read as a failure of the removal when it was measuring the + // fixture instead. + const obj = { name: 'account', titleField: 'headline' }; + const rec = { id: 'a1', headline: 'Undeclared Alias', amount: 100 }; + expect(getRecordDisplayName(obj, rec)).toBe('Record #a1'); + }); + + it('CONTROL: the DECLARED view-level `options.titleField` still wins at step 0', () => { + // The half of step 0 that survives. A view's own `titleField` outranks the + // object's canonical `nameField` by design — that is the author's + // per-view choice, and it is a declared key on every view config. + const obj = { + name: 'account', + nameField: 'name', + fields: { name: { type: 'text' }, headline: { type: 'text' } }, + }; + const rec = { id: 'a1', name: 'Canonical Name', headline: 'From The View' }; + expect(getRecordDisplayName(obj, rec, { titleField: 'headline' })).toBe('From The View'); + }); + + it('CONTROL: an object carrying the undeclared key still resolves through every declared rung', () => { + // Removing the read must not disturb the rest of the ladder for an object + // that happens to carry the stray key. + const withAlias = { + titleField: 'legacy_title', + displayNameField: 'code', + fields: { code: { type: 'text' }, legacy_title: { type: 'text' } }, + }; + expect( + getRecordDisplayName(withAlias, { id: '1', code: 'ACC-1', legacy_title: 'Undeclared Alias' }), + ).toBe('ACC-1'); + + const withFormat = { + titleField: 'legacy_title', + titleFormat: '{code}', + fields: { code: { type: 'text' }, legacy_title: { type: 'text' } }, + }; + expect( + getRecordDisplayName(withFormat, { id: '1', code: 'ACC-1', legacy_title: 'Undeclared Alias' }), + ).toBe('ACC-1'); + }); +}); + describe('deriveTitleField — memoization (per objectDef, not per record)', () => { it('returns the same derived field across repeated calls for one objectDef', () => { const obj = { fields: { activity_name: { type: 'text' }, start_date: { type: 'date' } } }; diff --git a/packages/core/src/utils/record-title.ts b/packages/core/src/utils/record-title.ts index 72d15c387..c91fd4db9 100644 --- a/packages/core/src/utils/record-title.ts +++ b/packages/core/src/utils/record-title.ts @@ -23,8 +23,12 @@ * (`nameField`) to canonical and de-prioritizes the legacy render-only * `titleFormat` template: * + * 0. `options.titleField` — the caller's own DECLARED view-level pointer + * (gallery / calendar / gantt / map / kanban / timeline config). * 1. `objectDef.nameField` — the canonical record-title pointer (ADR-0079); - * a field the server can return and query, if present & non-empty. + * a field the server can return and query, if present & non-empty. With + * the undeclared `objectDef.titleField` read gone (objectui#6531) this is + * the top of the OBJECT-level ladder, exactly as ADR-0079 states. * 2. `objectDef.displayNameField` (alias `NAME_FIELD_KEY`) — deprecated alias * of `nameField`, kept for back-compat. * 3. `objectDef.titleFormat` — LEGACY render-only template, rendered via @@ -304,10 +308,16 @@ export function deriveTitleField(objectDef: any): string | undefined { /** * Standard name-ish keys probed directly on a *record* as a last resort before * the `Record #` floor — used when `objectDef.fields` is absent so - * {@link deriveTitleField} can't run (loosely-typed metadata, lightweight search - * candidates that carry only `name`/`titleField`, etc.). Mirrors the historical + * {@link deriveTitleField} can't run (loosely-typed metadata, the lightweight + * object stub `useRecordSearch` synthesizes for a hit whose object is not in + * the app's metadata — `{ name: objectName }` — etc.). Mirrors the historical * hard-coded fallback chain the divergent resolvers used, so removing them is * non-regressive. Ordered by priority. + * + * objectui#6531 rewrote the parenthetical above: it used to say those search + * candidates "carry only `name`/`titleField`", which read as evidence that some + * producer puts `titleField` on an object-shaped payload. The census found + * none, and the spec's object schema rejects the key outright. */ const NAME_ISH_RECORD_KEYS = [ 'name', @@ -356,9 +366,10 @@ export interface RecordDisplayNameOptions { * * Precedence (ADR-0079 Phase 2 makes the object's declared field canonical and * de-prioritizes the legacy `titleFormat` template): - * 0. `options.titleField`, then `objectDef.titleField` (when provided & - * non-empty on the record) — lets a view / object keep its author-chosen - * title field. + * 0. `options.titleField` (when provided & non-empty on the record) — lets + * a view keep its author-chosen title field. The object-level + * `objectDef.titleField` leg was removed in objectui#6531: the spec's + * object schema rejects that key, so nothing could produce it. * 1. `objectDef.nameField` — the NEW canonical record-title pointer * (ADR-0079). A field name the server can return and query. * 2. `objectDef.displayNameField` / `objectDef.NAME_FIELD_KEY` — the @@ -382,10 +393,23 @@ export function getRecordDisplayName( record: any, options?: RecordDisplayNameOptions, ): string { - // 0. Explicit title field — caller option first, then the object's own - // `titleField` hint — if it actually resolves on the record. - const explicit = - valueAt(record, options?.titleField) ?? valueAt(record, objectDef?.titleField); + // 0. Explicit title field — the caller's option, if it actually resolves on + // the record. This is a DECLARED key: every view config that offers a + // title pointer (`ui/CalendarConfig`, `ui/GalleryConfig`, `ui/GanttConfig`, + // `ui/ListMapConfig`, `ui/ObjectKanbanProps`, `ui/TimelineConfig`) declares + // `titleField`, and its author chose it for THIS view, so it outranks the + // object's own pointer by design. + // + // An object-level `objectDef.titleField` used to be consulted here too, as + // a second `??` leg. Removed in objectui#6531: `@objectstack/spec`'s object + // schema is a `strictObject` that REJECTS `titleField` with + // `unrecognized_keys` — the same code a nonsense key gets — so no + // spec-compliant producer can ship it, and a census across both repos + // (metadata, every `getObjectSchema` implementation, the lookup-chip and + // search-candidate paths) found none that does. Reading it was a + // consumer-side alias for an undeclared key (AGENTS.md Commandment #0.1), + // ranked ABOVE the `nameField` ADR-0079 Phase 2 made canonical. + const explicit = valueAt(record, options?.titleField); if (explicit) return explicit; // 1+2. Declared name field — the canonical `nameField` (ADR-0079 Phase 2),