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
40 changes: 40 additions & 0 deletions .changeset/8351-titleformat-hides-no-row.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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(
<RecordContextProvider
objectName={schema?.name ?? 'contract'}
recordId={record.id}
data={record}
objectSchema={schema}
>
<RecordDetailsRenderer schema={{ fields } as never} />
</RecordContextProvider>,
);
}

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
});
});
88 changes: 76 additions & 12 deletions packages/plugin-detail/src/renderers/record-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { RecordDetailsComponentProps } from '@object-ui/types';
import {
columnIdentity,
deriveTitleField,
formatTitleTemplate,
isObjectInlineEditable,
recordDisplayValueAt,
resolveNameField,
Expand Down Expand Up @@ -344,14 +345,26 @@ export const RecordDetailsRenderer: React.FC<RecordDetailsRendererProps> = ({
// 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`
Expand Down Expand Up @@ -406,10 +419,61 @@ export const RecordDetailsRenderer: React.FC<RecordDetailsRendererProps> = ({
// 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;
}
}
}

Expand Down
Loading