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
27 changes: 27 additions & 0 deletions .changeset/9436-core-declared-name-field-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@object-ui/core': minor
---

feat(core): export `declaredNameField`, the one spelling of the ADR-0079 declared name pointer (objectui#9436)

`declaredNameField(objectDef)` returns the object's declared record-title
pointer exactly as `getRecordDisplayName` reads it at steps 1+2: the canonical
`nameField`, then the deprecated `displayNameField` and `NAME_FIELD_KEY`
aliases. It returns `undefined` when none is declared. It never derives, and
that is the difference from `resolveNameField`, which falls back to the
type-aware derivation.

The function already existed privately in `record-title.ts` and its behaviour
is unchanged. It is exported so that a caller which renders `titleFormat`
itself can rank the declared pointer above the template and the derivation
below it, without re-typing the `??` chain:

```ts
import { declaredNameField, recordDisplayValueAt } from '@object-ui/core';

const declaredTitle = recordDisplayValueAt(record, declaredNameField(objectDef));
```

`PageHeaderRenderer` (`@object-ui/components`), `DetailView` and the
`record:details` H1 dedupe (`@object-ui/plugin-detail`) read it this way in
the same release.
37 changes: 37 additions & 0 deletions .changeset/9436-detail-declared-pointer-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
'@object-ui/plugin-detail': minor
---

fix(plugin-detail): `DetailView`'s header and the `record:details` H1 dedupe rank the declared `nameField` above `titleFormat`, the ADR-0079 order

⚠️ Behaviour change: which heading `DetailView` shows, and which row
`record:details` hides. Declared `minor` under this repo's fixed-group
versioning. Executes ruling C1 on objectui#9436, alongside the same change to
`PageHeaderRenderer` in `@object-ui/components`.

- **`DetailView` header.** `resolveDisplayTitle` used to try `titleFormat`
right after the view-level `primaryField`, and before the object's declared
pointer. The declared pointer (`nameField`, then its deprecated
`displayNameField` alias) now sits between the two, so on an object
declaring both, the heading is the pointer's value. `primaryField` still
wins over everything. A pointer that is blank on the record still falls
through to the template.
- **`record:details` H1 dedupe.** The body hides the one row the record page
H1 already shows. It used to check the template first, which mirrored the
header's old order. With the header moved, that would have printed the
pointer's row directly under an identical H1. On a single-field template it
would also have hidden a row the H1 no longer showed. The dedupe now reads
the declared pointer first: when it holds a value, that field's row is the
one hidden and the template is not consulted. The template outcomes ruled on
objectui#8351 are unchanged wherever the template really is the H1, which
is when no pointer is declared or the pointer is blank on the record.

**Upgrade effect.** It is the same set of objects as the header change: any
object declaring both a `nameField` (or `displayNameField`) and a
`titleFormat` that renders something else. Measured once, at objectstack
`61609edf`, and not re-derived by any gate here, that reaches 16 ObjectStack
platform objects. For 5 of them (`sys_approval_action`,
`sys_approval_approver`, `sys_approval_request`, `sys_automation_run` and
`sys_http_delivery`), `nameField: 'id'` makes the heading the raw record id.
That is a known consequence of the ruled order. The fix belongs to those
objects' metadata in ObjectStack: a formula field designated as `nameField`.
56 changes: 56 additions & 0 deletions .changeset/9436-page-header-declared-pointer-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
'@object-ui/components': minor
---

fix(components): the record page H1 ranks the declared `nameField` above `titleFormat`, the ADR-0079 order

⚠️ Behaviour change: what the H1 shows. Declared `minor` under this repo's
fixed-group versioning. Executes ruling C1 on objectui#9436.

`PageHeaderRenderer` (`page:header`, the H1 of the synthesized record page)
used to try the object's `titleFormat` template BEFORE its declared name
pointer. The protocol ranks them the other way. `@objectstack/spec`'s
`titleFormat` describe says "an explicit nameField now takes precedence",
ADR-0079 D3 says the same, and `getRecordDisplayName` already resolves the
name that way. So on an object declaring both, the H1 showed one field
while gallery, calendar, search and related lists showed another.

The header now resolves its title in this order:

1. an explicit `schema.title`;
2. the declared pointer: `nameField`, then its deprecated `displayNameField`
alias. This rung is new, and it only applies when the pointer holds a value
on the record;
3. `titleFormat`, rendered with the header's own interpolation, including
i18n option labels, exactly as before;
4. the type-aware derivation, then the record-key rung, then
`${objectLabel} ${id}`, all unchanged.

A pointer that is blank on a record still falls through to the template.
Objects that declare a pointer and no `titleFormat`, and objects that declare a
`titleFormat` and no pointer, render exactly as before.

**Upgrade effect.** Any object that declares BOTH a `nameField` (or
`displayNameField`) and a `titleFormat` whose rendering differs from that
field's value now shows the field's value as its H1. objectui's own
`examples/**` and `apps/**` declare no `titleFormat` at all. The first-party
objects this reaches are ObjectStack's platform objects. Measured once, at
objectstack `61609edf`, not re-derived by any gate here:

- 16 platform objects' H1 moves.
- 11 of them move to another field's value: `sys_import_job`,
`sys_job_queue`, `sys_job_run`, `sys_session`, `sys_setting`,
`sys_migration_journal`, `sys_activity`, `sys_audit_log`, `sys_comment`,
`sys_sharing_rule` and `sys_webhook`.
- The other 5 declare `nameField: 'id'`, so their H1 becomes the raw record
id: `sys_approval_action`, `sys_approval_approver`, `sys_approval_request`,
`sys_automation_run` and `sys_http_delivery`.

This is a known consequence of the ruled order, not a regression to work
around in the renderer. The fix belongs to those objects' metadata in
ObjectStack: designate a formula field as the `nameField`, as the `titleFormat`
describe itself advises for a composite title.

`DetailView` and the `record:details` H1 dedupe (`@object-ui/plugin-detail`)
move to the same order in the same release, and the pointer is read through
`@object-ui/core`'s newly exported `declaredNameField`.
110 changes: 106 additions & 4 deletions packages/components/src/__tests__/page-header-title.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,15 @@ describe('PageHeaderRenderer — record title resolution', () => {
expect(screen.getByText('Kickoff call')).toBeTruthy();
});

it('titleFormat still outranks nameField (legacy header behaviour)', () => {
renderHeader({
it('the declared nameField outranks titleFormat (ADR-0079 protocol order, #9436)', () => {
// Rewritten, not deleted: this case used to be "titleFormat still
// outranks nameField (legacy header behaviour)" and asserted the opposite
// H1. The protocol ranks the declared pointer above the template, both in
// `@objectstack/spec`'s `titleFormat` describe ("an explicit nameField now
// takes precedence") and in `getRecordDisplayName`. So the H1 is the
// pointer's value and the template renders nowhere. The two answers are
// deliberately disjoint strings, so the assertion cannot pass by overlap.
const { container } = renderHeader({
objectSchema: {
name: 'contact',
label: 'Contact',
Expand All @@ -91,9 +98,10 @@ describe('PageHeaderRenderer — record title resolution', () => {
last_name: { type: 'text' },
},
},
record: { id: 'rec-3', nickname: 'Ada', first_name: 'Ada', last_name: 'Lovelace' },
record: { id: 'rec-3', nickname: 'Countess', first_name: 'Ada', last_name: 'Lovelace' },
});
expect(screen.getByText('Ada Lovelace')).toBeTruthy();
expect(container.querySelector('h1')?.textContent).toBe('Countess');
expect(screen.queryByText('Ada Lovelace')).toBeNull();
});

it('falls back to `${objectLabel} ${id}` when the record is truly unnamed', () => {
Expand All @@ -109,6 +117,100 @@ describe('PageHeaderRenderer — record title resolution', () => {
});
});

/**
* The declared pointer outranks `titleFormat`, and nothing else moved
* (objectui#9436, ruled C1).
*
* The pin above asserts the new order. The cases below are the rest of the
* contract, and all but one are CONTROLS: they hold under the old order and the
* new one alike, so if the order is reverted, only the order pins redden.
*
* - the deprecated `displayNameField` alias is part of the declared pointer,
* so it outranks the template too (a pin, like the one above);
* - a pointer that is BLANK on the record falls through to the template, as
* `getRecordDisplayName` walks from a blank steps 1+2 to step 3;
* - an object that declares no pointer still takes its title from the
* template, ABOVE the type-aware derivation, which stays where it was;
* - the template keeps THIS renderer's interpolation, which routes a select
* value through its option label. Core's `formatTitleTemplate` renders the
* raw value, so a ladder that consulted the whole unified resolver above
* the template would change this H1 and not just the order;
* - an explicit `schema.title` still outranks both.
*/
describe('PageHeaderRenderer — the declared pointer outranks `titleFormat` (#9436)', () => {
const contact = {
name: 'contact',
label: 'Contact',
titleFormat: '{first_name} {last_name}',
fields: {
nickname: { type: 'text' },
first_name: { type: 'text' },
last_name: { type: 'text' },
},
};

it('the deprecated `displayNameField` alias outranks titleFormat too', () => {
const { container } = renderHeader({
objectSchema: { ...contact, displayNameField: 'nickname' },
record: { id: 'rec-4', nickname: 'Countess', first_name: 'Ada', last_name: 'Lovelace' },
});
expect(container.querySelector('h1')?.textContent).toBe('Countess');
expect(screen.queryByText('Ada Lovelace')).toBeNull();
});

it('CONTROL — a declared pointer that is BLANK on the record falls through to titleFormat', () => {
const { container } = renderHeader({
objectSchema: { ...contact, nameField: 'nickname' },
record: { id: 'rec-5', nickname: ' ', first_name: 'Ada', last_name: 'Lovelace' },
});
expect(container.querySelector('h1')?.textContent).toBe('Ada Lovelace');
});

it('CONTROL — titleFormat still titles an object that declares no pointer, above the derivation', () => {
// `subject` is what the type-aware derivation would pick (a name-ish exact
// key, typed text). The template still outranks it: only the declared
// pointer moved above the template.
const { container } = renderHeader({
objectSchema: {
...contact,
fields: { ...contact.fields, subject: { type: 'text' } },
},
record: { id: 'rec-6', subject: 'Fix the widget', first_name: 'Ada', last_name: 'Lovelace' },
});
expect(container.querySelector('h1')?.textContent).toBe('Ada Lovelace');
expect(screen.queryByText('Fix the widget')).toBeNull();
});

it("CONTROL — titleFormat keeps the header's option-label interpolation", () => {
const { container } = renderHeader({
objectSchema: {
name: 'ticket',
label: 'Ticket',
titleFormat: '{status}',
fields: {
status: {
type: 'select',
options: [{ value: 'in_progress', label: 'In Progress' }],
},
},
},
record: { id: 'rec-7', status: 'in_progress' },
});
expect(container.querySelector('h1')?.textContent).toBe('In Progress');
});

it('CONTROL — an explicit `schema.title` still outranks both the pointer and titleFormat', () => {
const { container } = renderHeader({
objectSchema: { ...contact, nameField: 'nickname' },
record: { id: 'rec-8', nickname: 'Countess', first_name: 'Ada', last_name: 'Lovelace' },
schema: { type: 'page:header', title: 'Pinned heading' },
});
expect(container.querySelector('h1')?.textContent).toBe('Pinned heading');
expect(screen.queryByText('Countess')).toBeNull();
expect(screen.queryByText('Ada Lovelace')).toBeNull();
});
});

/**
* `objectSchema.primaryField` is NOT a rung of this chain (objectui#7586).
*
Expand Down
43 changes: 36 additions & 7 deletions packages/components/src/renderers/layout/containers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
*/

import React from 'react';
import { ComponentRegistry, ExpressionEvaluator, evalRowPredicate, getRecordDisplayName, recordDisplayValueAt, resolveNameField, toPredicateRecord } from '@object-ui/core';
import { ComponentRegistry, ExpressionEvaluator, declaredNameField, evalRowPredicate, getRecordDisplayName, recordDisplayValueAt, resolveNameField, toPredicateRecord } from '@object-ui/core';
import type { ComponentInput } from '@object-ui/core';
import { actionRendersAt, resolveDeclaredActionIds } from '@object-ui/types';
import type { DeclaredActionsRefusal } from '@object-ui/types';
Expand Down Expand Up @@ -2055,13 +2055,26 @@ const PageHeaderRenderer: React.FC<any> = ({ schema, className, ...props }) => {
// 2. Author hasn't opted out via `recordChrome: false`.
// When both pass, we resolve the chip title from (in order):
// - explicit `schema.title` (interpolated against data),
// - `objectSchema.titleFormat` (the author override),
// - the unified ADR-0079 resolver (`nameField` → `displayNameField` →
// type-aware derivation) — same precedence as DetailView's own header,
// - the object's DECLARED name pointer (`nameField`, then its deprecated
// `displayNameField` alias), when it holds a value on this record,
// - `objectSchema.titleFormat`, the legacy render-only template,
// - the unified ADR-0079 resolver (type-aware derivation, once the two
// rungs above have declined),
// - that same resolver's record-key rung, but ONLY for an object that
// names no title field at all (objectui#10117),
// - `${objectLabel} ${id}` as a last-resort.
//
// ⭐ The declared pointer OUTRANKS `titleFormat` (objectui#9436, ruled C1).
// That is the protocol's order, not this renderer's choice:
// `@objectstack/spec`'s `titleFormat` describe says "an explicit nameField
// now takes precedence", ADR-0079 D3 says the same, and
// `getRecordDisplayName` implements it (declared pointer at steps 1+2, the
// template at step 3). This header used to rank the template FIRST, so on an
// object declaring both, the H1 and the name every resolver-backed surface
// shows were two different fields. `DetailView.resolveDisplayTitle` and
// `record:details`' H1 dedupe moved in the same change, because they read the
// same order. Pinned in `__tests__/page-header-title.test.tsx`.
//
// ⛔ `objectSchema.primaryField` is NOT a rung and must not become one again
// (objectui#7586). It used to sit directly under `schema.title`, ABOVE the
// unified resolver — on the surface that renders the ACTUAL H1 of a
Expand All @@ -2084,9 +2097,12 @@ const PageHeaderRenderer: React.FC<any> = ({ schema, className, ...props }) => {
const objectLabel: string | undefined = rawObjectName
? tObjectLabel({ name: rawObjectName, label: fallbackLabel })
: fallbackLabel || undefined;
// Honor objectSchema.titleFormat (e.g. `{first_name} {last_name}`).
// Mirrors DetailView.resolveDisplayTitle's behaviour so default and
// synthesized record pages produce the same title.
// Honor objectSchema.titleFormat (e.g. `{first_name} {last_name}`) as the
// rung BELOW the declared pointer. `DetailView.resolveDisplayTitle` ranks
// the two the same way, so default and synthesized record pages produce
// the same title. The template keeps THIS renderer's interpolation (i18n
// option labels, separator cleanup) rather than core's
// `formatTitleTemplate`: only its rank moved in objectui#9436.
const rawTitleFormat: any = objSchema?.titleFormat;
const titleFormatStr: string | undefined =
typeof rawTitleFormat === 'string'
Expand Down Expand Up @@ -2151,8 +2167,21 @@ const PageHeaderRenderer: React.FC<any> = ({ schema, className, ...props }) => {
(objectLabel && data?.id ? `${objectLabel} ${String(data.id).slice(0, 8)}` : '') ||
objectLabel ||
'';
// The declared pointer as its OWN rung, directly above the template
// (objectui#9436). It is the resolver's steps 1+2, read through the one
// exported spelling of the pointer rather than a re-typed `??` chain, and
// filtered by the same floor test `unifiedTitle` applies. So for an object
// WITHOUT a `titleFormat` this rung answers exactly what `unifiedTitle`
// answered before it; only the template's rank moved. A blank value here
// falls through to the template, just as `getRecordDisplayName` walks from
// a blank step 1+2 to step 3.
const declaredTitle = (() => {
const resolved = recordDisplayValueAt(data, declaredNameField(objSchema));
return resolved && !isResolverFloor(resolved) ? resolved : '';
})();
const titleCandidate =
explicitTitle ||
declaredTitle ||
(interpolatedTitleFormat && !interpolatedTitleFormat.includes('{') ? interpolatedTitleFormat : '') ||
unifiedTitle ||
recordKeyTitle;
Expand Down
Loading
Loading