Skip to content
Merged
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
Original file line numberDiff line numberDiff line change
@@ -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.
102 changes: 97 additions & 5 deletions packages/core/src/utils/__tests__/record-title.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 #<id>`.
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 #<id>`.
//
// 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');
});

Expand DownExpand Up@@ -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' } } };
Expand Down
44 changes: 34 additions & 10 deletions packages/core/src/utils/record-title.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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 #<id>` 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',
Expand DownExpand Up@@ -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
Expand All@@ -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),
Expand Down
Loading