From 091d12ced905a215f32f0cc90dc64a756828b154 Mon Sep 17 00:00:00 2001 From: os-justin Date: Wed, 9 Sep 2026 01:31:42 +0000 Subject: [PATCH 1/4] fix(plugin-detail): the summary chip beside the H1 draws an object value through its own cell renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `effectiveSummaryFields`' chip displayed `String(val)` with four families special-cased, so an object-valued summary field — an expanded lookup payload, a location, an address — printed the literal `[object Object]` next to the page title, and its accessible name (built from the same string) said it too. Reachable both ways: `schema.summaryFields` is author-declared and never filtered by type, and the auto-detection can hand the status slot to a field whose stored value is not a scalar. Which question this chip asks was measured, not argued: it already prints `$1,235` for a stored `1234.5`, `Mar 4, 2026` for `'2026-03-04'` and `Closed Won` for `'won'` — the seen face, never the stored one — so the display authority for a kind it does not format is that kind's own cell renderer, the way `HeaderHighlight` reads it one band below. That route is not free. A Badge is a much smaller surface than a cell: with all 53 registered types rendered through `getCellRenderer` inside the real chip Badge against an object value, 15 draw a nested pill, an avatar composite, a bare `` with no text, or a "No value" face for a value `hasCellValue` had just called filled. Those kinds are named with the measurement in `summaryChipRenderers.ts` and take `coerceToSafeValue`, this repo's single answer to the same question and byte-equal to what seven of them print in their own cell (objectui#8596). Nothing here invents a chip-local stringifier. The switch fires on the defect's own signature — the string path having produced the placeholder — so every value that already rendered, scalars and scalar arrays included, is byte-for-byte untouched, and the emptiness classification objectui#8394 converged onto `hasCellValue` is re-pinned rather than moved. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S --- packages/plugin-detail/src/DetailView.tsx | 76 +++ .../summaryChip.badgeFitCensus-8464.test.tsx | 285 +++++++++++ .../summaryChip.objectValue-8464.test.tsx | 482 ++++++++++++++++++ .../plugin-detail/src/summaryChipRenderers.ts | 86 ++++ 4 files changed, 929 insertions(+) create mode 100644 packages/plugin-detail/src/__tests__/summaryChip.badgeFitCensus-8464.test.tsx create mode 100644 packages/plugin-detail/src/__tests__/summaryChip.objectValue-8464.test.tsx create mode 100644 packages/plugin-detail/src/summaryChipRenderers.ts diff --git a/packages/plugin-detail/src/DetailView.tsx b/packages/plugin-detail/src/DetailView.tsx index 1b214e5118..01e91cc672 100644 --- a/packages/plugin-detail/src/DetailView.tsx +++ b/packages/plugin-detail/src/DetailView.tsx @@ -51,7 +51,10 @@ import { useLocalization, resolveFieldCurrency } from '@object-ui/i18n'; import type { DetailViewSchema, DataSource, ActionSchema, SchemaNode } from '@object-ui/types'; import { useDetailTranslation } from './useDetailTranslation'; import { useRecordEditable } from './useRecordEditable'; +import { getCellRenderer, resolveCellRendererType, coerceToSafeValue } from '@object-ui/fields'; import { hasCellValue } from './emptiness'; +import { enrichDetailField } from './fieldEnrichment'; +import { chipTakesCellRenderer } from './summaryChipRenderers'; /** Default page size for related lists in the detail view */ const DEFAULT_RELATED_PAGE_SIZE = 5; @@ -1108,6 +1111,77 @@ export const DetailView: React.FC = ({ } catch { /* fall back to String(val) */ } + + // ── The chip's STRING path cannot express an object ─────── + // + // `String({…})` is the literal `[object Object]`, and it + // reached the reader twice: as the chip's text beside the H1 + // and, because the accessible name is built from the same + // string, as the chip's accessible name (objectui#8464). + // Every branch above lands here too — `Number({})` is `NaN`, + // `new Date({})` is Invalid, and the option lookup falls back + // to `String(val)` — so the four formatted families are + // caught by this one test rather than by four of their own. + // + // The test is the DEFECT'S OWN SIGNATURE, not a type guess: + // it fires exactly where the placeholder was produced, so a + // value the string path already renders (`['a','b']` → + // `a,b`, every scalar) is byte-for-byte untouched. + // + // ⭐ Which side this chip is on was MEASURED, not argued. + // objectui#8395 established on this page that "render what + // the user sees" and "render the underlying value" give + // different answers per kind. This chip already answers the + // FIRST question for every family it formats — it prints + // `$1,235` for a stored `1234.5`, `Mar 4, 2026` for + // `'2026-03-04'`, `Closed Won` for `'won'` — so the display + // authority is the field's own cell renderer, exactly as + // `HeaderHighlight` reads it one band below. + // + // ⚠️ …but only where a pill can host it. A Badge is a much + // smaller surface than a cell: 15 of the 53 registered types + // draw a nested pill, an avatar composite, a bare `` + // with no text, or a "No value" face for a value + // `hasCellValue` just called FILLED. Those kinds are named, + // with the measurement, in `./summaryChipRenderers`, and they + // take `coerceToSafeValue` — this package's single answer to + // the same question, and byte-equal to what seven of them + // print in their own cell (objectui#8596). + const chipField = enrichDetailField( + { name: fieldName, label: sectionField?.label, type: ftype || 'text' }, + objField, + ); + const chipRendererType = + resolveCellRendererType(chipField as any) || ftype || 'text'; + const ChipCellRenderer = + display.includes('[object Object]') && chipTakesCellRenderer(chipRendererType) + ? getCellRenderer(chipRendererType) + : null; + if (!ChipCellRenderer && display.includes('[object Object]')) { + display = String(coerceToSafeValue(val) ?? ''); + } + + if (ChipCellRenderer) { + return ( + + {/* The chip carries no visible label, so the field name + reached the reader only through the `aria-label` + that the string branches below still set. A renderer + draws an ELEMENT, and an `aria-label` would override + it — hiding the very value this branch exists to + show. Same accessible name, `field: value`, composed + from content instead. */} + {`${fieldName}: `} + + + ); + } + if (percentValue !== null) { return ( = ({ variant="secondary" className="text-xs bg-primary/10 text-primary border-transparent hover:bg-primary/15 gap-1.5 pl-2 pr-2" aria-label={`${fieldName}: ${display}`} + data-summary-chip={fieldName} > = ({ variant="secondary" className="text-xs bg-primary/10 text-primary border-transparent hover:bg-primary/15" aria-label={`${fieldName}: ${display}`} + data-summary-chip={fieldName} > {display} diff --git a/packages/plugin-detail/src/__tests__/summaryChip.badgeFitCensus-8464.test.tsx b/packages/plugin-detail/src/__tests__/summaryChip.badgeFitCensus-8464.test.tsx new file mode 100644 index 0000000000..746754fba5 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/summaryChip.badgeFitCensus-8464.test.tsx @@ -0,0 +1,285 @@ +/** + * 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 BADGE-FIT CENSUS — "do not assume A is free", measured (objectui#8464). + * + * The card's fix option A is "route the summary chip through `getCellRenderer`, + * as `HeaderHighlight` does", with the warning that a Badge is a much smaller + * surface than a cell, that some renderers may not fit inside one, and that this + * needs measuring per kind rather than assuming. This file IS that measurement, + * and it is an instrument rather than a grep: every registered field type is + * rendered through `getCellRenderer` INSIDE the real chip Badge — same element, + * same `variant`, same class string as `DetailView` uses — against the object + * value the defect is about, and the DOM the pill received is counted. + * + * The answer: **A for the 38 kinds that fit, with a stated rule for the 15 that + * do not.** The refused set is `CHIP_UNFIT_RENDERER_TYPES` in + * `../summaryChipRenderers`, and `THE SET MATCHES THE MEASUREMENT` below fails + * if the constant and this table ever disagree. + * + * ## The table, as measured on `ed971e8fc` for `{ id: 'acct-1', name: 'Acme Corp' }` + * + * | class | types | what the pill received | + * |--------------------------|-------------------------------------------------------------|-----------------------------------------------------| + * | ✅ plain inline text | the other 38 | `Acme Corp`, or the JSON literal for the seven behind objectui#8481's declared json-literal fence, or a value-independent face | + * | ⛔ a pill inside a pill | `select` `status` `multiselect` `radio` `checkboxes` `tags` | `SelectCellRenderer`'s own `Badge` — one `rounded-full` node nested in the chip's own | + * | ⛔ an avatar composite | `user` | TWO `rounded-full` nodes and the initials glued on: `ACAcme Corp` | + * | ⛔ an image and no text | `image` `avatar` `signature` | an ``, `textContent === ''` | + * | ⛔ a "No value" face | `boolean` `toggle` `datetime` `repeater`, and `date` | the shared `EmptyValue` (and `formatDate`'s own em-dash for `date`, objectui#8581) | + * + * The last class is not a layout objection. A chip is drawn only AFTER + * `hasCellValue` has called the value FILLED, so a renderer that answers "No + * value" one band later re-opens exactly the cross-band contradiction + * objectui#8394 closed. `date` is in the refused set for that reason even though + * its dash is not the shared affordance and this census records it as text. + * + * ## ⚠️ What this census does NOT measure + * + * PIXELS. happy-dom reports `clientWidth: 0` and never fires container-size + * effects, so nothing here can see a renderer that fits structurally and clips + * visually. That half is objectui#4054 — "a `record:highlights` chip clips + * multi-element cell renderers with NO ellipsis" — which says in its own words + * that "a DOM-only probe reports this surface healthy" and needs + * `getBoundingClientRect()` plus a screenshot. Its own analysis also records + * that `lookup` / `user` / `file` carry their own `truncate` and are unaffected, + * and names the `MapPin`-plus-text row (this repo's `location`) as a candidate. + * ⇒ the structural verdicts below are sound; a `location` chip's clipping + * behaviour is #4054's question, not this card's. + * + * ## ⚠️ Counting, not navigating + * + * Nested pills are counted by CLASS TOKEN over every descendant. `.rounded-full` + * matches TWO nodes per avatar (Radix `Avatar.Root` AND `AvatarFallback`); + * objectui#8596's census navigated with `querySelector` and never noticed. + * `user`'s row below asserts the count is exactly 2 — the fact a navigation + * cannot express. + * + * ⚠️ A matrix pin over an EMPTY set passes. `assertCensusComplete` asserts the + * exact expected size before a single cell is rendered, and a meta-test proves + * that guard throws when the set is emptied. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup, act } from '@testing-library/react'; +import * as React from 'react'; +import { Badge } from '@object-ui/components'; +import { getCellRenderer, resolveCellRendererType } from '@object-ui/fields'; +import { CHIP_UNFIT_RENDERER_TYPES, chipTakesCellRenderer } from '../summaryChipRenderers'; + +afterEach(() => cleanup()); + +/** Every type `getCellRenderer` resolves to a renderer of its own (objectui#8596). */ +const REGISTERED_TYPE_COUNT = 53; + +/** The object value the defect is about: an expanded reference payload. */ +const OBJ = { id: 'acct-1', name: 'Acme Corp' }; + +/** The chip's own class string, copied from `DetailView`'s summary Badge. */ +const CHIP_CLASS = 'text-xs bg-primary/10 text-primary border-transparent hover:bg-primary/15'; + +type Verdict = 'fit' | 'pill-in-pill' | 'avatar' | 'image-no-text' | 'no-value-face'; + +/** + * `text` is what the pill received; `verdict` is why it is or is not allowed. + * Only `fit` rows may be routed to a cell renderer by the chip. + */ +const CENSUS: ReadonlyArray = [ + // ── plain inline text: the coerced name ──────────────────────────────── + ['text', 'Acme Corp', 'fit'], + ['textarea', 'Acme Corp', 'fit'], + ['markdown', 'Acme Corp', 'fit'], + ['html', 'Acme Corp', 'fit'], + ['richtext', 'Acme Corp', 'fit'], + ['code', 'Acme Corp', 'fit'], + ['qrcode', 'Acme Corp', 'fit'], + ['time', 'Acme Corp', 'fit'], + ['auto_number', 'Acme Corp', 'fit'], + ['number', 'Acme Corp', 'fit'], + ['currency', 'Acme Corp', 'fit'], + ['percent', 'Acme Corp', 'fit'], + ['progress', 'Acme Corp', 'fit'], + ['slider', 'Acme Corp', 'fit'], + ['rating', 'Acme Corp', 'fit'], + ['formula', 'Acme Corp', 'fit'], + ['summary', 'Acme Corp', 'fit'], + ['lookup', 'Acme Corp', 'fit'], + ['master_detail', 'Acme Corp', 'fit'], + ['tree', 'Acme Corp', 'fit'], + ['email', 'Acme Corp', 'fit'], + ['url', 'Acme Corp', 'fit'], + ['phone', 'Acme Corp', 'fit'], + ['color', 'Acme Corp', 'fit'], + ['file', 'Acme Corp', 'fit'], + ['video', 'Acme Corp', 'fit'], + ['audio', 'Acme Corp', 'fit'], + // ── plain inline text: objectui#8481's declared json-literal fence ────── + ['location', '{"id":"acct-1","name":"Acme Corp"}', 'fit'], + ['geolocation', '{"id":"acct-1","name":"Acme Corp"}', 'fit'], + ['address', '{"id":"acct-1","name":"Acme Corp"}', 'fit'], + ['json', '{"id":"acct-1","name":"Acme Corp"}', 'fit'], + ['object', '{"id":"acct-1","name":"Acme Corp"}', 'fit'], + ['composite', '{"id":"acct-1","name":"Acme Corp"}', 'fit'], + ['record', '{"id":"acct-1","name":"Acme Corp"}', 'fit'], + // ── plain inline text: value-independent faces ───────────────────────── + ['password', '••••••', 'fit'], + ['secret', '••••••', 'fit'], + ['vector', '[Vector]', 'fit'], + ['grid', '[Grid]', 'fit'], + // ── refused: a pill inside a pill ────────────────────────────────────── + ['select', 'Acme Corp', 'pill-in-pill'], + ['status', 'Acme Corp', 'pill-in-pill'], + ['multiselect', 'Acme Corp', 'pill-in-pill'], + ['radio', 'Acme Corp', 'pill-in-pill'], + ['checkboxes', 'Acme Corp', 'pill-in-pill'], + ['tags', 'Acme Corp', 'pill-in-pill'], + // ── refused: an avatar composite ─────────────────────────────────────── + ['user', 'ACAcme Corp', 'avatar'], + // ── refused: an image and no text ────────────────────────────────────── + ['image', '', 'image-no-text'], + ['avatar', '', 'image-no-text'], + ['signature', '', 'image-no-text'], + // ── refused: a "No value" face for a value the band above called filled ─ + ['boolean', '—', 'no-value-face'], + ['toggle', '—', 'no-value-face'], + ['datetime', '—', 'no-value-face'], + ['repeater', '—', 'no-value-face'], + // `date` draws `formatDate`'s OWN em-dash, not the shared affordance + // (objectui#8581's subject). Recorded as it is, refused for the same reason. + ['date', '—', 'no-value-face'], +]; + +function assertCensusComplete(entries: readonly unknown[], expected: number, what: string): void { + if (entries.length !== expected) { + throw new Error( + `${what}: expected exactly ${expected} registered field types, got ${entries.length}. ` + + 'A census over a short or empty set passes without measuring anything.', + ); + } +} + +/** Render one type into the real chip Badge, exactly as `DetailView` builds it. */ +function renderChip(type: string) { + const Renderer = getCellRenderer(resolveCellRendererType({ type }) || type); + return render( + + + , + ); +} + +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +const nestedPills = (chip: HTMLElement) => + Array.from(chip.querySelectorAll('*')).filter((e) => + (e.getAttribute('class') || '').split(/\s+/).includes('rounded-full'), + ); + +describe('objectui#8464 — the Badge-fit census: A is not free, and here is which kinds it costs', () => { + it('THE CENSUS — the guard refuses a short or EMPTY set (a census over nothing passes)', () => { + expect(() => assertCensusComplete([], REGISTERED_TYPE_COUNT, 'census')).toThrowError( + /expected exactly 53 registered field types, got 0/, + ); + expect(() => + assertCensusComplete(CENSUS, REGISTERED_TYPE_COUNT, 'census'), + ).not.toThrow(); + expect(new Set(CENSUS.map(([t]) => t)).size, 'the census must not repeat a type').toBe( + REGISTERED_TYPE_COUNT, + ); + }); + + it('THE CENSUS — all 53 registered types draw their measured face inside the chip Badge', async () => { + assertCensusComplete(CENSUS, REGISTERED_TYPE_COUNT, 'census'); + let measured = 0; + for (const [type, text] of CENSUS) { + const { container } = renderChip(type); + await settle(); + const chip = container.firstElementChild as HTMLElement; + expect((chip.textContent ?? '').replace(/\s+/g, ' ').trim(), `${type}: the pill's text`).toBe( + text, + ); + measured += 1; + cleanup(); + } + expect(measured, 'every row was rendered, not skipped').toBe(REGISTERED_TYPE_COUNT); + }); + + it('THE REFUSALS — each refused class is refused for the artefact it actually produced', async () => { + const refused = CENSUS.filter(([, , v]) => v !== 'fit'); + expect( + refused.length, + 'fifteen types are refused: 6 pill-in-pill, 1 avatar, 3 image-no-text, 5 no-value-face', + ).toBe(15); + + for (const [type, , verdict] of refused) { + const { container } = renderChip(type); + await settle(); + const chip = container.firstElementChild as HTMLElement; + + if (verdict === 'pill-in-pill') { + expect(nestedPills(chip).length, `${type}: the option renderer nests its own pill`).toBe(1); + } + if (verdict === 'avatar') { + // ⚠️ TWO nodes, not one — Radix `Avatar.Root` AND `AvatarFallback`. + expect(nestedPills(chip).length, `${type}: an avatar is two rounded-full nodes`).toBe(2); + } + if (verdict === 'image-no-text') { + expect(chip.querySelectorAll('img').length, `${type}: draws an image`).toBe(1); + expect(chip.textContent, `${type}: and no text at all`).toBe(''); + } + if (verdict === 'no-value-face') { + expect( + (chip.textContent ?? '').trim(), + `${type}: answers "no value" for a value hasCellValue called filled`, + ).toBe('—'); + } + cleanup(); + } + }); + + it('⭐ THE SET MATCHES THE MEASUREMENT — the constant is derived from this table, not from memory', () => { + const measuredRefusals = CENSUS.filter(([, , v]) => v !== 'fit').map(([t]) => t).sort(); + expect( + [...CHIP_UNFIT_RENDERER_TYPES].sort(), + 'CHIP_UNFIT_RENDERER_TYPES must name exactly the kinds this census refused', + ).toEqual(measuredRefusals); + + // And the predicate the chip actually calls agrees, row by row — so the + // constant cannot be right while the function reading it is not. + for (const [type, , verdict] of CENSUS) { + expect(chipTakesCellRenderer(type), `${type} is routed per its measured verdict`).toBe( + verdict === 'fit', + ); + } + }); + + it('THE FIT SIDE IS LOAD-BEARING — the 38 fitting kinds draw text and no artefact', async () => { + // Refusing EVERY type also satisfies every refusal assertion above; this is + // the half that is red for it. + const fitting = CENSUS.filter(([, , v]) => v === 'fit'); + expect(fitting.length, 'the fitting side is not empty').toBe(38); + + for (const [type] of fitting) { + const { container } = renderChip(type); + await settle(); + const chip = container.firstElementChild as HTMLElement; + expect((chip.textContent ?? '').trim().length, `${type}: draws text`).toBeGreaterThan(0); + expect(nestedPills(chip).length, `${type}: no nested pill`).toBe(0); + expect(chip.querySelectorAll('img').length, `${type}: no image`).toBe(0); + expect( + chip.querySelectorAll('a[href],button,[role="button"],input').length, + `${type}: no interactive control beside the page title`, + ).toBe(0); + cleanup(); + } + }); +}); diff --git a/packages/plugin-detail/src/__tests__/summaryChip.objectValue-8464.test.tsx b/packages/plugin-detail/src/__tests__/summaryChip.objectValue-8464.test.tsx new file mode 100644 index 0000000000..f639e85e7f --- /dev/null +++ b/packages/plugin-detail/src/__tests__/summaryChip.objectValue-8464.test.tsx @@ -0,0 +1,482 @@ +/** + * 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. + */ + +/** + * What the `summaryFields` chip beside the record H1 draws for an OBJECT value + * (objectui#8464). + * + * ## The defect, reproduced before it was fixed + * + * The chip's display was `String(val)` with four families special-cased, so an + * object-valued summary field printed the literal `[object Object]` next to the + * page title — and, because the chip's accessible name is built from the SAME + * string, in its accessible name too. Measured on `ed971e8fc`, before the fix, + * with `summaryFields: ['owner_ref','billing_address','office_location']`: + * + * chip text `[object Object]` aria-label `owner_ref: [object Object]` + * chip text `[object Object]` aria-label `billing_address: [object Object]` + * chip text `[object Object]` aria-label `office_location: [object Object]` + * + * Reachable both ways the card names: `schema.summaryFields` is author-declared + * and never filtered by type, and the auto-detection can hand the status slot to + * a field whose stored value is not a scalar. + * + * ## Which question this chip asks — MEASURED, not argued + * + * objectui#8395 established on this exact page that "render what the user sees" + * and "render the underlying value" give different answers per field type, and + * that the intuitive choice was wrong for 9 of 17 types. That table is about a + * CLIPBOARD payload. This chip is a display surface, and its own existing + * behaviour answers which side it is on — re-derived against this tree: + * + * | field | stored | chip prints | + * |----------|------------------------------|------------------------| + * | currency | `1234.5` | `$1,235` | + * | date | `'2026-03-04'` | `Mar 4, 2026` | + * | datetime | `'2024-07-04T07:00:00.000Z'` | `Jul 4, 2024, 7:00 AM` | + * | select | `'won'` | `Closed Won` | + * + * Every one is the SEEN face, never the stored one. So the display authority for + * a kind the chip does not format is that kind's own cell renderer — option A, + * the way `HeaderHighlight` reads it one band below. The four rows above are + * pinned in `THE FOUR FORMATTED FAMILIES` and are RED for routing every value + * through the cell renderer regardless of kind. + * + * ## …and where a pill cannot host one + * + * A Badge is a much smaller surface than a cell. 15 of the 53 registered types + * draw a nested pill, an avatar composite, a bare `` with no text, or a + * "No value" face for a value `hasCellValue` just called FILLED. The full table + * and its instrument are `summaryChip.badgeFitCensus-8464.test.tsx`; the set is + * `../summaryChipRenderers`. Those kinds take `coerceToSafeValue`, this repo's + * single answer to the same question — ⛔ never a stringifier written for this + * chip (objectui#8395's option C, "answer-shopping"). + * + * ## Deliberately NOT moved + * + * ⭐ The chip's EMPTINESS classification. objectui#8394 / PR #8457 converged it + * onto `hasCellValue` as "the non-regressive convergence — it moves + * whitespace-only strings and nothing else". This card is a DISPLAY decision; + * `THE EMPTINESS CLASSIFICATION` below re-pins all four of its answers through + * the chip, so a display change that quietly re-classified a value is red here. + * + * ## Instruments + * + * - Chips are navigated by `[data-summary-chip=""]`, added by this change + * so a renderer-backed chip (which carries no `aria-label` — see below) has the + * same handle as a string one. ⚠️ NOT by `queryByText`, which throws on + * MULTIPLE matches as well as none, and every summary value also renders in the + * body grid. + * - ⚠️ Nested pills are counted by CLASS TOKEN over `chip.querySelectorAll('*')`, + * never by `querySelector`. `.rounded-full` matches TWO nodes per avatar (Radix + * `Avatar.Root` AND `AvatarFallback`); a `querySelector` navigation cannot see + * that, counting can, and `USER` below asserts the exact count. + * - A renderer-backed chip carries no `aria-label`: `aria-label` OVERRIDES + * content, so it would hide the very value the branch exists to show. Its + * accessible name is composed from content instead — an `sr-only` field-name + * prefix plus the renderer's text — which is why `textContent` reads + * `field: value` for those chips and bare `value` for string ones. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import * as React from 'react'; +import { DetailView } from '../DetailView'; +import type { DetailViewSchema } from '@object-ui/types'; + +/** + * `useRecordEditable` falls back to the GLOBAL fetch with no + * `SchemaRendererProvider` in the tree; under happy-dom that is a real request. + * Served from a double so no case here depends on the network + * (`DetailView.test.tsx`'s pattern). + */ +beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: true, json: async () => ({ record: { visible: true } }) })), + ); +}); +afterEach(() => { + vi.unstubAllGlobals(); + cleanup(); +}); + +const renderPage = (schema: Partial) => + render( + , + ); + +const chipFor = (c: HTMLElement, field: string) => + c.querySelector(`[data-summary-chip="${field}"]`); + +/** The chip, with a message naming the field when it is missing. */ +const requireChip = (c: HTMLElement, field: string): HTMLElement => { + const chip = chipFor(c, field); + expect(chip, `a summary chip for "${field}" is beside the H1`).not.toBeNull(); + return chip!; +}; + +const textOf = (el: HTMLElement) => (el.textContent ?? '').replace(/\s+/g, ' ').trim(); + +/** Descendants of the chip carrying the `rounded-full` class token. */ +const nestedPills = (chip: HTMLElement) => + Array.from(chip.querySelectorAll('*')).filter((e) => + (e.getAttribute('class') || '').split(/\s+/).includes('rounded-full'), + ); + +describe('objectui#8464 — an object-valued summary chip beside the H1', () => { + /** + * The four object-valued kinds whose renderer was MEASURED to fit the pill. + * `expected` is the whole chip: the `sr-only` field-name prefix that carries + * the accessible name plus the value the renderer drew. Asserting the exact + * string pins BOTH halves the defect broke. + */ + const RENDERER_BACKED = [ + { + field: 'owner_ref', + type: 'lookup', + value: { id: 'acct-1', name: 'Ada Lovelace' }, + expected: 'owner_ref: Ada Lovelace', + }, + { + field: 'billing_address', + type: 'address', + // `postalCode` is the spec spelling (objectstack#5143); `formatAddress` + // reads it, so the expected line below is a real format and not a + // re-derivation of the input. + value: { + street: '1 Main St', + city: 'Springfield', + state: 'IL', + postalCode: '62704', + country: 'USA', + }, + expected: 'billing_address: 1 Main St, Springfield, IL 62704, USA', + }, + { + field: 'office_location', + type: 'location', + // MORE precision than the cell prints (it rounds to 4 decimals), so the + // expectation cannot be satisfied by echoing the input back. + value: { latitude: 30.2741567, longitude: 120.1551234 }, + expected: 'office_location: 30.2742, 120.1551', + }, + { + field: 'contract', + type: 'file', + value: { name: 'contract.pdf', url: 'https://cdn.example.com/contract.pdf' }, + expected: 'contract: contract.pdf', + }, + { + field: 'payload', + type: 'json', + value: { a: 1, b: ['x', 'y'] }, + expected: 'payload: {"a":1,"b":["x","y"]}', + }, + ]; + + it.each(RENDERER_BACKED)( + 'FITTING KIND — $type draws its own cell face in the chip, never `[object Object]`', + ({ field, type, value, expected }) => { + const { container } = renderPage({ + summaryFields: [field, 'stage'] as any, + fields: [ + { name: field, label: field, type }, + { name: 'stage', label: 'Stage', type: 'text' }, + ] as any, + data: { id: 'A1', name: 'Acme Corporation', [field]: value, stage: 'Won' }, + }); + + // CONTROL — the page drew its H1, so an absent chip below is a decision + // about this value and not about the header failing to render. + expect(container.querySelector('h1'), 'CONTROL: the record H1 rendered').not.toBeNull(); + // CONTROL — the sibling scalar chip is there, so the chip row itself works. + expect( + textOf(requireChip(container, 'stage')), + 'CONTROL: the sibling scalar chip still reads its value', + ).toBe('Won'); + + // The artefact's ABSENCE first, so an unfixed tree fails on a different + // sentence from a harness that lost its navigation target. + const chip = requireChip(container, field); + expect( + textOf(chip), + `the "${field}" chip must not carry the String() placeholder`, + ).not.toContain('[object Object]'); + expect( + textOf(chip), + `the "${field}" chip draws what its own cell draws, and names the field for AT`, + ).toBe(expected); + + // A renderer-backed chip must not smuggle the placeholder back through the + // accessible name, which is exactly where the defect's second half lived. + expect( + chip.getAttribute('aria-label'), + 'a renderer-backed chip sets no aria-label — it would override the value it exists to show', + ).toBeNull(); + expect( + chip.querySelector('.sr-only')?.textContent, + 'the field name reaches AT through the visually-hidden prefix instead', + ).toBe(`${field}: `); + + // The pill hosts text, not a control, an image or a second pill. + expect(chip.querySelectorAll('a[href],button,[role="button"],input').length, + `the "${field}" chip draws no interactive control inside the page title row`).toBe(0); + expect(chip.querySelectorAll('img').length, `the "${field}" chip draws no image`).toBe(0); + expect(nestedPills(chip).length, `the "${field}" chip is not a pill inside a pill`).toBe(0); + expect( + chip.querySelectorAll('[data-slot="empty-value"]').length, + `the "${field}" chip never says "No value" for a value the band above called filled`, + ).toBe(0); + }, + ); + + it('AUTO-DETECTION — the status slot handed an object value reads a name, not the placeholder', () => { + // The card's SECOND route in: no `summaryFields` at all, so + // `autoSummaryFields` picks the field — and it filters by name and + // emptiness, never by type. + const { container } = renderPage({ + fields: [{ name: 'status', label: 'Status', type: 'lookup' }] as any, + data: { id: 'A2', name: 'Acme', status: { id: 's-1', name: 'Negotiation' } }, + }); + + expect(container.querySelector('h1'), 'CONTROL: the record H1 rendered').not.toBeNull(); + const chip = requireChip(container, 'status'); + expect(textOf(chip), 'the auto-detected chip must not carry the placeholder').not.toContain( + '[object Object]', + ); + expect(textOf(chip), 'the auto-detected chip reads the record name').toBe( + 'status: Negotiation', + ); + }); + + /** + * The kinds MEASURED not to fit a pill. They keep the string path — so they + * keep their `aria-label` — and its text is `coerceToSafeValue`'s, which + * objectui#8596 ruled `user` and the option families onto for an object value. + */ + it('USER — an expanded user object reads its name; the avatar does not enter the pill', () => { + const { container } = renderPage({ + summaryFields: ['owner'] as any, + fields: [{ name: 'owner', label: 'Owner', type: 'user' }] as any, + data: { id: 'A3', name: 'Acme', owner: { id: 'u-1', name: 'Ada Lovelace' } }, + }); + + const chip = requireChip(container, 'owner'); + // ⚠️ The instrument pin. `UserCellRenderer` draws a Radix Avatar, whose + // Root AND Fallback both carry `rounded-full` — TWO nodes, which a + // `querySelector` navigation cannot see and this count can. Both the avatar + // and the initials it glues onto the name (`ACAcme Corp` in the census) are + // why `user` is refused the renderer here. + expect(nestedPills(chip).length, 'no avatar (two rounded-full nodes) inside the chip').toBe(0); + expect(chip.querySelectorAll('img').length, 'no avatar image inside the chip').toBe(0); + expect(textOf(chip), 'the chip reads the coerced name').toBe('Ada Lovelace'); + expect( + chip.getAttribute('aria-label'), + 'a string-path chip keeps the accessible name it always had, now true', + ).toBe('owner: Ada Lovelace'); + }); + + it('OPTION FAMILY — a select field holding an object reads the coerced text, not a pill in a pill', () => { + const { container } = renderPage({ + summaryFields: ['stage'] as any, + fields: [ + { name: 'stage', label: 'Stage', type: 'select', options: [{ value: 'won', label: 'Closed Won' }] }, + ] as any, + data: { id: 'A4', name: 'Acme', stage: { id: 'st-9', name: 'Negotiation' } }, + }); + + const chip = requireChip(container, 'stage'); + expect(nestedPills(chip).length, 'the option renderer is not nested inside the chip').toBe(0); + expect(textOf(chip), 'the chip reads the coerced text').toBe('Negotiation'); + expect(chip.getAttribute('aria-label'), 'and its accessible name says the same').toBe( + 'stage: Negotiation', + ); + }); + + it('UNNAMEABLE OBJECT — an object with no name reads the page\'s own word for it', () => { + // `coerceToSafeValue`'s answer for an object carrying no name/label/id is + // `[Object]` — the SAME text objectui#8596 pinned for eleven families. It is + // deliberately not blank and deliberately not `[object Object]`. + const { container } = renderPage({ + summaryFields: ['meta'] as any, + fields: [{ name: 'meta', label: 'Meta', type: 'avatar' }] as any, + data: { id: 'A5', name: 'Acme', meta: { url: 'https://cdn.example.com/a.png' } }, + }); + + const chip = requireChip(container, 'meta'); + expect(textOf(chip), 'not the String() placeholder').not.toContain('[object Object]'); + expect(textOf(chip), "the package's one coercion answers it").toBe('[Object]'); + }); + + /** + * ⭐ THE NON-REGRESSION the card names. objectui#8394 / PR #8457 converged this + * chip's emptiness guard onto `hasCellValue` — "it moves whitespace-only + * strings and nothing else". A display change may not move any of its four + * answers, so all four are re-pinned here through the chip. + */ + describe('THE EMPTINESS CLASSIFICATION — unmoved by this display change', () => { + const cases = [ + { what: 'an object is FILLED, and now says so', data: { o: { id: 'x', name: 'Nm' } }, chip: true }, + { what: 'a whitespace-only string is EMPTY', data: { o: ' ' }, chip: false }, + { what: '`0` is FILLED', data: { o: 0 }, chip: true }, + { what: 'an empty array is EMPTY (objectui#8474)', data: { o: [] }, chip: false }, + { what: 'an empty object is FILLED — `hasCellValue` says so', data: { o: {} }, chip: true }, + ]; + + it.each(cases)('$what', ({ data, chip }) => { + const { container } = renderPage({ + summaryFields: ['o', 'keep'] as any, + fields: [ + { name: 'o', label: 'O', type: 'lookup' }, + { name: 'keep', label: 'Keep', type: 'text' }, + ] as any, + data: { id: 'A6', name: 'Acme', keep: 'sibling', ...data }, + }); + + // CONTROL — the sibling chip drew, so an absent chip is a classification + // and not a header that failed to render. + expect( + textOf(requireChip(container, 'keep')), + 'CONTROL: the sibling chip rendered', + ).toBe('sibling'); + + if (chip) { + expect(chipFor(container, 'o'), 'this value is FILLED and must keep its chip').not.toBeNull(); + } else { + expect(chipFor(container, 'o'), 'this value is EMPTY and must render no chip at all').toBeNull(); + } + }); + }); + + /** + * ⭐ THE FOUR FORMATTED FAMILIES. Each prints the SEEN face — which is the + * measurement that says this chip asks the display question — and each is + * BYTE-IDENTICAL to what it printed before this change. Red for "route every + * value through the cell renderer regardless of kind". + */ + describe('THE FOUR FORMATTED FAMILIES — byte-identical, and each is the seen face', () => { + const FORMATTED = [ + { field: 'price', type: 'currency', extra: { currency: 'USD' }, stored: 1234.5, chip: '$1,235' }, + { field: 'due', type: 'date', extra: {}, stored: '2026-03-04', chip: 'Mar 4, 2026' }, + { + field: 'closed_at', + type: 'datetime', + extra: {}, + stored: '2024-07-04T07:00:00.000Z', + chip: 'Jul 4, 2024, 7:00 AM', + }, + { + field: 'stage', + type: 'select', + extra: { options: [{ value: 'won', label: 'Closed Won' }] }, + stored: 'won', + chip: 'Closed Won', + }, + ]; + + it.each(FORMATTED)( + '$type prints $chip for $stored — the seen face, not the stored value', + ({ field, type, extra, stored, chip }) => { + const { container } = renderPage({ + summaryFields: [field] as any, + fields: [{ name: field, label: field, type, ...extra }] as any, + data: { id: 'A7', name: 'Acme', [field]: stored }, + }); + + const el = requireChip(container, field); + expect(textOf(el), `${type} keeps its own summary format`).toBe(chip); + expect( + el.getAttribute('aria-label'), + `${type} keeps the accessible name it always had`, + ).toBe(`${field}: ${chip}`); + expect(String(stored), 'CONTROL: the stored value and the chip face differ or agree by measurement').not.toBe( + '[object Object]', + ); + expect(nestedPills(el).length, `${type} draws no cell renderer's pill inside the chip`).toBe(0); + }, + ); + + it('PERCENT — keeps its own text AND its single decorative bar', () => { + const { container } = renderPage({ + summaryFields: ['ratio'] as any, + fields: [{ name: 'ratio', label: 'Ratio', type: 'percent' }] as any, + data: { id: 'A8', name: 'Acme', ratio: 0.123 }, + }); + + const chip = requireChip(container, 'ratio'); + expect(textOf(chip), 'the percent chip keeps its own text').toBe('0.123%'); + // The chip's OWN bar is two `rounded-full` spans — track and fill. A cell + // renderer routed in here would add its own, so the exact count is the pin. + expect(nestedPills(chip).length, 'exactly the chip\'s own two-span bar, no renderer bar').toBe(2); + expect( + chip.getAttribute('aria-label'), + 'and the accessible name is unchanged', + ).toBe('ratio: 0.123%'); + }); + }); + + /** + * The string path for values it always handled. Byte-identical: the fix fires + * on the DEFECT'S OWN SIGNATURE (`String(val)` produced the placeholder), so + * nothing that already rendered can be touched by it. + */ + it('SCALARS AND SCALAR ARRAYS — untouched', () => { + const { container } = renderPage({ + summaryFields: ['name_txt', 'count', 'labels'] as any, + fields: [ + { name: 'name_txt', label: 'Name', type: 'text' }, + { name: 'count', label: 'Count', type: 'number' }, + { name: 'labels', label: 'Labels', type: 'text' }, + ] as any, + data: { id: 'A9', name: 'Acme', name_txt: 'Plain String Value', count: 16, labels: ['a', 'b'] }, + }); + + expect(textOf(requireChip(container, 'name_txt')), 'a string is verbatim').toBe( + 'Plain String Value', + ); + expect(textOf(requireChip(container, 'count')), 'a number is verbatim').toBe('16'); + // `String(['a','b'])` is `a,b` — it never produced the placeholder, so the + // fix does not fire and this text may not move. + expect(textOf(requireChip(container, 'labels')), 'an array of strings keeps String()\'s join').toBe( + 'a,b', + ); + }); + + it('THE PAGE — no band of the rendered record still says `[object Object]`', () => { + const { container } = renderPage({ + summaryFields: ['owner_ref', 'billing_address', 'office_location'] as any, + fields: [ + { name: 'owner_ref', label: 'Owner', type: 'lookup' }, + { name: 'billing_address', label: 'Billing Address', type: 'address' }, + { name: 'office_location', label: 'Office Location', type: 'location' }, + ] as any, + data: { + id: 'A10', + name: 'Acme Corporation', + owner_ref: { id: 'acct-1', name: 'Ada Lovelace' }, + billing_address: { street: '1 Main St', city: 'Springfield', state: 'IL', postalCode: '62704', country: 'USA' }, + office_location: { latitude: 30.2741567, longitude: 120.1551234 }, + }, + }); + + // CONTROL — all three chips are present, so "no placeholder" is not the + // trivially-true statement of a page that rendered no chips at all. + expect( + ['owner_ref', 'billing_address', 'office_location'].filter((f) => chipFor(container, f)), + 'CONTROL: all three summary chips rendered', + ).toEqual(['owner_ref', 'billing_address', 'office_location']); + + expect( + container.textContent ?? '', + 'the whole record page is free of the String() placeholder', + ).not.toContain('[object Object]'); + }); +}); diff --git a/packages/plugin-detail/src/summaryChipRenderers.ts b/packages/plugin-detail/src/summaryChipRenderers.ts new file mode 100644 index 0000000000..11426a24f0 --- /dev/null +++ b/packages/plugin-detail/src/summaryChipRenderers.ts @@ -0,0 +1,86 @@ +/** + * 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. + */ + +/** + * Which field kinds the `summaryFields` chip beside the record H1 may draw + * with the field's OWN cell renderer, and which it may not (objectui#8464). + * + * ## Why this set exists at all + * + * The chip's display was `String(val)` with four families special-cased, so an + * object-valued summary field printed the literal `[object Object]` beside the + * H1 — and, because the chip's accessible name is built from that same string, + * in its accessible name too. The fix routes such a value through + * `getCellRenderer`, the way `HeaderHighlight` one band below already does. + * + * ⚠️ That route is NOT free, and this set is the measurement that says so. A + * Badge is a `whitespace-nowrap rounded-full` pill — a much smaller surface + * than a cell — and 15 of the 53 registered types draw something a pill cannot + * host. Measured, not assumed: every registered type was rendered through + * `getCellRenderer` INSIDE the real chip Badge against the object value + * `{ id: 'acct-1', name: 'Acme Corp' }`, and the DOM inside the pill counted. + * The instrument is `__tests__/summaryChip.badgeFitCensus-8464.test.tsx`, + * which re-derives the whole table and fails if this set stops matching it. + * + * ## The four measured refusals + * + * | class | types | what the pill got | + * |-------------------------------|--------------------------------------------------------|-------------------------------------------------------| + * | a pill inside a pill | `select` `status` `multiselect` `radio` `checkboxes` `tags` | `SelectCellRenderer`'s own `Badge` — one `rounded-full` node nested in the chip's own | + * | an avatar composite | `user` | TWO `rounded-full` nodes (Radix `Avatar.Root` + `AvatarFallback`) and the initials glued onto the name: `ACAcme Corp` | + * | an image and no text | `image` `avatar` `signature` | an `` and `textContent === ''` — a pill with nothing to say, and nothing for the accessible name | + * | a "No value" face | `boolean` `toggle` `datetime` `repeater` (`EmptyValue`), `date` (`formatDate`'s own em-dash, objectui#8581) | the chip is drawn only AFTER `hasCellValue` called the value FILLED; a renderer answering "empty" one band later re-opens exactly the cross-band contradiction objectui#8394 closed | + * + * The other 38 types draw plain inline text inside the pill — `Acme Corp` for + * the nameable families, the JSON literal for `location` / `geolocation` / + * `address` / `json` / `object` / `composite` / `record` behind objectui#8481's + * declared json-literal fence, and the value-independent faces (`password`, + * `secret`, `vector`, `grid`). + * + * ## What the refused kinds get instead — NOT a stringifier written here + * + * They fall to `coerceToSafeValue`, `@object-ui/fields`' single documented + * answer to "what text does a cell draw for a value that is not a string". + * objectui#8596 ruled `select` / `status` / `multiselect` / `radio` / + * `checkboxes` / `tags` / `user` onto exactly that text for an object value, so + * for 7 of these 15 the chip is BYTE-EQUAL to its own cell. ⛔ Nothing here + * invents a renderer-side format (AGENTS.md #0.1): every kind lands either on + * its own renderer or on the one coercion this repo already had. + */ +export const CHIP_UNFIT_RENDERER_TYPES: ReadonlySet = new Set([ + // a pill inside a pill + 'select', + 'status', + 'multiselect', + 'radio', + 'checkboxes', + 'tags', + // an avatar composite + 'user', + // an image and no text + 'image', + 'avatar', + 'signature', + // a "No value" face for a value the band above called filled + 'boolean', + 'toggle', + 'date', + 'datetime', + 'repeater', +]); + +/** + * May the chip draw `rendererType` with the field's own cell renderer? + * + * Takes the type ALREADY resolved through `resolveCellRendererType`, because + * that is the key `getCellRenderer` dispatches on — asking this question of the + * authored spelling would answer for a renderer the chip is not about to use. + */ +export function chipTakesCellRenderer(rendererType: string): boolean { + return !CHIP_UNFIT_RENDERER_TYPES.has(rendererType); +} From 4f50bbf36643af425f4139def8224de2a718399e Mon Sep 17 00:00:00 2001 From: os-justin Date: Wed, 9 Sep 2026 01:34:26 +0000 Subject: [PATCH 2/4] test(plugin-detail): the census's fit side asserts the half a caricature can break Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S --- .ablation/run.sh | 131 ++++++++++++++++++ .ablation/run2.sh | 83 +++++++++++ .../summaryChip.badgeFitCensus-8464.test.tsx | 9 ++ 3 files changed, 223 insertions(+) create mode 100755 .ablation/run.sh create mode 100644 .ablation/run2.sh diff --git a/.ablation/run.sh b/.ablation/run.sh new file mode 100755 index 0000000000..59d8987f47 --- /dev/null +++ b/.ablation/run.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Ablation harness for objectui#8464. Mutates a READ SITE, never a pin. +set -uo pipefail + +REPO_ROOT="$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)" +cd "$REPO_ROOT" +TARGET_REL="packages/plugin-detail/src/DetailView.tsx" +SET_REL="packages/plugin-detail/src/summaryChipRenderers.ts" +TARGET="$REPO_ROOT/$TARGET_REL" +SET_FILE="$REPO_ROOT/$SET_REL" +OUT="${ABL_OUT:-/tmp/ablation-8464}" +mkdir -p "$OUT" + +restore() { + git -C "$REPO_ROOT" checkout HEAD -- "$TARGET" "$SET_FILE" 2>/dev/null || true +} +trap restore EXIT INT TERM + +blob_at_head() { git -C "$REPO_ROOT" rev-parse "HEAD:$1"; } + +# Prove a mutation LANDED: both directions (anchor gone / injected present), +# a hash that differs from the HEAD blob, and a line-total gate. +prove_mutated() { # $1 rel path $2 removed-anchor $3 injected-anchor $4 expected line delta + local rel="$1" gone="$2" added="$3" delta="$4" + local abs="$REPO_ROOT/$rel" + local head_blob now_blob + head_blob="$(blob_at_head "$rel")" + now_blob="$(git -C "$REPO_ROOT" hash-object "$abs")" + if [ -z "$now_blob" ] || [ -z "$head_blob" ]; then + echo "ABLATION FAILURE: empty hash — path did not resolve ($rel)"; exit 3 + fi + if [ "$now_blob" = "$head_blob" ]; then + echo "ABLATION FAILURE: $rel is byte-identical to HEAD — the mutation did not land"; exit 3 + fi + local n_gone n_added + n_gone="$(grep -c -- "$gone" "$abs" || true)" + n_added="$(grep -c -- "$added" "$abs" || true)" + if [ "$n_gone" != "0" ]; then + echo "ABLATION FAILURE: the removed anchor is still present ($n_gone) in $rel"; exit 3 + fi + if [ "$n_added" = "0" ]; then + echo "ABLATION FAILURE: the injected anchor is absent from $rel"; exit 3 + fi + local head_lines now_lines + head_lines="$(git -C "$REPO_ROOT" show "HEAD:$rel" | wc -l)" + now_lines="$(wc -l < "$abs")" + if [ "$(( now_lines - head_lines ))" != "$delta" ]; then + echo "ABLATION FAILURE: line-total gate — expected delta $delta, got $(( now_lines - head_lines ))"; exit 3 + fi + echo " mutation ON DISK: $rel head=$head_blob now=$now_blob removed-anchor=0 injected-anchor=$n_added lines ${head_lines}->${now_lines}" +} + +prove_restored() { + restore + local d + d="$(git -C "$REPO_ROOT" diff HEAD --name-only)" + if [ -n "$d" ]; then + echo "ABLATION FAILURE: restore left the tree dirty: $d"; exit 3 + fi + echo " restored BY STATE: git diff HEAD is empty" +} + +run_suite() { # $1 label + local label="$1" + pnpm exec vitest run \ + packages/plugin-detail/src/__tests__/summaryChip.objectValue-8464.test.tsx \ + packages/plugin-detail/src/__tests__/summaryChip.badgeFitCensus-8464.test.tsx \ + --reporter=json --outputFile="$OUT/$label.json" > "$OUT/$label.log" 2>&1 + echo " vitest exit: $?" + node -e ' + const fs=require("fs"); + const r=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); + const rows=[]; + for(const f of r.testResults||[]) for(const a of f.assertionResults||[]) + rows.push([a.status, a.fullName]); + const failed=rows.filter(([s])=>s==="failed"); + console.log(" TOTAL "+rows.length+" PASSED "+rows.filter(([s])=>s==="passed").length+" FAILED "+failed.length); + for(const [,n] of failed) console.log(" RED "+n); + ' "$OUT/$label.json" +} + +echo "== BASELINE (HEAD, unmutated) ==" +prove_restored +run_suite baseline + +echo +echo "== CARICATURE 1 — render nothing for every object (the fenced option B) ==" +python3 - "$TARGET" <<'PY' +import sys +p=sys.argv[1]; s=open(p).read() +old=" const chipField = enrichDetailField(" +new=" if (val !== null && typeof val === 'object') return null; // ABLATION-B\n"+old +assert s.count(old)==1 +open(p,'w').write(s.replace(old,new,1)) +PY +prove_mutated "$TARGET_REL" "ABLATION-NEVER-PRESENT-XYZ" "ABLATION-B" 1 +run_suite caricature1 +prove_restored + +echo +echo "== CARICATURE 2 — route EVERY value through the cell renderer regardless of kind ==" +python3 - "$TARGET" <<'PY' +import sys +p=sys.argv[1]; s=open(p).read() +old=""" const ChipCellRenderer = + display.includes('[object Object]') && chipTakesCellRenderer(chipRendererType) + ? getCellRenderer(chipRendererType) + : null;""" +new=""" const ChipCellRenderer = getCellRenderer(chipRendererType); // ABLATION-C""" +assert s.count(old)==1 +open(p,'w').write(s.replace(old,new,1)) +PY +prove_mutated "$TARGET_REL" "chipTakesCellRenderer(chipRendererType)" "ABLATION-C" -3 +run_suite caricature2 +prove_restored + +echo +echo "== CARICATURE 3 — refuse the renderer for EVERY kind (the census's own caricature) ==" +python3 - "$SET_FILE" <<'PY' +import sys +p=sys.argv[1]; s=open(p).read() +old=" return !CHIP_UNFIT_RENDERER_TYPES.has(rendererType);" +new=" return false; // ABLATION-D\n return !CHIP_UNFIT_RENDERER_TYPES.has(rendererType);" +assert s.count(old)==1 +open(p,'w').write(s.replace(old,new,1)) +PY +prove_mutated "$SET_REL" "ABLATION-NEVER-PRESENT-XYZ" "ABLATION-D" 1 +run_suite caricature3 +prove_restored +echo +echo "ABLATION COMPLETE" diff --git a/.ablation/run2.sh b/.ablation/run2.sh new file mode 100644 index 0000000000..f8e5ec61aa --- /dev/null +++ b/.ablation/run2.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Ablation legs 4-5 for objectui#8464 — the CENSUS's own load-bearing halves. +# Mutates a READ SITE in @object-ui/fields (`getCellRenderer`), never a pin. +# Vitest aliases '@object-ui/fields' -> packages/fields/src (vitest.config.mts +# line 507), so the source edit IS what the test executes; no dist is involved. +set -uo pipefail + +REPO_ROOT="$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)" +cd "$REPO_ROOT" +REL="packages/fields/src/index.tsx" +ABS="$REPO_ROOT/$REL" +OUT="${ABL_OUT:-/tmp/ablation-8464}" +mkdir -p "$OUT" + +restore() { git -C "$REPO_ROOT" checkout HEAD -- "$ABS" 2>/dev/null || true; } +trap restore EXIT INT TERM + +prove_mutated() { # $1 injected-anchor $2 expected line delta + local added="$1" delta="$2" + local head_blob now_blob n_added head_lines now_lines + head_blob="$(git -C "$REPO_ROOT" rev-parse "HEAD:$REL")" + now_blob="$(git -C "$REPO_ROOT" hash-object "$ABS")" + [ -n "$head_blob" ] && [ -n "$now_blob" ] || { echo "ABLATION FAILURE: empty hash"; exit 3; } + [ "$head_blob" != "$now_blob" ] || { echo "ABLATION FAILURE: byte-identical to HEAD"; exit 3; } + n_added="$(grep -c -- "$added" "$ABS" || true)" + [ "$n_added" != "0" ] || { echo "ABLATION FAILURE: injected anchor absent"; exit 3; } + # The other direction: the unmutated function must no longer be reachable — + # its first registry lookup is gone from the executed path, proven by the + # early return sitting ABOVE it. + grep -q "if (fieldRegistry.has(fieldType))" "$ABS" || { echo "ABLATION FAILURE: lost the anchor we mutate around"; exit 3; } + head_lines="$(git -C "$REPO_ROOT" show "HEAD:$REL" | wc -l)" + now_lines="$(wc -l < "$ABS")" + [ "$(( now_lines - head_lines ))" = "$delta" ] || { echo "ABLATION FAILURE: line gate expected $delta got $(( now_lines - head_lines ))"; exit 3; } + echo " mutation ON DISK: $REL head=$head_blob now=$now_blob injected-anchor=$n_added lines ${head_lines}->${now_lines}" +} + +prove_restored() { + restore + local d; d="$(git -C "$REPO_ROOT" diff HEAD --name-only)" + [ -z "$d" ] || { echo "ABLATION FAILURE: restore left tree dirty: $d"; exit 3; } + echo " restored BY STATE: git diff HEAD is empty" +} + +run_suite() { + local label="$1" + pnpm exec vitest run \ + packages/plugin-detail/src/__tests__/summaryChip.badgeFitCensus-8464.test.tsx \ + --reporter=json --outputFile="$OUT/$label.json" > "$OUT/$label.log" 2>&1 + echo " vitest exit: $?" + node -e ' + const r=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); + const rows=[]; for(const f of r.testResults||[]) for(const a of f.assertionResults||[]) rows.push([a.status,a.fullName]); + const failed=rows.filter(([s])=>s==="failed"); + console.log(" TOTAL "+rows.length+" PASSED "+rows.filter(([s])=>s==="passed").length+" FAILED "+failed.length); + for(const [,n] of failed) console.log(" RED "+n); + ' "$OUT/$label.json" +} + +mutate() { # $1 = replacement body line + python3 - "$ABS" "$1" <<'PY' +import sys +p, inject = sys.argv[1], sys.argv[2] +s=open(p).read() +old="export function getCellRenderer(fieldType: string): React.FC {\n" +assert s.count(old)==1, s.count(old) +open(p,'w').write(s.replace(old, old+" "+inject+"\n", 1)) +PY +} + +echo "== CARICATURE 4 - every type answers what the text cell answers ==" +mutate "return TextCellRenderer; // ABLATION-E" +prove_mutated "ABLATION-E" 1 +run_suite caricature4 +prove_restored + +echo +echo "== CARICATURE 5 — every type draws the shared No-value affordance ==" +mutate "return () => ; // ABLATION-F" +prove_mutated "ABLATION-F" 1 +run_suite caricature5 +prove_restored +echo +echo "ABLATION 2 COMPLETE" diff --git a/packages/plugin-detail/src/__tests__/summaryChip.badgeFitCensus-8464.test.tsx b/packages/plugin-detail/src/__tests__/summaryChip.badgeFitCensus-8464.test.tsx index 746754fba5..4781110abb 100644 --- a/packages/plugin-detail/src/__tests__/summaryChip.badgeFitCensus-8464.test.tsx +++ b/packages/plugin-detail/src/__tests__/summaryChip.badgeFitCensus-8464.test.tsx @@ -273,6 +273,15 @@ describe('objectui#8464 — the Badge-fit census: A is not free, and here is whi await settle(); const chip = container.firstElementChild as HTMLElement; expect((chip.textContent ?? '').trim().length, `${type}: draws text`).toBeGreaterThan(0); + // ⭐ The discriminating half. "Every kind draws the shared No-value glyph" + // satisfies every other assertion in this case — it has text (`—`), no + // pill, no image, no control — and it is exactly the mutation that makes + // the renderer answer the same thing for everything. Observed red under + // it; the two assertions around it are not. + expect( + chip.querySelectorAll('[data-slot="empty-value"]').length, + `${type}: a FITTING kind draws its value, never the "No value" affordance`, + ).toBe(0); expect(nestedPills(chip).length, `${type}: no nested pill`).toBe(0); expect(chip.querySelectorAll('img').length, `${type}: no image`).toBe(0); expect( From defbd4d90e6fb5915c7abbc395d72241311b08ea Mon Sep 17 00:00:00 2001 From: os-justin Date: Wed, 9 Sep 2026 01:34:54 +0000 Subject: [PATCH 3/4] chore: drop the scratch ablation harness Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S --- .ablation/run.sh | 131 ---------------------------------------------- .ablation/run2.sh | 83 ----------------------------- 2 files changed, 214 deletions(-) delete mode 100755 .ablation/run.sh delete mode 100644 .ablation/run2.sh diff --git a/.ablation/run.sh b/.ablation/run.sh deleted file mode 100755 index 59d8987f47..0000000000 --- a/.ablation/run.sh +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/env bash -# Ablation harness for objectui#8464. Mutates a READ SITE, never a pin. -set -uo pipefail - -REPO_ROOT="$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)" -cd "$REPO_ROOT" -TARGET_REL="packages/plugin-detail/src/DetailView.tsx" -SET_REL="packages/plugin-detail/src/summaryChipRenderers.ts" -TARGET="$REPO_ROOT/$TARGET_REL" -SET_FILE="$REPO_ROOT/$SET_REL" -OUT="${ABL_OUT:-/tmp/ablation-8464}" -mkdir -p "$OUT" - -restore() { - git -C "$REPO_ROOT" checkout HEAD -- "$TARGET" "$SET_FILE" 2>/dev/null || true -} -trap restore EXIT INT TERM - -blob_at_head() { git -C "$REPO_ROOT" rev-parse "HEAD:$1"; } - -# Prove a mutation LANDED: both directions (anchor gone / injected present), -# a hash that differs from the HEAD blob, and a line-total gate. -prove_mutated() { # $1 rel path $2 removed-anchor $3 injected-anchor $4 expected line delta - local rel="$1" gone="$2" added="$3" delta="$4" - local abs="$REPO_ROOT/$rel" - local head_blob now_blob - head_blob="$(blob_at_head "$rel")" - now_blob="$(git -C "$REPO_ROOT" hash-object "$abs")" - if [ -z "$now_blob" ] || [ -z "$head_blob" ]; then - echo "ABLATION FAILURE: empty hash — path did not resolve ($rel)"; exit 3 - fi - if [ "$now_blob" = "$head_blob" ]; then - echo "ABLATION FAILURE: $rel is byte-identical to HEAD — the mutation did not land"; exit 3 - fi - local n_gone n_added - n_gone="$(grep -c -- "$gone" "$abs" || true)" - n_added="$(grep -c -- "$added" "$abs" || true)" - if [ "$n_gone" != "0" ]; then - echo "ABLATION FAILURE: the removed anchor is still present ($n_gone) in $rel"; exit 3 - fi - if [ "$n_added" = "0" ]; then - echo "ABLATION FAILURE: the injected anchor is absent from $rel"; exit 3 - fi - local head_lines now_lines - head_lines="$(git -C "$REPO_ROOT" show "HEAD:$rel" | wc -l)" - now_lines="$(wc -l < "$abs")" - if [ "$(( now_lines - head_lines ))" != "$delta" ]; then - echo "ABLATION FAILURE: line-total gate — expected delta $delta, got $(( now_lines - head_lines ))"; exit 3 - fi - echo " mutation ON DISK: $rel head=$head_blob now=$now_blob removed-anchor=0 injected-anchor=$n_added lines ${head_lines}->${now_lines}" -} - -prove_restored() { - restore - local d - d="$(git -C "$REPO_ROOT" diff HEAD --name-only)" - if [ -n "$d" ]; then - echo "ABLATION FAILURE: restore left the tree dirty: $d"; exit 3 - fi - echo " restored BY STATE: git diff HEAD is empty" -} - -run_suite() { # $1 label - local label="$1" - pnpm exec vitest run \ - packages/plugin-detail/src/__tests__/summaryChip.objectValue-8464.test.tsx \ - packages/plugin-detail/src/__tests__/summaryChip.badgeFitCensus-8464.test.tsx \ - --reporter=json --outputFile="$OUT/$label.json" > "$OUT/$label.log" 2>&1 - echo " vitest exit: $?" - node -e ' - const fs=require("fs"); - const r=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); - const rows=[]; - for(const f of r.testResults||[]) for(const a of f.assertionResults||[]) - rows.push([a.status, a.fullName]); - const failed=rows.filter(([s])=>s==="failed"); - console.log(" TOTAL "+rows.length+" PASSED "+rows.filter(([s])=>s==="passed").length+" FAILED "+failed.length); - for(const [,n] of failed) console.log(" RED "+n); - ' "$OUT/$label.json" -} - -echo "== BASELINE (HEAD, unmutated) ==" -prove_restored -run_suite baseline - -echo -echo "== CARICATURE 1 — render nothing for every object (the fenced option B) ==" -python3 - "$TARGET" <<'PY' -import sys -p=sys.argv[1]; s=open(p).read() -old=" const chipField = enrichDetailField(" -new=" if (val !== null && typeof val === 'object') return null; // ABLATION-B\n"+old -assert s.count(old)==1 -open(p,'w').write(s.replace(old,new,1)) -PY -prove_mutated "$TARGET_REL" "ABLATION-NEVER-PRESENT-XYZ" "ABLATION-B" 1 -run_suite caricature1 -prove_restored - -echo -echo "== CARICATURE 2 — route EVERY value through the cell renderer regardless of kind ==" -python3 - "$TARGET" <<'PY' -import sys -p=sys.argv[1]; s=open(p).read() -old=""" const ChipCellRenderer = - display.includes('[object Object]') && chipTakesCellRenderer(chipRendererType) - ? getCellRenderer(chipRendererType) - : null;""" -new=""" const ChipCellRenderer = getCellRenderer(chipRendererType); // ABLATION-C""" -assert s.count(old)==1 -open(p,'w').write(s.replace(old,new,1)) -PY -prove_mutated "$TARGET_REL" "chipTakesCellRenderer(chipRendererType)" "ABLATION-C" -3 -run_suite caricature2 -prove_restored - -echo -echo "== CARICATURE 3 — refuse the renderer for EVERY kind (the census's own caricature) ==" -python3 - "$SET_FILE" <<'PY' -import sys -p=sys.argv[1]; s=open(p).read() -old=" return !CHIP_UNFIT_RENDERER_TYPES.has(rendererType);" -new=" return false; // ABLATION-D\n return !CHIP_UNFIT_RENDERER_TYPES.has(rendererType);" -assert s.count(old)==1 -open(p,'w').write(s.replace(old,new,1)) -PY -prove_mutated "$SET_REL" "ABLATION-NEVER-PRESENT-XYZ" "ABLATION-D" 1 -run_suite caricature3 -prove_restored -echo -echo "ABLATION COMPLETE" diff --git a/.ablation/run2.sh b/.ablation/run2.sh deleted file mode 100644 index f8e5ec61aa..0000000000 --- a/.ablation/run2.sh +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env bash -# Ablation legs 4-5 for objectui#8464 — the CENSUS's own load-bearing halves. -# Mutates a READ SITE in @object-ui/fields (`getCellRenderer`), never a pin. -# Vitest aliases '@object-ui/fields' -> packages/fields/src (vitest.config.mts -# line 507), so the source edit IS what the test executes; no dist is involved. -set -uo pipefail - -REPO_ROOT="$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)" -cd "$REPO_ROOT" -REL="packages/fields/src/index.tsx" -ABS="$REPO_ROOT/$REL" -OUT="${ABL_OUT:-/tmp/ablation-8464}" -mkdir -p "$OUT" - -restore() { git -C "$REPO_ROOT" checkout HEAD -- "$ABS" 2>/dev/null || true; } -trap restore EXIT INT TERM - -prove_mutated() { # $1 injected-anchor $2 expected line delta - local added="$1" delta="$2" - local head_blob now_blob n_added head_lines now_lines - head_blob="$(git -C "$REPO_ROOT" rev-parse "HEAD:$REL")" - now_blob="$(git -C "$REPO_ROOT" hash-object "$ABS")" - [ -n "$head_blob" ] && [ -n "$now_blob" ] || { echo "ABLATION FAILURE: empty hash"; exit 3; } - [ "$head_blob" != "$now_blob" ] || { echo "ABLATION FAILURE: byte-identical to HEAD"; exit 3; } - n_added="$(grep -c -- "$added" "$ABS" || true)" - [ "$n_added" != "0" ] || { echo "ABLATION FAILURE: injected anchor absent"; exit 3; } - # The other direction: the unmutated function must no longer be reachable — - # its first registry lookup is gone from the executed path, proven by the - # early return sitting ABOVE it. - grep -q "if (fieldRegistry.has(fieldType))" "$ABS" || { echo "ABLATION FAILURE: lost the anchor we mutate around"; exit 3; } - head_lines="$(git -C "$REPO_ROOT" show "HEAD:$REL" | wc -l)" - now_lines="$(wc -l < "$ABS")" - [ "$(( now_lines - head_lines ))" = "$delta" ] || { echo "ABLATION FAILURE: line gate expected $delta got $(( now_lines - head_lines ))"; exit 3; } - echo " mutation ON DISK: $REL head=$head_blob now=$now_blob injected-anchor=$n_added lines ${head_lines}->${now_lines}" -} - -prove_restored() { - restore - local d; d="$(git -C "$REPO_ROOT" diff HEAD --name-only)" - [ -z "$d" ] || { echo "ABLATION FAILURE: restore left tree dirty: $d"; exit 3; } - echo " restored BY STATE: git diff HEAD is empty" -} - -run_suite() { - local label="$1" - pnpm exec vitest run \ - packages/plugin-detail/src/__tests__/summaryChip.badgeFitCensus-8464.test.tsx \ - --reporter=json --outputFile="$OUT/$label.json" > "$OUT/$label.log" 2>&1 - echo " vitest exit: $?" - node -e ' - const r=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); - const rows=[]; for(const f of r.testResults||[]) for(const a of f.assertionResults||[]) rows.push([a.status,a.fullName]); - const failed=rows.filter(([s])=>s==="failed"); - console.log(" TOTAL "+rows.length+" PASSED "+rows.filter(([s])=>s==="passed").length+" FAILED "+failed.length); - for(const [,n] of failed) console.log(" RED "+n); - ' "$OUT/$label.json" -} - -mutate() { # $1 = replacement body line - python3 - "$ABS" "$1" <<'PY' -import sys -p, inject = sys.argv[1], sys.argv[2] -s=open(p).read() -old="export function getCellRenderer(fieldType: string): React.FC {\n" -assert s.count(old)==1, s.count(old) -open(p,'w').write(s.replace(old, old+" "+inject+"\n", 1)) -PY -} - -echo "== CARICATURE 4 - every type answers what the text cell answers ==" -mutate "return TextCellRenderer; // ABLATION-E" -prove_mutated "ABLATION-E" 1 -run_suite caricature4 -prove_restored - -echo -echo "== CARICATURE 5 — every type draws the shared No-value affordance ==" -mutate "return () => ; // ABLATION-F" -prove_mutated "ABLATION-F" 1 -run_suite caricature5 -prove_restored -echo -echo "ABLATION 2 COMPLETE" From c83fee0eba870ed15cf5af0d282341256ce10309 Mon Sep 17 00:00:00 2001 From: os-justin Date: Wed, 9 Sep 2026 01:39:09 +0000 Subject: [PATCH 4/4] docs(plugin-detail): describe what a summary chip does with a non-scalar value, and add the changeset Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S --- .changeset/8464-summary-chip-object-value.md | 24 ++++++++++++++++++++ packages/plugin-detail/README.md | 21 +++++++++++++++++ packages/plugin-detail/src/DetailView.tsx | 5 ++-- 3 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 .changeset/8464-summary-chip-object-value.md diff --git a/.changeset/8464-summary-chip-object-value.md b/.changeset/8464-summary-chip-object-value.md new file mode 100644 index 0000000000..2d834b27f8 --- /dev/null +++ b/.changeset/8464-summary-chip-object-value.md @@ -0,0 +1,24 @@ +--- +'@object-ui/plugin-detail': patch +--- + +The record page's summary chips beside the H1 no longer render an object-valued +field as the literal text `[object Object]`. + +`effectiveSummaryFields`' chip displayed `String(val)` with only currency, date, +datetime, percent and the option families formatted, so an expanded lookup +payload, a location or an address printed the placeholder next to the page +title — and, because the chip's accessible name is built from that same string, +in its accessible name too. + +An object value is now drawn by the field's own cell renderer, the way the +highlights strip one band below already reads it: a lookup chip shows the +referenced record's name, an address chip its formatted postal line, a location +chip its coordinates. Fifteen field kinds whose renderer does not fit a pill — +the option families (a badge inside a badge), `user` (an avatar), the image +family (no text at all), and the kinds that draw a "No value" face for a value +the page has just called filled — keep a text chip and take +`@object-ui/fields`' shared value coercion instead. + +Values that already rendered are untouched, and the chip's emptiness +classification is unchanged. diff --git a/packages/plugin-detail/README.md b/packages/plugin-detail/README.md index a363c7609b..ac9fca29eb 100644 --- a/packages/plugin-detail/README.md +++ b/packages/plugin-detail/README.md @@ -243,6 +243,27 @@ const schema: DetailViewSchema = { }; ``` +### `summaryFields` and non-scalar values + +A summary chip is a single-line pill, and it formats `currency`, `date`, +`datetime`, `percent` and the option families itself. Any other value it can +render as text it renders as text. + +An **object** value — an expanded lookup payload, an address, a location, a +file — is drawn by that field's own cell renderer, so the chip shows what the +same value shows everywhere else on the page: the referenced record's name, the +formatted postal line, the coordinates, the file name. + +Fifteen field kinds are the exception, because their renderer does not fit a +pill: the option families draw a badge (which would nest inside the chip's own), +`user` draws an avatar, `image` / `avatar` / `signature` draw an image and no +text at all, and `boolean` / `toggle` / `date` / `datetime` / `repeater` draw a +"No value" face for a value the chip only exists because the page called +filled. Those keep a text chip, carrying `@object-ui/fields`' shared value +coercion — the record's name where the object has one, and `[Object]` where it +does not. The set and the measurement behind it are +`src/summaryChipRenderers.ts`. + ## Components ### DetailSection diff --git a/packages/plugin-detail/src/DetailView.tsx b/packages/plugin-detail/src/DetailView.tsx index 01e91cc672..6e2fdb7215 100644 --- a/packages/plugin-detail/src/DetailView.tsx +++ b/packages/plugin-detail/src/DetailView.tsx @@ -1153,11 +1153,12 @@ export const DetailView: React.FC = ({ ); const chipRendererType = resolveCellRendererType(chipField as any) || ftype || 'text'; + const stringPathFailed = display.includes('[object Object]'); const ChipCellRenderer = - display.includes('[object Object]') && chipTakesCellRenderer(chipRendererType) + stringPathFailed && chipTakesCellRenderer(chipRendererType) ? getCellRenderer(chipRendererType) : null; - if (!ChipCellRenderer && display.includes('[object Object]')) { + if (stringPathFailed && !ChipCellRenderer) { display = String(coerceToSafeValue(val) ?? ''); }