diff --git a/.changeset/10447-one-title-interpolator.md b/.changeset/10447-one-title-interpolator.md new file mode 100644 index 0000000000..13bedc303c --- /dev/null +++ b/.changeset/10447-one-title-interpolator.md @@ -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`. diff --git a/.changeset/9174-interpolate-fastpath-trim.md b/.changeset/9174-interpolate-fastpath-trim.md index b99bad2fbc..919780536e 100644 --- a/.changeset/9174-interpolate-fastpath-trim.md +++ b/.changeset/9174-interpolate-fastpath-trim.md @@ -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 @@ -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 `

` 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 diff --git a/.changeset/9436-page-header-declared-pointer-order.md b/.changeset/9436-page-header-declared-pointer-order.md index 2531384f6c..c141442686 100644 --- a/.changeset/9436-page-header-declared-pointer-order.md +++ b/.changeset/9436-page-header-declared-pointer-order.md @@ -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 diff --git a/packages/components/src/__tests__/cleanup-title-separators.test.ts b/packages/components/src/__tests__/cleanup-title-separators.test.ts deleted file mode 100644 index 13ec9d1a59..0000000000 --- a/packages/components/src/__tests__/cleanup-title-separators.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * 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. - */ - -import { describe, it, expect } from 'vitest'; -import { cleanupTitleSeparators } from '../renderers/layout/containers'; - -describe('cleanupTitleSeparators', () => { - it('returns empty input unchanged', () => { - expect(cleanupTitleSeparators('')).toBe(''); - }); - - it('preserves a clean title', () => { - expect(cleanupTitleSeparators('CTR-0001 - Acme Contract')).toBe('CTR-0001 - Acme Contract'); - }); - - it('strips a trailing dash from a missing tail interpolation', () => { - expect(cleanupTitleSeparators('CTR-0001 -')).toBe('CTR-0001'); - expect(cleanupTitleSeparators('CTR-0001 - ')).toBe('CTR-0001'); - }); - - it('strips a trailing middle-dot or colon connector', () => { - expect(cleanupTitleSeparators('Acme ·')).toBe('Acme'); - expect(cleanupTitleSeparators('Status :')).toBe('Status'); - }); - - it('strips a leading dash from a missing head interpolation', () => { - expect(cleanupTitleSeparators('- Acme')).toBe('Acme'); - expect(cleanupTitleSeparators(' · Acme')).toBe('Acme'); - }); - - it('collapses double separators left by an empty middle field', () => { - expect(cleanupTitleSeparators('A - - B')).toBe('A - B'); - expect(cleanupTitleSeparators('A · · B')).toBe('A · B'); - }); - - it('handles em-dash and en-dash connectors', () => { - expect(cleanupTitleSeparators('CTR-0001 —')).toBe('CTR-0001'); - expect(cleanupTitleSeparators('CTR-0001 –')).toBe('CTR-0001'); - }); - - it('does not strip dashes inside an identifier', () => { - expect(cleanupTitleSeparators('CTR-0001')).toBe('CTR-0001'); - }); - - it('collapses runs of whitespace', () => { - expect(cleanupTitleSeparators('A B')).toBe('A B'); - }); - - it('is idempotent', () => { - const once = cleanupTitleSeparators('CTR-0001 - '); - expect(cleanupTitleSeparators(once)).toBe(once); - }); -}); diff --git a/packages/components/src/__tests__/page-header-title.oneInterpolator-10447.test.tsx b/packages/components/src/__tests__/page-header-title.oneInterpolator-10447.test.tsx new file mode 100644 index 0000000000..e6d18b6761 --- /dev/null +++ b/packages/components/src/__tests__/page-header-title.oneInterpolator-10447.test.tsx @@ -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 ; +} + +/** 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( + + + + + , + ); + 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]> = [ + ['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'); + }); +}); diff --git a/packages/components/src/__tests__/page-header-title.test.tsx b/packages/components/src/__tests__/page-header-title.test.tsx index 418db085ad..3d4a052942 100644 --- a/packages/components/src/__tests__/page-header-title.test.tsx +++ b/packages/components/src/__tests__/page-header-title.test.tsx @@ -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)', () => { diff --git a/packages/components/src/renderers/layout/containers.tsx b/packages/components/src/renderers/layout/containers.tsx index b728bcba62..0024c9677f 100644 --- a/packages/components/src/renderers/layout/containers.tsx +++ b/packages/components/src/renderers/layout/containers.tsx @@ -19,7 +19,7 @@ */ import React from 'react'; -import { ComponentRegistry, ExpressionEvaluator, declaredNameField, evalRowPredicate, getRecordDisplayName, recordDisplayValueAt, resolveNameField, toPredicateRecord } from '@object-ui/core'; +import { ComponentRegistry, ExpressionEvaluator, declaredNameField, evalRowPredicate, formatTitleTemplate, 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'; @@ -321,6 +321,77 @@ const translateLabel = ( return text; }; +/** `useSafeFieldLabel().fieldOptionLabel`'s shape. */ +type FieldOptionLabel = (objectName: string, fieldName: string, value: string, fallback: string) => string; + +/** + * The option label a user reads for one select-field VALUE, or `undefined` + * when `fieldName` is not an options-bearing field of `objectSchema` (or a + * piece needed to translate it is absent). + * + * The ONE place this file maps an enum value to its label. Two readers call + * it: the `{token}` interpolation below (`page:header`'s `title` / + * `subtitle`), and the record copy the `titleFormat` rung hands to core's + * `formatTitleTemplate` ({@link withOptionLabels}, objectui#10447). + */ +const optionLabelFor = ( + objectSchema: any, + fieldOptionLabel: FieldOptionLabel | undefined, + objectName: string | undefined, + fieldName: string, + raw: string, +): string | undefined => { + if (!objectSchema?.fields || !fieldOptionLabel || !objectName) return undefined; + const fieldDef: any = Array.isArray(objectSchema.fields) + ? objectSchema.fields.find((f: any) => f?.name === fieldName) + : objectSchema.fields[fieldName]; + const options: any[] | undefined = fieldDef?.options; + if (!Array.isArray(options)) return undefined; + const match = options.find((opt: any) => String(opt?.value ?? opt) === raw); + const fallback = match?.label ? String(match.label) : raw; + return fieldOptionLabel(objectName, fieldName, raw, fallback); +}; + +/** + * A copy of `data` in which each select-field value reads as its option label + * ({@link optionLabelFor}); `data` itself when no value maps. + * + * Why a copy and not a hook on the formatter (objectui#10447): the record + * page H1's `titleFormat` rung renders through core's `formatTitleTemplate`, + * the one interpolator every other title surface uses. That function takes a + * template and a record, and nothing else. The H1 has always shown a select + * token as its translated label ("In Progress", not `in_progress`), so the + * translation happens on the record before the formatter sees it, and the + * formatter's signature does not move. + * + * Only a top-level scalar is mapped: `null`, `undefined`, an expanded + * reference and an array stay as they are, so core judges them exactly as it + * judges the raw record. A mapped value is never empty (the label falls back + * to the raw value), so the placeholders that resolve on the copy are the ones + * that resolve on the raw record. That is what keeps `record:details`' dedupe, + * which renders the same template over the raw record, deciding about the same + * fields the H1 shows. + */ +const withOptionLabels = ( + data: any, + objectSchema: any, + fieldOptionLabel: FieldOptionLabel | undefined, + objectName: string | undefined, +): any => { + if (!data || typeof data !== 'object') return data; + const source: Record = data; + let copy: Record | undefined; + for (const key of Object.keys(source)) { + const v = source[key]; + if (v === null || v === undefined || typeof v === 'object') continue; + const label = optionLabelFor(objectSchema, fieldOptionLabel, objectName, key, String(v)); + if (label === undefined) continue; + if (!copy) copy = { ...source }; + copy[key] = label; + } + return copy ?? source; +}; + /** * Replace `{field.path}` tokens in a template against the given data object. * Missing fields collapse to an empty string. The result is trimmed and @@ -330,12 +401,17 @@ const translateLabel = ( * resolves to a select-field value gets routed through the i18n option * label dictionary — so `subtitle: "{industry} · {type}"` renders as * "科技 · 客户" rather than the raw enum values "technology · customer". + * + * Callers: `page:header`'s own `title` and `subtitle`. The record-title + * `titleFormat` rung does NOT come through here: it renders through core's + * `formatTitleTemplate` (objectui#10447), because that template is the one + * `getRecordDisplayName` and `record:details`' dedupe render too. */ const interpolate = ( template: string, data: any, objectSchema?: any, - fieldOptionLabel?: (objectName: string, fieldName: string, value: string, fallback: string) => string, + fieldOptionLabel?: FieldOptionLabel, objectName?: string, ): string => { if (!template || typeof template !== 'string') return template || ''; @@ -363,16 +439,9 @@ const interpolate = ( // Only the first path segment is treated as a field name (deeper // paths reach into related records and have their own translation // surfaces). - if (objectSchema?.fields && fieldOptionLabel && objectName && !path.includes('.')) { - const fieldDef: any = Array.isArray(objectSchema.fields) - ? objectSchema.fields.find((f: any) => f?.name === path) - : objectSchema.fields[path]; - const options: any[] | undefined = fieldDef?.options; - if (Array.isArray(options)) { - const match = options.find((opt: any) => String(opt?.value ?? opt) === raw); - const fallback = match?.label ? String(match.label) : raw; - return fieldOptionLabel(objectName, path, raw, fallback); - } + if (!path.includes('.')) { + const label = optionLabelFor(objectSchema, fieldOptionLabel, objectName, path, raw); + if (label !== undefined) return label; } return raw; }) @@ -1184,37 +1253,6 @@ ComponentRegistry.register('section', PageSectionRenderer, { // (objectstack#11592 ruling, objectui#6252) — see `resolvedHeaderActions`. // --------------------------------------------------------------------------- -/** - * Strip dangling connectors that survive when a `titleFormat` interpolates - * with one side empty — e.g. `{number} - {name}` becomes `CTR-0001 -` when - * `name` is blank. Removes a trailing/leading hyphen / middle-dot / colon / - * slash / pipe (optionally surrounded by whitespace) and collapses - * adjacent whitespace into a single space. Idempotent. - * - * Exported for unit tests. - */ -export function cleanupTitleSeparators(s: string): string { - if (!s) return s; - let out = s; - // Repeatedly trim trailing connectors. Loop so chains like " - · " all peel. - for (let i = 0; i < 4; i += 1) { - const next = out.replace(/[\s\u00A0]*[-·:|/–—][\s\u00A0]*$/u, '').trimEnd(); - if (next === out) break; - out = next; - } - for (let i = 0; i < 4; i += 1) { - const next = out.replace(/^[\s\u00A0]*[-·:|/–—][\s\u00A0]*/u, '').trimStart(); - if (next === out) break; - out = next; - } - // Collapse double-separators in the middle (rare, but happens when the - // middle field of a 3-part format is empty: "A - - B" -> "A - B"). - out = out.replace(/([-·:|/–—])[\s\u00A0]*\1/gu, '$1'); - // Collapse runs of whitespace. - out = out.replace(/[\s\u00A0]+/g, ' ').trim(); - return out; -} - /** * One-time diagnostics for header-action `visible` / `hidden` predicates * (#2358). Warn-once per (action, predicate) pair so re-renders don't spam the @@ -2100,19 +2138,28 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { // 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' - ? rawTitleFormat - : (rawTitleFormat && typeof rawTitleFormat === 'object' && typeof rawTitleFormat.source === 'string') - ? rawTitleFormat.source - : undefined; - const interpolatedTitleFormat = titleFormatStr - ? cleanupTitleSeparators(interpolate(titleFormatStr, data, objSchema, fieldOptionLabel, rawObjectName).trim()) - : ''; + // the same title. + // + // ⭐ ONE interpolator (objectui#10447). The template renders through + // core's `formatTitleTemplate`, the function `getRecordDisplayName`'s + // template rung and `record:details`' H1 dedupe call. This rung used to + // run this file's own `interpolate` plus a separator cleanup, with + // different token rules: an expanded lookup token rendered as nothing + // here and as its display name in core, so `{account} - {deal_no}` read + // `Q3-042` in the H1 while the dedupe compared `Acme - Q3-042`, and the + // row equal to the H1 stayed printed under it. objectui#9436 moved only + // this rung's rank; the rungs' order below is unchanged. + // + // The template and the Expression envelope go to core as declared (core + // reads both), and whatever core returns is the rung's answer: its empty + // string is its "nothing resolved", and there is no second test for an + // unresolved token. The one thing the H1 adds is the translated option + // label for a select value, applied to a copy of the record + // (`withOptionLabels`) so the formatter's signature does not move. + const interpolatedTitleFormat = formatTitleTemplate( + objSchema?.titleFormat, + withOptionLabels(data, objSchema, fieldOptionLabel, rawObjectName), + ); // Unified resolver (ADR-0079): honours the object's declared // `nameField`/`displayNameField` and falls back to type-aware field // derivation. `deriveFromRecordKeys: false` keeps bare record-key @@ -2182,7 +2229,7 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { const titleCandidate = explicitTitle || declaredTitle || - (interpolatedTitleFormat && !interpolatedTitleFormat.includes('{') ? interpolatedTitleFormat : '') || + interpolatedTitleFormat || unifiedTitle || recordKeyTitle; // Defensive backstop — deliberately last, and deliberately NOT the fix: on diff --git a/packages/core/src/utils/__tests__/record-title.whitespacePlaceholder-10446.test.ts b/packages/core/src/utils/__tests__/record-title.whitespacePlaceholder-10446.test.ts new file mode 100644 index 0000000000..971cc1bb11 --- /dev/null +++ b/packages/core/src/utils/__tests__/record-title.whitespacePlaceholder-10446.test.ts @@ -0,0 +1,91 @@ +/** + * 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. + */ + +/** + * `formatTitleTemplate` judges a placeholder EMPTY the way + * `recordDisplayValueAt` does: trim, then empty (objectui#10446). + * + * ## The defect + * + * The template renderer tested each placeholder's raw value against `null`, + * `undefined` and `''` on its own. A whitespace-only value passed that test, so + * it counted as RESOLVED and was emitted. The whitespace collapse that runs + * afterwards then removed the value but not the separator the empty-placeholder + * pass would have stripped with it: `{contract_no} - {name}` with a blank + * `name` rendered `HT-2026-003 -`. `getRecordDisplayName`'s `titleFormat` rung + * returns that string, so every resolver-backed surface showed it. + * + * `recordDisplayValueAt`, the objectui#8350 emptiness authority, trims and calls + * the same value empty. The two now share one implementation of that rule + * rather than two spellings of it. + * + * ## What is pinned + * + * - THE CARD, at both doors the card measured: `formatTitleTemplate` and + * `getRecordDisplayName`. + * - A real value is unchanged (the control: the fix must not drop values). + * - Agreement, value by value: a placeholder is dropped exactly when + * `recordDisplayValueAt` calls its field empty, and otherwise renders what + * that function returns. The template has a second, always-resolved + * placeholder so an empty first one shows as its separator being stripped, + * not merely as whitespace that the final trim would hide. + */ + +import { describe, it, expect } from 'vitest'; +import { formatTitleTemplate, getRecordDisplayName, recordDisplayValueAt } from '../record-title'; + +const TEMPLATE = '{contract_no} - {name}'; + +describe('formatTitleTemplate — a whitespace-only placeholder is empty (#10446)', () => { + it('THE CARD — the blank `name` drops with its separator', () => { + expect(formatTitleTemplate(TEMPLATE, { contract_no: 'HT-2026-003', name: ' ' })).toBe('HT-2026-003'); + }); + + it("THE CARD — `getRecordDisplayName`'s titleFormat rung renders the same", () => { + expect( + getRecordDisplayName( + { titleFormat: TEMPLATE }, + { id: 'c3', contract_no: 'HT-2026-003', name: ' ' }, + ), + ).toBe('HT-2026-003'); + }); + + it('CONTROL — a real value is unchanged, at both doors', () => { + const record = { id: 'c4', contract_no: 'HT-2026-003', name: 'Acme' }; + expect(formatTitleTemplate(TEMPLATE, record)).toBe('HT-2026-003 - Acme'); + expect(getRecordDisplayName({ titleFormat: TEMPLATE }, record)).toBe('HT-2026-003 - Acme'); + }); + + it('a blank LEADING placeholder drops with its separator too', () => { + expect(formatTitleTemplate(TEMPLATE, { contract_no: ' ', name: 'Acme' })).toBe('Acme'); + }); +}); + +describe('formatTitleTemplate — a placeholder is empty exactly when `recordDisplayValueAt` says so (#10446)', () => { + const values: Array<[string, unknown]> = [ + ['undefined', undefined], + ['null', null], + ["''", ''], + ['spaces', ' '], + ['no-break spaces', '  '], + ['padded value', ' Acme '], + ['0', 0], + ['false', false], + ['expanded lookup with a name', { id: 'a1', name: 'Acme' }], + ['expanded lookup with a blank name', { id: 'a1', name: ' ' }], + ['bare lookup payload', { id: 'a1' }], + ]; + + for (const [label, v] of values) { + it(`${label}`, () => { + const shown = recordDisplayValueAt({ v }, 'v'); + const expected = shown === undefined ? 'K' : `${shown} - K`; + expect(formatTitleTemplate('{v} - {k}', { v, k: 'K' })).toBe(expected); + }); + } +}); diff --git a/packages/core/src/utils/record-title.ts b/packages/core/src/utils/record-title.ts index 12f75a2eac..d7e714385e 100644 --- a/packages/core/src/utils/record-title.ts +++ b/packages/core/src/utils/record-title.ts @@ -150,10 +150,20 @@ function displayNameOfEmbeddedObject(o: any): string | null { * supported. Dotted paths (`{account.name}`) walk `$expanded` lookup objects; a * placeholder that resolves to a whole object is reduced to its display name. * - * Empty placeholders (missing / null / empty fields) are stripped along with - * any orphan separator they leave behind, so `"{full_name} - {company}"` - * evaluated against `{ company: "Acme" }` yields `"Acme"`, not `" - Acme"`. - * Returns an empty string when no placeholder resolved. + * Empty placeholders (missing / null / empty / whitespace-only fields) are + * stripped along with any orphan separator they leave behind, so + * `"{full_name} - {company}"` evaluated against `{ company: "Acme" }` yields + * `"Acme"`, not `" - Acme"`. Returns an empty string when no placeholder + * resolved. + * + * What counts as "empty" is not decided here (objectui#10446). Each + * placeholder's value goes through {@link displayValueOf}, the body of + * {@link recordDisplayValueAt}: trim, then empty. This function used to test + * the raw value against `null` / `undefined` / `''` on its own, so a + * whitespace-only value counted as RESOLVED. The later whitespace collapse + * then left its separator dangling: `{contract_no} - {name}` with a blank + * `name` rendered `HT-2026-003 -`, while every value-keyed rung of + * {@link getRecordDisplayName} judged the same value empty. */ export function formatTitleTemplate( titleFormat: string | { source?: string } | undefined | null, @@ -180,14 +190,10 @@ export function formatTitleTemplate( if (value == null) break; value = (value as any)[p]; } - if (value && typeof value === 'object') { - value = displayNameOfEmbeddedObject(value as any); - } - if (value === null || value === undefined || value === '') { - return EMPTY_TOKEN; - } + const shown = displayValueOf(value); + if (shown === undefined) return EMPTY_TOKEN; anyResolved = true; - return String(value); + return shown; }); if (!anyResolved) return ''; @@ -473,10 +479,28 @@ const NAME_ISH_RECORD_KEYS = [ * `{ id }` lookup payload is not a title); * - non-strings are stringified, so `0` and `false` ARE values, not blanks; * - `null` and `undefined` are empty, and only those two. + * + * The rule itself is {@link displayValueOf}, which {@link formatTitleTemplate} + * applies to each placeholder value (objectui#10446). So a `titleFormat` + * placeholder is empty exactly when this function would call its field empty. */ export function recordDisplayValueAt(record: any, field: string | undefined): string | undefined { if (!field || !record || typeof record !== 'object') return undefined; - let v: any = record[field]; + return displayValueOf(record[field]); +} + +/** + * The value half of {@link recordDisplayValueAt}: one already-read value in, + * its display string or `undefined` (empty) out. Trim, then empty. + * + * Module-private on purpose. It exists so the two readers in this file that + * judge a single value, `recordDisplayValueAt` and each placeholder of + * {@link formatTitleTemplate}, run ONE implementation of that judgement rather + * than two that agree today (objectui#10446). Read its full definition on + * {@link recordDisplayValueAt}. + */ +function displayValueOf(value: any): string | undefined { + let v: any = value; if (v && typeof v === 'object') v = displayNameOfEmbeddedObject(v); if (v === null || v === undefined) return undefined; const s = typeof v === 'string' ? v.trim() : String(v); diff --git a/packages/plugin-detail/src/renderers/__tests__/record-details.titleFormatOneInterpolator-10447.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-details.titleFormatOneInterpolator-10447.test.tsx new file mode 100644 index 0000000000..6e5c4e02ac --- /dev/null +++ b/packages/plugin-detail/src/renderers/__tests__/record-details.titleFormatOneInterpolator-10447.test.tsx @@ -0,0 +1,215 @@ +/** + * 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 and `record:details`' H1 dedupe render the SAME + * `titleFormat` string, so the dedupe hides the row that matches the H1 and + * no other (objectui#10447, with objectui#10446 folded in). + * + * ## The defect + * + * `page:header` rendered the legacy template with its own interpolation, while + * the dedupe asks core's `formatTitleTemplate` what the H1 shows. The two + * disagreed, so the dedupe reasoned about a heading the user never saw: + * + * - THE CARD (#10447): `{account} - {deal_no}` with an expanded `account`. + * The H1 dropped the lookup and read `Q3-042`; core rendered the composite + * `Acme - Q3-042`, which no row equals, so nothing was hidden and the + * "Deal No" row printed `Q3-042` directly under an H1 reading `Q3-042`. + * - #10446: `{contract_no} - {name}` with a whitespace-only `name`. The H1's + * own separator cleanup read `HT-2026-003`; core counted the blank as a + * value and rendered `HT-2026-003 -`, again equal to no row, so the + * "Contract No" row printed under an H1 showing the same value. + * + * The H1 now renders through `formatTitleTemplate` too, and core judges a + * whitespace-only placeholder empty. So each case asserts the H1 IS core's + * string for that record before it asserts which row the body drops. + * + * The one thing the H1 adds is a select value's option label. It is applied to + * the record before core renders it, and never changes WHICH placeholders + * resolve, so the dedupe (which renders over the raw record) still decides + * about the field the H1 shows. The last two cases pin that. + * + * ## The surface under test + * + * Each case renders the REAL `page:header` beside the REAL body under one + * record context, as `record-details.titleFormatPlaceholderDedupe-10360.test.tsx` + * does, and reads both the H1 the user sees and which row the body drops. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import * as React from 'react'; +// Registers `page:header` (and the rest of the atoms) on the shared registry. +import '@object-ui/components'; +import { ComponentRegistry, formatTitleTemplate } from '@object-ui/core'; +import { ActionProvider, RecordContextProvider } from '@object-ui/react'; +import { RecordDetailsRenderer } from '../record-details'; + +/** + * Pinned rather than inherited, for the reason + * `record-details.dedupeEmptinessTrims-8350.test.tsx` states: `DetailSection` + * can also drop an EMPTY row through a viewport-dependent auto-hide. On the + * desktop branch no fixture here reaches its 4-row minimum, so a row can only + * be missing because the dedupe dropped it. + */ +const DESKTOP_WIDTH = 1280; + +/** Render the header and the body under ONE record context; read both. */ +function renderPage( + record: any, + schema: any, + fields: string[], +): { h1: string | null | undefined; body: string } { + const Header = ComponentRegistry.get('page:header'); + if (!Header) throw new Error('page:header not registered'); + const { container } = render( + + +
+
+
+
+ +
+
+
, + ); + const h1 = container.querySelector('[data-testid="page-header"] h1')?.textContent; + const body = container.querySelector('[data-testid="page-body"]')?.textContent ?? ''; + return { h1, body }; +} + +beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: DESKTOP_WIDTH }); + // `useRecordEditable` probes `POST /api/v1/security/explain`; happy-dom + // would resolve that to a REAL socket (objectui#6640). Its answer is + // orthogonal to which heading renders and which row is hidden. + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ allowed: true }), + text: async () => '{"allowed":true}', + })) as never); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +/** + * No name pointer, so the template titles the H1. The type-aware derivation + * lands on `deal_no` (the only text field); that only puts it on the dedupe's + * candidate list, it does not change the heading. + */ +const dealSchema = { + name: 'deal', + label: 'Deal', + titleFormat: '{account} - {deal_no}', + fields: { + account: { type: 'lookup', reference_to: 'account', label: 'Account' }, + deal_no: { type: 'text', label: 'Deal No' }, + amount: { type: 'number', label: 'Amount' }, + }, +}; +const DEAL_FIELDS = ['account', 'deal_no', 'amount']; + +describe('page:header and record:details render one `titleFormat` string (#10447)', () => { + it('THE CARD — the H1 is core\'s composite, so no row duplicates it and "Deal No" stays', () => { + const record = { id: 'D1', account: { id: 'a1', name: 'Acme' }, deal_no: 'Q3-042', amount: 5 }; + + const { h1, body } = renderPage(record, dealSchema, DEAL_FIELDS); + + expect(h1).toBe(formatTitleTemplate(dealSchema.titleFormat, record)); + expect(h1).toBe('Acme - Q3-042'); + // The H1 is no single field's value: the dedupe hides nothing, and the + // body does not repeat the heading. + expect(body).not.toContain(h1); + expect(body).toContain('Deal No'); + expect(body).toContain('Amount'); // CONTROL: the body rendered at all + }); + + it('CONTROL — a lookup with no display name drops, the H1 collapses onto `deal_no`, and that row goes', () => { + const record = { id: 'D2', account: { id: 'a1' }, deal_no: 'Q3-042', amount: 6 }; + + const { h1, body } = renderPage(record, dealSchema, DEAL_FIELDS); + + expect(h1).toBe(formatTitleTemplate(dealSchema.titleFormat, record)); + expect(h1).toBe('Q3-042'); + expect(body).not.toContain('Q3-042'); + expect(body).not.toContain('Deal No'); + expect(body).toContain('Amount'); // CONTROL: the rows the H1 does not show stay + }); + + it('#10446 — a whitespace-only `name` drops with its separator in BOTH, so the "Contract No" row goes', () => { + const schema = { + name: 'contract', + label: 'Contract', + titleFormat: '{contract_no} - {name}', + fields: { + contract_no: { type: 'text', label: 'Contract No' }, + name: { type: 'text', label: 'Name' }, + amount: { type: 'number', label: 'Amount' }, + }, + }; + const record = { id: 'C3', contract_no: 'HT-2026-003', name: ' ', amount: 13 }; + + const { h1, body } = renderPage(record, schema, ['contract_no', 'name', 'amount']); + + expect(h1).toBe(formatTitleTemplate(schema.titleFormat, record)); + expect(h1).toBe('HT-2026-003'); + expect(body).not.toContain('HT-2026-003'); + expect(body).not.toContain('Contract No'); + expect(body).toContain('Amount'); // CONTROL: the body rendered at all + }); +}); + +describe('a select token\'s option label does not move the dedupe off the field the H1 shows (#10447)', () => { + const ticketSchema = { + name: 'ticket', + label: 'Ticket', + titleFormat: '{ticket_no} - {status}', + fields: { + ticket_no: { type: 'text', label: 'Ticket No' }, + status: { + type: 'select', + label: 'Status', + options: [{ value: 'in_progress', label: 'In Progress' }], + }, + amount: { type: 'number', label: 'Amount' }, + }, + }; + const TICKET_FIELDS = ['ticket_no', 'status', 'amount']; + + it('the H1 collapses onto `status` and shows its LABEL; the Status row is the one that goes', () => { + const record = { id: 'T1', ticket_no: '', status: 'in_progress', amount: 7 }; + + const { h1, body } = renderPage(record, ticketSchema, TICKET_FIELDS); + + expect(h1).toBe('In Progress'); + expect(body).not.toContain('Status'); + expect(body).toContain('Ticket No'); // the row the H1 does not show stays + expect(body).toContain('Amount'); // CONTROL: the body rendered at all + }); + + it('CONTROL — both placeholders resolve: a composite H1 with the label, and both rows stay', () => { + const record = { id: 'T2', ticket_no: 'TK-9', status: 'in_progress', amount: 8 }; + + const { h1, body } = renderPage(record, ticketSchema, TICKET_FIELDS); + + expect(h1).toBe('TK-9 - In Progress'); + expect(body).toContain('TK-9'); + expect(body).toContain('Status'); + }); +});