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
19 changes: 19 additions & 0 deletions .changeset/10447-one-title-interpolator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@object-ui/core': patch
'@object-ui/components': patch
---

One `titleFormat` interpolator for the record title (objectui#10447, objectui#10446).

`@object-ui/core`: `formatTitleTemplate` now judges a placeholder empty the way `recordDisplayValueAt` does: trim, then empty. A whitespace-only value used to count as resolved, so `{contract_no} - {name}` with a blank `name` rendered `HT-2026-003 -` in `formatTitleTemplate` and in `getRecordDisplayName`'s `titleFormat` rung, and so on every surface backed by that resolver. It now renders `HT-2026-003`. Both functions run one shared implementation of the rule, so a resolved placeholder renders the same trimmed display string `recordDisplayValueAt` returns. The exported signatures are unchanged.

`@object-ui/components`: the record page H1 (`page:header`) renders its `titleFormat` rung through core's `formatTitleTemplate`, the function `getRecordDisplayName` and `record:details`' H1 dedupe already use, instead of its own interpolation and separator cleanup. Visible changes on that rung:

- An expanded lookup token renders its display name: `{account} - {deal_no}` reads `Acme - Q3-042` where the H1 used to drop the lookup and read `Q3-042`, so `record:details` no longer prints the row it wrongly treated as distinct from the heading.
- A comma left by an empty placeholder is stripped: `{deal_no}, {code}` with no `code` reads `Q3-042`, not `Q3-042,`.
- A template none of whose placeholders resolves no longer shows its literal text (`Session — {user_id}` with no user used to read `Session`); the H1 walks on to the next rung, as `getRecordDisplayName` does.
- A select token still reads as its translated option label: the label is applied to a copy of the record before core renders it.

The rungs' order is unchanged. `page:header`'s own `title` and `subtitle` templates keep their existing interpolation.

This supersedes how two earlier changesets, still pending in the same release, describe the `titleFormat` rung; each now carries a dated note naming this card. objectui#9436 (the declared pointer outranks `titleFormat` in `page:header`) said the template kept the header's own interpolation. objectui#9174 (`interpolate()`'s no-token fast path) listed the record-title `titleFormat` among `interpolate()`'s callers. Both describe the header as it was at their change; from this release the rung renders through `formatTitleTemplate`.
10 changes: 9 additions & 1 deletion .changeset/9174-interpolate-fastpath-trim.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Fix `interpolate()`'s no-token fast path skipping its own trim (objectui#9174).

`page:header`'s `title`/`subtitle` (and the record-title `titleFormat`) all go through
At this change `page:header`'s `title`/`subtitle` (and the record-title `titleFormat`) all went through
`interpolate()` in `packages/components/src/renderers/layout/containers.tsx`. When the
template contained a `{token}` the function collapsed and trimmed whitespace before
returning; when it contained no `{` at all it returned the raw string untouched. A
Expand All @@ -15,6 +15,14 @@ its own heading to the authored header, already trimmed and correctly read "no t
`PageRenderer` also drew its own implicit heading — two `<h1>` elements on one document,
the broken outline objectui#3434 closed, arriving through a different door.

⚠️ **Dated note, 2026-09-25 — the record title's titleFormat rung now renders through core's formatTitleTemplate — objectui#10447.**
Later in this same release the record-title `titleFormat` stopped going through
`interpolate()`: it renders through `@object-ui/core`'s `formatTitleTemplate`, and a
select token still reads as its translated option label, applied by
`withOptionLabels` to a copy of the record before core renders it. `title` and
`subtitle` still go through `interpolate()`, so the fix below holds for both of them
unchanged.

The fix removes the divergence rather than patching around it: the no-`{` fast path
still skips the token-substitution `replace()` callback (its actual performance
saving), but every template — token or not — now falls through to the same, single
Expand Down
19 changes: 15 additions & 4 deletions .changeset/9436-page-header-declared-pointer-order.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,25 @@ The header now resolves its title in this order:
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;
3. `titleFormat`, rendered at this change 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.
At this change, objects that declare a pointer and no `titleFormat`, and
objects that declare a `titleFormat` and no pointer, render exactly as before.

⚠️ **Dated note, 2026-09-25 — the record title's titleFormat rung now renders through core's formatTitleTemplate — objectui#10447.**
Later in this same release rung 3 stopped using the header's own
interpolation: the template goes to `@object-ui/core`'s `formatTitleTemplate`,
the renderer `getRecordDisplayName`, `DetailView` and the `record:details` H1
dedupe already use. A select token still reads as its translated option label,
applied by `withOptionLabels` to a copy of the record before core renders it.
The order above is unchanged. What the rung now renders differently for a
`titleFormat`-only object (an expanded lookup token, a whitespace-only value,
a template with no resolved placeholder) is stated in the objectui#10447 entry;
the rest of this entry is kept as the reading of this change.

**Upgrade effect.** Any object that declares BOTH a `nameField` (or
`displayNameField`) and a `titleFormat` whose rendering differs from that
Expand Down
58 changes: 0 additions & 58 deletions packages/components/src/__tests__/cleanup-title-separators.test.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* The record page H1's `titleFormat` rung renders through core's
* `formatTitleTemplate` — ONE interpolator (objectui#10447).
*
* ## The defect
*
* `PageHeaderRenderer` rendered the legacy template with its own `interpolate`
* plus a separator cleanup, while `getRecordDisplayName`'s template rung,
* `DetailView.resolveDisplayTitle` and `record:details`' H1 dedupe all call
* `formatTitleTemplate`. The two implementations had different token rules.
* The card's case: an expanded lookup token rendered as NOTHING in the header
* and as the reference's display name in core, so
* `titleFormat: '{account} - {deal_no}'` read `Q3-042` in the H1 while the
* dedupe compared `Acme - Q3-042`.
*
* ## What is pinned
*
* - THE CARD: the H1 is exactly the string core renders for the same record.
* - The same equality across the other token shapes the two implementations
* were measured to treat differently, plus the shapes they already agreed
* on as controls. Each expectation is core's own output, read at run time,
* so a change to core moves the H1 with it rather than reddening here.
* - A template none of whose placeholders resolves is core's empty answer, so
* the H1 walks on to the next rung instead of showing the template's
* literal text.
* - The option-label translation the H1 has always applied to a select token
* is KEPT, applied to the record before core renders it. So the H1 equals
* core over that labelled record, and deliberately differs from core over
* the raw record by exactly the label.
*
* Every fixture declares no name pointer and gives the template's fields
* types the type-aware derivation cannot take (or none at all), so the
* template is the rung that answers.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import { ComponentRegistry, formatTitleTemplate } from '@object-ui/core';
import { ActionProvider, RecordContextProvider } from '@object-ui/react';
// Registers `page:header` at module scope, NOT inside a `beforeAll` — there the
// cold transform is billed to `hookTimeout`
// (object-ui/no-dynamic-import-in-test-hook, objectui#3010). This keeps the
// file in the light `dom` project instead of adding it to `heavyDomTests`.
import '../renderers';

function PageHeader({ schema }: { schema: any }) {
const Component = ComponentRegistry.get('page:header');
if (!Component) throw new Error('page:header not registered');
// eslint-disable-next-line react-hooks/static-components -- ComponentRegistry.get returns a registered component (stable), not one created during render
return <Component schema={schema} />;
}

/** Render the real `page:header` for one record and return its H1 text. */
function h1Of(objectSchema: any, record: any): string | null | undefined {
const { container } = render(
<ActionProvider>
<RecordContextProvider
objectName={objectSchema.name}
recordId={record.id}
data={record}
objectSchema={objectSchema}
>
<PageHeader schema={{ type: 'page:header' }} />
</RecordContextProvider>
</ActionProvider>,
);
return container.querySelector('h1')?.textContent;
}

afterEach(() => {
cleanup();
});

/** Lookup-typed and number-typed fields: the derivation takes neither. */
const dealFields = {
account: { type: 'lookup', reference_to: 'account', label: 'Account' },
deal_no: { type: 'autonumber', label: 'Deal No' },
};

const deal = (titleFormat: unknown) => ({ name: 'deal', label: 'Deal', titleFormat, fields: dealFields });

describe('page:header H1 — the titleFormat rung renders what core renders (#10447)', () => {
it('THE CARD — an expanded lookup token renders its display name, exactly as core does', () => {
const titleFormat = '{account} - {deal_no}';
const record = { id: 'rec-1', account: { id: 'a1', name: 'Acme' }, deal_no: 'Q3-042' };

const h1 = h1Of(deal(titleFormat), record);

expect(h1).toBe(formatTitleTemplate(titleFormat, record));
expect(h1).toBe('Acme - Q3-042');
});

// [name, template, record]. Every template names only fields the fixture
// leaves untyped or types out of the derivation, and every record resolves
// at least one placeholder, so core's answer is non-empty and IS the H1.
const shapes: Array<[string, unknown, Record<string, unknown>]> = [
['an expanded lookup with no display name drops with its separator', '{account} - {deal_no}', { account: { id: 'a1' }, deal_no: 'Q3-042' }],
['a whitespace-only value drops with its separator (#10446)', '{contract_no} - {code}', { contract_no: 'HT-2026-003', code: ' ' }],
['a comma left by an empty placeholder is stripped', '{deal_no}, {code}', { deal_no: 'Q3-042' }],
['a double-brace token', '{{deal_no}} - {{code}}', { deal_no: 'Q3-042', code: 'X9' }],
['a token with inner whitespace', '{ deal_no } - { code }', { deal_no: 'Q3-042', code: 'X9' }],
['a hyphenated key', '{deal-code} {deal_no}', { 'deal-code': 'EU', deal_no: 'Q3-042' }],
['the Expression envelope', { dialect: 'template', source: '{deal_no} - {code}' }, { deal_no: 'Q3-042', code: 'X9' }],
['CONTROL — a dotted path into an expanded lookup', '{account.name} - {deal_no}', { account: { id: 'a1', name: 'Acme' }, deal_no: 'Q3-042' }],
['CONTROL — a three-part template with its middle placeholder empty', '{deal_no} - {code} - {region}', { deal_no: 'Q3-042', region: 'EMEA' }],
['CONTROL — a literal prefix beside a resolved placeholder', 'Deal {deal_no}', { deal_no: 'Q3-042' }],
];

for (const [name, titleFormat, values] of shapes) {
it(name, () => {
const record = { id: 'rec-2', ...values };
const expected = formatTitleTemplate(titleFormat as never, record);
// Guard the fixture, not the renderer: an empty core answer would mean
// the H1 came from another rung and the equality below proved nothing.
expect(expected).not.toBe('');
expect(h1Of(deal(titleFormat), record)).toBe(expected);
});
}

it('a template none of whose placeholders resolves declines, so its literal text is not the H1', () => {
// Core's rule: no placeholder resolved ⇒ ''. The rung takes that answer
// and the H1 walks on, here to the `${objectLabel} ${id}` floor, instead
// of printing the template's literal prefix as if it were a title.
const titleFormat = 'Session — {user_id}';
const record = { id: 'rec-00000003' };
expect(formatTitleTemplate(titleFormat, record)).toBe('');

const h1 = h1Of(deal(titleFormat), record);

expect(h1).not.toBe('Session');
expect(h1).toBe('Deal rec-0000');
});
});

describe('page:header H1 — a select token keeps its option label, applied before core renders (#10447)', () => {
const statusOptions = [
{ value: 'in_progress', label: 'In Progress' },
{ value: 'won', label: 'Won' },
];
const ticket = (fields: unknown) => ({
name: 'ticket',
label: 'Ticket',
titleFormat: '{deal_no} · {status}',
fields,
});
const recordFields = {
deal_no: { type: 'autonumber', label: 'Deal No' },
status: { type: 'select', label: 'Status', options: statusOptions },
};

it('the H1 is core over the record with the select value read as its label', () => {
const record = { id: 'rec-4', deal_no: 'Q3-042', status: 'in_progress' };

const h1 = h1Of(ticket(recordFields), record);

expect(h1).toBe(formatTitleTemplate('{deal_no} · {status}', { ...record, status: 'In Progress' }));
expect(h1).toBe('Q3-042 · In Progress');
// Where it deliberately differs from core over the RAW record: the label.
expect(formatTitleTemplate('{deal_no} · {status}', record)).toBe('Q3-042 · in_progress');
});

it('the array form of `fields` maps the same way', () => {
const record = { id: 'rec-5', deal_no: 'Q3-042', status: 'won' };
const arrayFields = Object.entries(recordFields).map(([name, def]) => ({ name, ...def }));

expect(h1Of(ticket(arrayFields), record)).toBe('Q3-042 · Won');
});

it('a value no option declares renders raw', () => {
const record = { id: 'rec-6', deal_no: 'Q3-042', status: 'archived' };

expect(h1Of(ticket(recordFields), record)).toBe('Q3-042 · archived');
});

it('a blank select value is still empty, and drops with its separator', () => {
const record = { id: 'rec-7', deal_no: 'Q3-042', status: ' ' };

expect(h1Of(ticket(recordFields), record)).toBe('Q3-042');
});
});
10 changes: 6 additions & 4 deletions packages/components/src/__tests__/page-header-title.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,12 @@ describe('PageHeaderRenderer — record title resolution', () => {
* `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;
* - the template's select value still reads as its option label. Since
* objectui#10447 the rung renders through core's `formatTitleTemplate`
* over a record copy whose select values read as their labels; the
* unified resolver renders the raw value, so a ladder that consulted the
* whole 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)', () => {
Expand Down
Loading
Loading