From 425cd8565ca457fbbb2449a804660ac0fd5c251c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 21:40:11 +0000 Subject: [PATCH] fix(plugin-detail): an interpolated `titleFormat` H1 hides no field row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#8351, maintainer ruling 2026-09-08 (decision batch #82), option B. `record:details`' dedupe ladder answers "which single field is the H1 showing". A `titleFormat` rung answers with a rendered TEMPLATE, which on a multi-field format is no field's value at all — so the ladder walked past it onto `resolveNameField` and hid THAT row, a row the H1 never showed. Nothing errored; a field was simply absent from the grid. "Fully interpolates" is measured with the instruments already in the chain, not a new predicate: `formatTitleTemplate` (what `getRecordDisplayName` step 3 and this package's own `DetailView.resolveDisplayTitle` step 2 both call) renders the title, and `recordDisplayValueAt` — the emptiness authority the rest of the ladder already imports — answers whether that string IS some candidate's value. Three outcomes: - composite, no candidate's value equals it -> hide NOTHING (the ruled case); - renders nothing on this record -> the header has already walked past this rung, so the existing value-keyed walk runs unchanged; - collapses onto ONE field's value (a blank placeholder dropped with its orphan separator, or a single-field format) -> that row IS the duplicate and still goes. The match is a SCAN of the candidates rather than a peek at the first one with a value: with `titleFormat: '{name}'` over `nameField: 'contract_no'` the first resolving candidate is `contract_no` while the H1 is `name`'s value, so stopping early hides the wrong row and leaves the real duplicate. Deliberately untouched, by the same ruling: `page:header`'s own `schema.title` (invisible to this package), and the ORDER in which `PageHeaderRenderer` ranks `titleFormat` against the ADR-0079 pointer — that moves what the H1 shows on existing records and retires a green pin, so it carries its own `needs-user-decision` card. This ladder does not depend on which of the two wins the header. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011QreXiyMEqKLN4U5daMPVa --- .changeset/8351-titleformat-hides-no-row.md | 40 +++ ...-details.titleFormatNoDedupe-8351.test.tsx | 234 ++++++++++++++++++ .../src/renderers/record-details.tsx | 88 ++++++- 3 files changed, 350 insertions(+), 12 deletions(-) create mode 100644 .changeset/8351-titleformat-hides-no-row.md create mode 100644 packages/plugin-detail/src/renderers/__tests__/record-details.titleFormatNoDedupe-8351.test.tsx diff --git a/.changeset/8351-titleformat-hides-no-row.md b/.changeset/8351-titleformat-hides-no-row.md new file mode 100644 index 0000000000..8dc1dbeb70 --- /dev/null +++ b/.changeset/8351-titleformat-hides-no-row.md @@ -0,0 +1,40 @@ +--- +'@object-ui/plugin-detail': patch +--- + +`record:details`' dedupe no longer hides a field row when the page H1 is an +interpolated `titleFormat` that names no single field (objectui#8351, maintainer +ruling 2026-09-08, decision batch #82, option B). + +**What changed for a user.** On an object declaring a `titleFormat`, a record +whose title renders as a composite — `"{contract_no} - {name}"` rendering +`HT-2026-001 - Acme Corporation` — now shows **every** field in the details grid. +Before, one row silently disappeared: the ladder asked "which single field is the +H1 showing?", got the ADR-0079 declared pointer as an answer, and hid that row — +while the H1 was showing a template that is not that field's value, and is not any +field's value. Nothing errored; a field was simply absent. + +**What did not change.** The dedupe still removes a genuine duplicate. Three cases +are distinguished, and the rendered title decides which: + +- the template renders a composite no field's value equals → nothing is hidden; +- the template renders nothing on this record (no placeholder resolved) → the + header has already walked past that rung onto the declared pointer, and the + existing ladder hides that row exactly as before; +- the template collapses onto one field's value — a blank placeholder dropped with + its orphan separator, or a single-field format like `"{name}"` → that row *is* + the duplicate and is still hidden. + +"Fully interpolates" is measured with the instruments already in the chain, not a +new predicate: `formatTitleTemplate` (what `getRecordDisplayName` step 3 and +`DetailView.resolveDisplayTitle` step 2 both call) renders the title, and +`recordDisplayValueAt` — the same emptiness authority the rest of the ladder uses +— answers whether that string is some candidate's value. + +**Deliberately out of scope**, both by the same ruling: `page:header`'s own +`schema.title`, which this package cannot see at all; and the order in which +`PageHeaderRenderer` ranks `titleFormat` against the ADR-0079 name pointer, which +would move what the H1 *shows* on existing records and retire a currently-green +pin — a maintainer question carrying its own card. This fix deliberately does not +depend on which of those two rungs wins the header: it compares the rendered title +against the candidates' values, which answers the dedupe under either order. diff --git a/packages/plugin-detail/src/renderers/__tests__/record-details.titleFormatNoDedupe-8351.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-details.titleFormatNoDedupe-8351.test.tsx new file mode 100644 index 0000000000..cb915870c4 --- /dev/null +++ b/packages/plugin-detail/src/renderers/__tests__/record-details.titleFormatNoDedupe-8351.test.tsx @@ -0,0 +1,234 @@ +/** + * 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. + */ + +/** + * `record:details`' dedupe must not hide a row when the H1 is an interpolated + * `titleFormat` that names no single field (objectui#8351, ruled option B). + * + * ## The surface under test is the DEDUPE, not the title + * + * Same boundary as `record-details.nameFieldDedupe-8175.test.tsx`: this + * renderer draws the body grid, never the H1. Every case below therefore + * asserts which row RENDERS and which row DROPS. + * + * ## The defect + * + * The ladder is keyed on "which single FIELD is the H1 showing". A + * `titleFormat` rung answers with a rendered TEMPLATE, which on a multi-field + * format is no field's value at all. The ladder had no rung for it, so it + * walked straight on to `resolveNameField` and hid THAT row — a row whose + * value the H1 was not showing. The field simply vanished from the grid, and + * nothing errored. + * + * ## What "fully interpolates" is measured against (objectui#8351 ZONE 2) + * + * Not a new predicate. The ladder asks `formatTitleTemplate` — the SAME + * function `getRecordDisplayName`'s step 3 and this package's own + * `DetailView.resolveDisplayTitle` step 2 call — for the title the template + * renders on THIS record, and then asks `recordDisplayValueAt` whether that + * string IS some candidate's value: + * + * - renders a composite (no candidate's value equals it) -> hide NOTHING. + * That is the ruled case: "the H1 is not any single field's value, so + * there is no row to hide". + * - renders nothing (no placeholder resolved) -> the header has already + * walked past the template rung, so the ladder runs unchanged. + * - collapses onto ONE field's value (a blank placeholder was dropped, or + * the format names a single field) -> that row IS the duplicate the + * dedupe exists for, and it still goes. + * + * The last two are the lit controls: a rule that suppressed the dedupe on the + * mere PRESENCE of a resolving `titleFormat` prints "Contract No: HT-2026-003" + * directly under an H1 reading `HT-2026-003`, which is the exact duplication + * Phase P.0 of the ladder exists to remove. + * + * ⛔ Out of scope by the same ruling: `page:header`'s own `schema.title`, which + * this package cannot see, and the ORDER in which `PageHeaderRenderer` ranks + * `titleFormat` against the ADR-0079 pointer (its own card). + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import * as React from 'react'; +import { RecordContextProvider } from '@object-ui/react'; +import { RecordDetailsRenderer } from '../record-details'; + +/** + * An object that declares BOTH a `nameField` and a two-field `titleFormat`. + * The two answer differently on every record below, so "which row is hidden" + * is answerable from the DOM alone. + */ +const contractSchema = { + name: 'contract', + label: 'Contract', + nameField: 'contract_no', + titleFormat: '{contract_no} - {name}', + fields: { + contract_no: { type: 'text', label: 'Contract No' }, + name: { type: 'text', label: 'Name' }, + amount: { type: 'number', label: 'Amount' }, + }, +}; + +const CONTRACT_FIELDS = ['contract_no', 'name', 'amount']; + +function renderBody(record: any, schema: any, fields: string[]) { + return render( + + + , + ); +} + +beforeEach(() => { + // `useRecordEditable` probes `POST /api/v1/security/explain` for the + // ROW-level verdict; happy-dom resolves that relative URL to a REAL socket, + // which the repo's network-escape guard fails the file for (objectui#6640). + // Serve it from a double — its answer is orthogonal to 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(); +}); + +describe('record:details dedupe — an interpolated `titleFormat` hides no row (#8351)', () => { + /** + * The halves are separate cases ON PURPOSE, exactly as in the objectui#8175 + * pin: asserting both inside one `it` lets the first failure short-circuit + * the second, so an ablation of the read site reddens one half and says + * nothing at all about the other. + */ + it('HALF 1 — KEEPS the declared `nameField` row (the H1 is not its value)', () => { + renderBody( + { id: 'C1', contract_no: 'HT-2026-001', name: 'Acme Corporation', amount: 42 }, + contractSchema, + CONTRACT_FIELDS, + ); + + // The H1 for this record reads `HT-2026-001 - Acme Corporation`. It is not + // `contract_no`'s value, so `contract_no` duplicates no heading — and the + // ladder used to hide it anyway, because `resolveNameField` names it. + expect(screen.getByText('HT-2026-001')).toBeInTheDocument(); + + // CONTROL — the grid rendered at all. Without it a build that rendered + // nothing would satisfy nothing here, and `queryByText === null` cases + // below are trivially true on an empty document. + expect(screen.getByText('42')).toBeInTheDocument(); + expect(screen.getByText('Amount')).toBeInTheDocument(); + }); + + it('HALF 2 — KEEPS the ordinary `name` row too (the template names no ONE field)', () => { + renderBody( + { id: 'C1', contract_no: 'HT-2026-001', name: 'Acme Corporation', amount: 42 }, + contractSchema, + CONTRACT_FIELDS, + ); + + expect(screen.getByText('Acme Corporation')).toBeInTheDocument(); + + // CONTROL — the grid rendered at all. + expect(screen.getByText('42')).toBeInTheDocument(); + }); + + it('LIT CONTROL A — a `titleFormat` that resolves to NOTHING leaves the ladder alone', () => { + // No placeholder resolves on this record, so `formatTitleTemplate` returns + // '' and the header has already walked PAST the template rung onto the + // declared pointer. The H1 reads `HT-2026-002`, and that row must still go. + renderBody( + { id: 'C2', contract_no: 'HT-2026-002', name: 'internal-name', amount: 7 }, + { ...contractSchema, titleFormat: '{ref_a} - {ref_b}' }, + CONTRACT_FIELDS, + ); + + expect(screen.queryByText('HT-2026-002')).toBeNull(); + expect(screen.getByText('internal-name')).toBeInTheDocument(); + expect(screen.getByText('7')).toBeInTheDocument(); // CONTROL: grid rendered + }); + + it('LIT CONTROL B — a template that COLLAPSES onto one field still hides that row', () => { + // `name` is blank, so `formatTitleTemplate` drops that placeholder and the + // orphan separator with it: the H1 reads exactly `HT-2026-003`. That IS + // `contract_no`'s value, so the row is a real duplicate and still goes. + // This is the case a presence-only rule gets wrong — it would print + // "Contract No: HT-2026-003" directly under an identical H1. + renderBody( + { id: 'C3', contract_no: 'HT-2026-003', name: '', amount: 9 }, + contractSchema, + CONTRACT_FIELDS, + ); + + expect(screen.queryByText('HT-2026-003')).toBeNull(); + expect(screen.getByText('9')).toBeInTheDocument(); // CONTROL: grid rendered + }); + + it('LIT CONTROL C — a single-field `titleFormat` is that field, and still dedupes', () => { + // `{name}` renders exactly `name`'s value. "Interpolated" does not mean + // "composite": the ruled reason is that the H1 names no ONE field, and + // here it names exactly one. + renderBody( + { id: 'C4', name: 'Acme Corporation', amount: 5 }, + { + name: 'plain', + label: 'Plain', + titleFormat: '{name}', + fields: { + name: { type: 'text', label: 'Name' }, + amount: { type: 'number', label: 'Amount' }, + }, + }, + ['name', 'amount'], + ); + + expect(screen.queryByText('Acme Corporation')).toBeNull(); + expect(screen.getByText('5')).toBeInTheDocument(); // CONTROL: grid rendered + }); + + it('hides the row the TEMPLATE names, not the one the declared pointer names', () => { + // `titleFormat` is `{name}` while `nameField` points at `contract_no`. The + // H1 reads `Acme Corporation`. The ladder's first candidate WITH A VALUE + // is `contract_no` — so a walk that stops at the first resolving candidate + // hides `HT-2026-005`, a row the H1 never showed, and leaves the real + // duplicate printed underneath. Both halves wrong at once, the objectui#8175 + // shape one rung further along. + renderBody( + { id: 'C5', contract_no: 'HT-2026-005', name: 'Acme Corporation', amount: 3 }, + { ...contractSchema, titleFormat: '{name}' }, + CONTRACT_FIELDS, + ); + + expect(screen.queryByText('Acme Corporation')).toBeNull(); + expect(screen.getByText('HT-2026-005')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); // CONTROL: grid rendered + }); + + it('CONTROL — an object with NO `titleFormat` dedupes exactly as before', () => { + // The objectui#8175 ladder, untouched. Without this case, a change that + // disabled the H1 dedupe outright would pass every assertion above. + renderBody( + { id: 'C6', contract_no: 'HT-2026-006', name: 'internal-name', amount: 11 }, + { ...contractSchema, titleFormat: undefined }, + CONTRACT_FIELDS, + ); + + expect(screen.queryByText('HT-2026-006')).toBeNull(); + expect(screen.getByText('internal-name')).toBeInTheDocument(); + expect(screen.getByText('11')).toBeInTheDocument(); // CONTROL: grid rendered + }); +}); diff --git a/packages/plugin-detail/src/renderers/record-details.tsx b/packages/plugin-detail/src/renderers/record-details.tsx index d16371f23d..badbb75bdd 100644 --- a/packages/plugin-detail/src/renderers/record-details.tsx +++ b/packages/plugin-detail/src/renderers/record-details.tsx @@ -18,6 +18,7 @@ import type { RecordDetailsComponentProps } from '@object-ui/types'; import { columnIdentity, deriveTitleField, + formatTitleTemplate, isObjectInlineEditable, recordDisplayValueAt, resolveNameField, @@ -344,14 +345,26 @@ export const RecordDetailsRenderer: React.FC = ({ // lets the fall-through happen here too. When nothing is declared the two // return the same name and the first one simply wins. // - // ⚠️ Two disagreements with the header chain are KNOWN and deliberately NOT - // repaired here — both are rungs that name no FIELD, so there is no row to - // hide for either, and closing them is a separate ruling: - // - `page:header`'s own `schema.title`, which this package cannot see; - // - `objectSchema.titleFormat`, a render-only template the header ranks - // ABOVE the declared pointer (pinned in `@object-ui/components`' - // `__tests__/page-header-title.test.tsx`, "titleFormat still outranks - // nameField"), interpolating any number of fields. + // ⚠️ Two rungs of the header chain name no FIELD at all, so a dedupe keyed + // on "which single field is the H1" is structurally unable to answer them + // (objectui#8351). They are NOT symmetric and only ONE is answered here: + // - `objectSchema.titleFormat` — ANSWERED, below, by the ruled option B: + // the template's rendered output is compared against the candidates' + // values, and a composite that is no field's value hides no row. + // - `page:header`'s own `schema.title` — NOT answered, and not answerable + // from this package: it is a key on the HEADER schema, which + // `record:details` never receives. Same shape, its own card. + // + // ⛔ Neither of those is the ORDER question. `PageHeaderRenderer` ranks the + // interpolated `titleFormat` ABOVE the ADR-0079 declared pointer, while + // `getRecordDisplayName` documents it BELOW (step 3) — pinned green in + // `@object-ui/components`' `__tests__/page-header-title.test.tsx` as + // "titleFormat still outranks nameField". Closing that divergence moves what + // the H1 SHOWS on existing records and retires that pin, so it is a + // maintainer ruling and carries its own `needs-user-decision` card. This + // ladder deliberately does not depend on which of the two wins: it asks + // whether the rendered template IS some candidate's value, which answers the + // dedupe under either order. // // ⛔ `objSchema?.primaryField` used to top this list, and it is gone // (objectui#7586). It is a `DetailViewSchema` key (`@object-ui/types` @@ -406,10 +419,61 @@ export const RecordDetailsRenderer: React.FC = ({ // down: it would still disagree with the header about an expanded lookup // object whose display chain yields nothing (`{ id: 'u1' }` is not a title), // which the raw test — and a trim of it — both read as a value. - for (const candidate of titleCandidates) { - if (recordDisplayValueAt(data, candidate) !== undefined) { - hideFieldNames.add(candidate); - break; + // + // ⭐ THE `titleFormat` RUNG (objectui#8351, maintainer ruling option B). + // + // A `titleFormat` H1 is a rendered TEMPLATE, not a field. On a multi-field + // format it is no single field's value, so NO row duplicates it and the walk + // below must not run at all — it would hide `resolveNameField`'s row, a row + // the H1 never showed, exactly the "a field silently vanished" shape + // objectui#8175 closed one rung higher. + // + // ⚠️ "Fully interpolates" is measured, not assumed, and it is measured with + // the instruments already here — no new predicate, which is the same rule + // the emptiness note below states: + // - `formatTitleTemplate` is THE renderer of this rung. It is what + // `getRecordDisplayName` step 3 calls and what this package's own + // `DetailView.resolveDisplayTitle` step 2 calls, so all three agree + // about what the template produces on a given record. + // - `recordDisplayValueAt` then answers the only question a dedupe has: + // is that string some candidate's value? + // + // Three outcomes, and the two that are NOT the ruled case are what keep this + // honest: + // - composite (no candidate's value equals it) → hide NOTHING. The ruled + // case: "the H1 is not any single field's value, so there is no row to + // hide". + // - empty (no placeholder resolved on this record) → the header has + // ALREADY walked past this rung onto the declared pointer, so the + // value-keyed walk below runs unchanged. Suppressing on the mere + // PRESENCE of a `titleFormat` would blind the dedupe on every record + // where the template renders nothing. + // - collapsed onto ONE field's value (a blank placeholder was dropped with + // its orphan separator, or the format names a single field) → that row + // IS the duplicate, and it still goes. A presence-only rule prints + // "Contract No: HT-0001" directly beneath an H1 reading `HT-0001`, which + // is the duplication Phase P.0 exists to remove. + // + // Pinned in `__tests__/record-details.titleFormatNoDedupe-8351.test.tsx`, + // which asserts which row RENDERS and which row DROPS — never the heading, + // which this package does not draw. + // + // ⚠️ The match is a SCAN of the candidates, not a peek at the first one with + // a value: with `titleFormat: '{name}'` over `nameField: 'contract_no'` the + // first resolving candidate is `contract_no` and the H1 is `name`'s value, + // so stopping early would hide the wrong row AND leave the real duplicate. + const interpolatedTitle = formatTitleTemplate(objSchema?.titleFormat, data); + if (interpolatedTitle) { + const shownAs = titleCandidates.find( + (candidate) => recordDisplayValueAt(data, candidate) === interpolatedTitle, + ); + if (shownAs) hideFieldNames.add(shownAs); + } else { + for (const candidate of titleCandidates) { + if (recordDisplayValueAt(data, candidate) !== undefined) { + hideFieldNames.add(candidate); + break; + } } }