From 9159c55c14e46aa14cf1f66af90b63c990df4023 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 21:49:33 +0000 Subject: [PATCH 1/2] fix(fields): composite and record cells draw labelled sub-values, not the stored JSON (objectui#11697) `composite` and `record` were registered to JsonCellRenderer, so the record page (and every grid cell reading the same table) drew `{"width":10,"height":20}` in a monospace face. They now draw one truncated line of labelled pairs: a composite key per pair, a record entry per labelled group. Scalar sub-values read through the package's own faces (the number formatter, the boolean word); nested objects and arrays stay compact JSON. The floor and unrecognized shapes answer exactly as JsonCellRenderer does. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- packages/fields/src/index.tsx | 225 +++++++++++++++++++++++++++++++--- 1 file changed, 208 insertions(+), 17 deletions(-) diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 883f72b7dd..6e801a91c2 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -31,6 +31,7 @@ import { formatAddress, type AddressValue } from './widgets/address-format.js'; // (`CurrencyField`) and percent (`PercentField`) (objectui#11444). import { formatNumberFieldValue, formatCurrency, formatPercentPoints, percentCellScale } from './widgets/number-format.js'; import { useFieldTranslation } from './widgets/useFieldTranslation.js'; +import { useBooleanValueLabel } from './widgets/booleanValueLabel.js'; // Module-level cache so multiple renderers fetching the same lookup ID // only trigger one network call. Keyed by `${objectName}:${id}`. It holds the @@ -406,8 +407,9 @@ export { coerceToSafeValue }; * empty, and + every unparsable one, objectui#8581); * - **the floor with a member DECLINED, out loud** — `JsonCellRenderer` draws * the two-character literal for `[]` on purpose (objectui#8474 measured and - * kept it), `LocationCellRenderer` and `AddressCellRenderer` inherit that - * through their JSON fallback, and `FileCellRenderer` states "0 files". + * kept it), `LocationCellRenderer`, `AddressCellRenderer` and the + * `composite` / `record` face (`StructuredValueCell`) inherit that through + * their JSON fallback, and `FileCellRenderer` states "0 files". * * ⛔ Those disagreements are MEASURED, not drift: do not "finish the job" by * making every renderer answer the floor. The pins that go red if one is @@ -3064,9 +3066,28 @@ export function resolveCellRendererType(fieldOrType: string | { type?: string; f } /** - * Renders structured/embedded values (json, object, composite, record, - * address, geolocation) as compact, readable JSON. Objects and arrays are - * stringified; primitives fall through to their string form. + * The compact JSON text of a value: objects and arrays are stringified, + * primitives fall through to their string form. A structure `JSON.stringify` + * cannot represent (a cycle) keeps its `String` form rather than throwing out + * of a render. + * + * ONE spelling, two readers: {@link JsonCellRenderer} draws a whole value with + * it, and the `composite` / `record` face below draws the nested values it + * still shows as JSON with it, so one stored value cannot read two ways. + */ +function compactJsonText(value: unknown): string { + if (typeof value !== 'object') return String(value); + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +/** + * Renders a free-form JSON value (`json`, `object`) as compact, readable JSON, + * and is the fallback face for a shape a structured renderer cannot recognize + * (`location` / `geolocation`, `address`, `composite` / `record`). */ export function JsonCellRenderer({ value }: CellRendererProps): React.ReactElement { // THE FLOOR WITH ONE MEMBER DECLINED, and the declension is the point @@ -3076,22 +3097,191 @@ export function JsonCellRenderer({ value }: CellRendererProps): React.ReactEleme // objectui#8474 measured that and pinned it. ⛔ Do not simplify this to // `isEmptyValue(value)`: that flattens a decision already on the record. if (isEmptyValue(value) && !Array.isArray(value)) return ; - let text: string; - if (typeof value === 'object') { - try { - text = JSON.stringify(value); - } catch { - text = String(value); - } - } else { - text = String(value); - } + const text = compactJsonText(value); // The original site of the block-level+max-w-full+title pattern // (objectui#2578) — now shared with every single-line value renderer via // TruncatedText (objectui#3466). return ; } +/** + * The `number` field a sub-value is formatted as: no `scale` is declared, so a + * sub-value keeps its natural precision and the default grouping, exactly what + * `NumberCellRenderer` draws for a `number` field that declares none. + */ +const UNDECLARED_NUMBER_SUB_FIELD: FieldMetadata = { name: '', type: 'number' }; + +/** One drawn sub-value: the node the line shows, and its text for the `title`. */ +interface SubValueFace { + node: React.ReactNode; + text: string; +} + +/** One labelled sub-value: a key and its face. */ +interface StructuredPair { + key: string; + label: string; + face: SubValueFace; +} + +/** One entry of the face: a pair, or (a `record` entry) a labelled group of pairs. */ +type StructuredEntry = StructuredPair | { key: string; label: string; group: StructuredPair[] }; + +const STRUCTURED_SEPARATOR = ' · '; + +/** The plain text of a list of entries — the line as the `title` spells it. */ +function structuredEntriesText(entries: readonly StructuredEntry[]): string { + return entries + .map((entry) => + 'group' in entry + ? `${entry.label} (${structuredEntriesText(entry.group)})` + : `${entry.label} ${entry.face.text}`, + ) + .join(STRUCTURED_SEPARATOR); +} + +/** + * The `dt` / `dd` pairs of a list of entries. The separator and the brackets + * are `aria-hidden` (the `
` already says where a pair starts) and sit + * INSIDE a `dt` / `dd`, because a `
` may hold nothing else. + */ +function structuredEntriesNodes(entries: readonly StructuredEntry[]): React.ReactNode { + return entries.map((entry, index) => ( +
+
+ {index > 0 && ( + + )} + {entry.label} +
{' '} +
+ {'group' in entry ? ( + <> + +
{structuredEntriesNodes(entry.group)}
+ + + ) : ( + entry.face.node + )} +
+
+ )); +} + +/** + * `composite` / `record` cell renderers: an embedded value read as labelled + * sub-values, not as its stored JSON (objectui#11697). + * + * `@objectstack/spec` declares `composite` as one embedded sub-object and + * `record` as a name-keyed map of embedded sub-objects whose insertion order is + * the display order. Both were registered to {@link JsonCellRenderer}, so the + * record page, which reaches every value through this table (the one the grid + * reads too), drew `{"width":10,"height":20}` in a monospace face. `address` + * made the same move in objectui#4037. + * + * ## The face + * + * - `composite`: one labelled pair per key, `Width 10 · Height 20`. + * - `record`: one labelled group per entry name holding that entry's pairs, + * `Primary (Name A · Score 9) · Backup (Name B · Score 7)`. An entry that + * is not a populated sub-object is drawn as a pair, as a composite key is. + * - A scalar sub-value is drawn with this package's face for its JS type: a + * number through `formatNumberFieldValue` (the call `NumberCellRenderer` + * makes, here with no declared `scale`), a boolean as the locale's word + * (`useBooleanValueLabel`, the boolean-as-text face of the read-only + * surfaces), a string as itself, and a floor member as {@link EmptyValue}. + * ⛔ Not the cell components themselves: the text cell and the boolean + * checkbox are block-level, and this face is one line. + * - A nested object or array stays compact JSON ({@link compactJsonText}). + * + * It is ONE truncated line in every host, as {@link TruncatedText} is, because + * the grid cell, the record page row and the summary chip all draw it; the + * full text is the `title` on the value element. The structure is a `
`, so + * assistive technology reads term / definition pairs. + * + * ## Labels + * + * The humanized key (`humanizeLabel`, the key fallback this package's boolean + * cell already uses for its "Off" badge). There is no declared sub-field label + * to prefer: the spec's `FieldSchema` has no sub-field member for either type + * and refuses `fields` / `subFields` as unrecognized keys, so no metadata a + * producer can publish carries one, and a label read from the field here would + * be a key the contract refuses (AGENTS.md #0.1). + * + * ## Unchanged + * + * The floor, and every shape this face does not recognize, answer exactly as + * {@link JsonCellRenderer} answers: `null` / `undefined` / `''` draw + * {@link EmptyValue}; `[]` and `{}` keep their literal (objectui#8474, and + * objectui#8481's json-literal fence); a value that is not an object, a JSON + * string included, is drawn as it is and never parsed (AGENTS.md #0.1). + * + * ⛔ Not exported, for the reason {@link RepeaterCellRenderer} gives: the table + * entries are reachable the way every call site reaches them, + * `getCellRenderer('composite')` / `getCellRenderer('record')`. + */ +function StructuredValueCell({ + value, + field, + groupEntries, +}: CellRendererProps & { groupEntries: boolean }): React.ReactElement { + // Hooks before the early returns, so the hook count does not change with the + // value (the rule `NumberCellRenderer` states). + const locale = useDisplayLocale(); + const booleanLabel = useBooleanValueLabel(); + if (isEmptyValue(value) && !Array.isArray(value)) return ; + if (!isPlainObjectValue(value) || Object.keys(value).length === 0) { + return ; + } + + const faceOf = (sub: unknown): SubValueFace => { + if (isEmptyValue(sub) && !Array.isArray(sub)) { + return { node: , text: '—' }; + } + if (typeof sub === 'boolean') { + const word = booleanLabel(sub); + return { node: word, text: word }; + } + if (typeof sub === 'number' && Number.isFinite(sub)) { + const formatted = formatNumberFieldValue(sub, UNDECLARED_NUMBER_SUB_FIELD, locale); + return { node: {formatted}, text: formatted }; + } + if (Array.isArray(sub) || isPlainObjectValue(sub)) { + const json = compactJsonText(sub); + return { node: {json}, text: json }; + } + const text = String(coerceToSafeValue(sub)); + return { node: text, text }; + }; + const pairsOf = (object: Record): StructuredPair[] => + Object.entries(object).map(([key, sub]) => ({ key, label: humanizeLabel(key), face: faceOf(sub) })); + + const entries: StructuredEntry[] = Object.entries(value).map(([key, sub]) => + groupEntries && isPlainObjectValue(sub) && Object.keys(sub).length > 0 + ? { key, label: humanizeLabel(key), group: pairsOf(sub) } + : { key, label: humanizeLabel(key), face: faceOf(sub) }, + ); + + return ( +
+ {structuredEntriesNodes(entries)} +
+ ); +} + +/** `composite`: one embedded sub-object, drawn as labelled pairs. See {@link StructuredValueCell}. */ +function CompositeCellRenderer(props: CellRendererProps): React.ReactElement { + return ; +} + +/** `record`: a name-keyed map of sub-objects, one labelled group per entry. See {@link StructuredValueCell}. */ +function RecordMapCellRenderer(props: CellRendererProps): React.ReactElement { + return ; +} + /** * Renders a `color` value as a swatch alongside its hex/string value. */ @@ -3499,8 +3689,9 @@ function buildStandardCellRendererMap(): Record Date: Tue, 6 Oct 2026 22:02:25 +0000 Subject: [PATCH 2/2] test(fields,plugin-detail): pin the composite / record labelled face, move the census rows, document it (objectui#11697) The renderer's contract is pinned through getCellRenderer in `compositeRecordCell-11697`, and the record page end of the path through the real DetailSection in `DetailSection.compositeRecord-11697`. Two existing pins named composite / record as JSON and move with the face: the address test's "structural types stay JSON" control keeps json / object only, and the summary chip census records the new text (still a fit). The object field doc's cell renderer section and the changeset describe the new face. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .changeset/11697-composite-record-display.md | 12 + content/docs/fields/object.mdx | 21 +- .../__tests__/AddressCellRenderer.test.tsx | 4 +- .../compositeRecordCell-11697.test.tsx | 212 ++++++++++++++++++ ...tailSection.compositeRecord-11697.test.tsx | 90 ++++++++ .../summaryChip.badgeFitCensus-8464.test.tsx | 8 +- 6 files changed, 341 insertions(+), 6 deletions(-) create mode 100644 .changeset/11697-composite-record-display.md create mode 100644 packages/fields/src/__tests__/compositeRecordCell-11697.test.tsx create mode 100644 packages/plugin-detail/src/__tests__/DetailSection.compositeRecord-11697.test.tsx diff --git a/.changeset/11697-composite-record-display.md b/.changeset/11697-composite-record-display.md new file mode 100644 index 0000000000..5453cd03a6 --- /dev/null +++ b/.changeset/11697-composite-record-display.md @@ -0,0 +1,12 @@ +--- +'@object-ui/fields': patch +--- + +A `composite` or `record` field value reads as labelled sub-values, not as its stored JSON (objectui#11697). On the record page, and in every grid cell that draws from the same cell-renderer table, a composite value such as `{"width":10,"height":20}` showed as raw JSON in a monospace face. It now reads `Width 10 · Height 20`. A record value reads one labelled group per entry name, so `{ primary: { name: "A", score: 9 }, backup: { name: "B", score: 7 } }` reads `Primary (Name A · Score 9) · Backup (Name B · Score 7)`. A record entry that is not a sub-object reads as a pair. + +- **Labels** are the humanized key. The field metadata has no sub-field declaration to read one from: `@objectstack/spec`'s field schema declares none for these types and refuses `fields` / `subFields` as unrecognized keys. +- **Sub-values.** A number is formatted as the number cell formats a field with no declared `scale`, and a boolean reads as Yes / No in the reader's language. A string reads as itself and an unset sub-value as the shared "No value" dash. An object or array nested inside a sub-value stays compact JSON. +- **One line.** The value is one truncated line in the grid, on the record page and in the summary chip, with the full text in its `title`. It is a description list, so a screen reader reads term and value pairs. +- **Unchanged.** An empty value, `[]`, `{}` and a non-object value read exactly as the JSON cell reads them, and a string holding JSON is never parsed. `json` and `object` keep the JSON cell. The record page's copy button still copies the stored JSON. + +Nothing is added to the package entry: no export, prop, type member or language-pack key. The new renderer is module-local and is reached through `getCellRenderer('composite')` and `getCellRenderer('record')`. diff --git a/content/docs/fields/object.mdx b/content/docs/fields/object.mdx index 4998365f60..2b1886b905 100644 --- a/content/docs/fields/object.mdx +++ b/content/docs/fields/object.mdx @@ -131,8 +131,7 @@ The object field stores data as parsed JSON: ## Cell Renderer In tables/grids, an `object` value is rendered by the JSON cell renderer — -`getCellRenderer('object')` resolves to this same component, shared with `json`, -`composite` and `record`: +`getCellRenderer('object')` resolves to this same component, shared with `json`: ```ts import { JsonCellRenderer } from '@object-ui/fields'; @@ -142,6 +141,24 @@ import { JsonCellRenderer } from '@object-ui/fields'; // A null or empty value renders an em-dash instead. ``` +`composite` and `record` values are not drawn as JSON. Their cell — in a grid and +on the record page alike — is one line of labelled sub-values, with the full text +in the `title`: + +```plaintext +// composite { width: 10, height: 20 } +Width 10 · Height 20 + +// record { primary: { name: "A", score: 9 }, backup: { name: "B", score: 7 } } +Primary (Name A · Score 9) · Backup (Name B · Score 7) +``` + +A label is the humanized key: the field metadata declares no sub-fields to take a +label from. A number sub-value is formatted as the number cell formats it, a boolean +reads as Yes / No in the reader's language, a string reads as itself, and an object or +an array nested inside a sub-value stays compact JSON. +An empty value, `[]`, `{}` and a non-object value read as the JSON cell reads them. + ## Schema Validation For typed object fields, you can define a schema in two formats: diff --git a/packages/fields/src/__tests__/AddressCellRenderer.test.tsx b/packages/fields/src/__tests__/AddressCellRenderer.test.tsx index 9ab639966d..aa73b4c4f6 100644 --- a/packages/fields/src/__tests__/AddressCellRenderer.test.tsx +++ b/packages/fields/src/__tests__/AddressCellRenderer.test.tsx @@ -158,7 +158,9 @@ describe('address display renderer — controls (unchanged surfaces)', () => { it('leaves genuinely structural types stringifying as JSON', () => { // `address` moved out of the JSON bucket; `json` / `object` stay in it. - for (const type of ['json', 'object', 'composite', 'record']) { + // `composite` / `record` moved out too (objectui#11697): they draw labelled + // sub-values, pinned in `compositeRecordCell-11697.test.tsx`. + for (const type of ['json', 'object']) { const { container } = renderThroughDisplayRegistry(type, { a: 1 }); expect(container.textContent).toBe('{"a":1}'); } diff --git a/packages/fields/src/__tests__/compositeRecordCell-11697.test.tsx b/packages/fields/src/__tests__/compositeRecordCell-11697.test.tsx new file mode 100644 index 0000000000..e7130e7373 --- /dev/null +++ b/packages/fields/src/__tests__/compositeRecordCell-11697.test.tsx @@ -0,0 +1,212 @@ +/** + * 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. + */ + +/** + * objectui#11697: a `composite` or `record` value read as its stored JSON on + * the record page — `{"width":10,"height":20}` in a monospace face — because + * the cell-renderer table mapped both types to `JsonCellRenderer`. They now + * draw labelled sub-values on one line. + * + * Every case resolves its renderer through `getCellRenderer`, the resolver the + * record page and the grid both call, so a regressed table entry goes red here + * and not only a regressed renderer. The record-page end of the path is pinned + * in `@object-ui/plugin-detail`'s `DetailSection.compositeRecord-11697`. + * + * Values are the platform's own producers where one exists: the showcase Field + * Zoo seeds `f_composite` / `f_record`, and the SQL driver's fidelity test and + * the dogfood field-zoo matrix write a `record` whose entries are scalars. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import type { FieldMetadata } from '@object-ui/types'; +import { getCellRenderer, resolveCellRendererType } from '../index'; + +afterEach(() => cleanup()); + +/** Resolve + render exactly the way `DetailSection` builds a read-mode value. */ +function renderCell(type: string, value: unknown) { + const Renderer = getCellRenderer(resolveCellRendererType({ type }) || type); + const field = { type, name: `f_${type}` } as unknown as FieldMetadata; + return render(); +} + +/** What assistive technology reads in an element: its text minus `aria-hidden` nodes. */ +function spokenText(element: Element): string { + const clone = element.cloneNode(true) as Element; + clone.querySelectorAll('[aria-hidden="true"]').forEach((node) => node.remove()); + return (clone.textContent ?? '').trim(); +} + +/** The `dt` / `dd` pairs directly under one `
`, as spoken. */ +function pairsOf(list: Element): Array<[term: string, definition: string]> { + return Array.from(list.querySelectorAll(':scope > div')).map((group) => [ + spokenText(group.querySelector(':scope > dt')!), + spokenText(group.querySelector(':scope > dd')!), + ]); +} + +/** The face's outer `
`; fails loudly when the cell drew something else. */ +function outerList(container: HTMLElement): HTMLElement { + const outer = container.firstElementChild as HTMLElement | null; + expect(outer?.tagName, 'the face is a description list').toBe('DL'); + return outer!; +} + +describe('objectui#11697 — composite and record values render as labelled sub-values', () => { + it('a composite value renders each sub-field as a labelled pair, not as its JSON', () => { + // The Field Zoo seed for `f_composite`, the value the card reported. + const { container } = renderCell('composite', { width: 10, height: 20 }); + const outer = outerList(container); + + expect(pairsOf(outer)).toEqual([ + ['Width', '10'], + ['Height', '20'], + ]); + expect(container.textContent).toBe('Width 10 · Height 20'); + expect(container.textContent, 'no stored-JSON signature').not.toMatch(/[{}"]/); + }); + + it('a record value renders one labelled group per entry name, holding that entry’s pairs', () => { + // The Field Zoo seed for `f_record`. + const { container } = renderCell('record', { + primary: { name: 'A', score: 9 }, + backup: { name: 'B', score: 7 }, + }); + const outer = outerList(container); + + const entries = Array.from(outer.querySelectorAll(':scope > div')); + expect(entries.map((entry) => spokenText(entry.querySelector(':scope > dt')!))).toEqual([ + 'Primary', + 'Backup', + ]); + const groups = entries.map((entry) => entry.querySelector(':scope > dd > dl')!); + expect(pairsOf(groups[0])).toEqual([ + ['Name', 'A'], + ['Score', '9'], + ]); + expect(pairsOf(groups[1])).toEqual([ + ['Name', 'B'], + ['Score', '7'], + ]); + expect(container.textContent).toBe('Primary (Name A · Score 9) · Backup (Name B · Score 7)'); + expect(container.textContent, 'no stored-JSON signature').not.toMatch(/[{}"]/); + }); + + it('a record entry that is not a sub-object is drawn as a pair, with no group', () => { + // The shape the SQL driver's fidelity test and the dogfood matrix write. + const { container } = renderCell('record', { home: '+1', work: '+2' }); + const outer = outerList(container); + + expect(pairsOf(outer)).toEqual([ + ['Home', '+1'], + ['Work', '+2'], + ]); + expect(outer.querySelector('dd dl'), 'a scalar entry opens no group').toBeNull(); + }); + + it('a nested object or array inside a sub-value stays compact JSON', () => { + const composite = renderCell('composite', { size: { w: 1 }, tags: ['a', 'b'] }); + expect(pairsOf(outerList(composite.container))).toEqual([ + ['Size', '{"w":1}'], + ['Tags', '["a","b"]'], + ]); + expect( + composite.container.querySelector('dd dl'), + 'a composite never groups: its sub-field holds an object, it is not a map entry', + ).toBeNull(); + cleanup(); + + const record = renderCell('record', { primary: { dims: { w: 1 } } }); + expect(record.container.textContent).toBe('Primary (Dims {"w":1})'); + }); + + it('each scalar sub-value reads through this package’s face for its type', () => { + const { container } = renderCell('composite', { + total: 1234.5, + active: true, + archived: false, + note: null, + code: 'x-1', + }); + const pairs = pairsOf(outerList(container)); + + // The number is the `number` cell's own text for a field with no `scale`: + // compared against that cell, not against a literal, so the two agree by + // construction rather than by a copy. + const numberCell = render( + React.createElement(getCellRenderer('number'), { + value: 1234.5, + field: { type: 'number', name: 'n' } as FieldMetadata, + }), + ); + expect(pairs[0]).toEqual(['Total', numberCell.container.textContent]); + expect(pairs[0][1], 'grouped, natural precision').toBe('1,234.5'); + // A boolean is the locale's word, the boolean-as-text face. + expect(pairs[1]).toEqual(['Active', 'Yes']); + expect(pairs[2]).toEqual(['Archived', 'No']); + // A floor member is the shared affordance, with its accessible name. + const noteValue = outerList(container).querySelectorAll(':scope > div > dd')[3]; + expect( + noteValue.querySelector('[data-slot="empty-value"]')?.getAttribute('aria-label'), + 'an unset sub-value is the shared "No value" affordance', + ).toBe('No value'); + expect(pairs[4]).toEqual(['Code', 'x-1']); + }); + + it('the face is one truncated line whose title carries the full text', () => { + const { container } = renderCell('record', { + primary: { name: 'A', score: 9 }, + backup: { name: 'B', score: 7 }, + }); + const outer = outerList(container); + for (const token of ['block', 'max-w-full', 'truncate']) { + expect(outer.classList.contains(token), `the line carries \`${token}\``).toBe(true); + } + expect(outer.getAttribute('title'), 'the hover text is the line itself').toBe(container.textContent); + }); +}); + +describe('objectui#11697 — what does NOT move', () => { + for (const type of ['composite', 'record'] as const) { + it(`\`${type}\`: the floor and the unrecognized shapes answer exactly as the JSON cell does`, () => { + for (const empty of [null, undefined, '']) { + const { container } = renderCell(type, empty); + expect( + container.querySelector('[data-slot="empty-value"]'), + `${type} holding ${JSON.stringify(empty)}: the shared affordance`, + ).not.toBeNull(); + expect(container.querySelector('dl')).toBeNull(); + cleanup(); + } + // The literals objectui#8474 and objectui#8481's fence pinned, and a + // string that happens to hold JSON: drawn as it is, never parsed. + for (const [input, text] of [ + [[], '[]'], + [{}, '{}'], + ['{"width":10}', '{"width":10}'], + ] as const) { + const { container } = renderCell(type, input); + expect(container.textContent, `${type} holding ${JSON.stringify(input)}`).toBe(text); + expect(container.querySelector('dl')).toBeNull(); + cleanup(); + } + }); + } + + it('`json` and `object` keep the compact JSON face', () => { + for (const type of ['json', 'object']) { + const { container } = renderCell(type, { width: 10, height: 20 }); + expect(container.textContent, `${type}: a free-form JSON value reads as JSON`).toBe( + '{"width":10,"height":20}', + ); + cleanup(); + } + }); +}); diff --git a/packages/plugin-detail/src/__tests__/DetailSection.compositeRecord-11697.test.tsx b/packages/plugin-detail/src/__tests__/DetailSection.compositeRecord-11697.test.tsx new file mode 100644 index 0000000000..58d16f2646 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/DetailSection.compositeRecord-11697.test.tsx @@ -0,0 +1,90 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { render } from '@testing-library/react'; +import { DetailSection } from '../DetailSection'; +import type { DetailViewSection } from '@object-ui/types'; + +/** + * objectui#11697: on the record page the showcase Field Zoo's "Composite + * (embedded object)" field read `{"width":10,"height":20}` and its "Record + * (name-keyed map)" field read `{"primary":{"name":"A","score":…`, raw JSON in + * a monospace face. + * + * End-of-path: these drive the real `DetailSection`, which reaches the value + * through `getCellRenderer`, so they stay red if the table entry regresses even + * when the renderer itself is fine. The renderer's own contract is pinned in + * `@object-ui/fields`' `compositeRecordCell-11697`. + */ +describe('DetailSection — composite and record fields render labelled sub-values (objectui#11697)', () => { + // The desktop row renders above the mobile breakpoint. + beforeAll(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); + }); + + // The Field Zoo's two fields and its seed values. + const objectSchema = { + fields: { + f_composite: { type: 'composite', label: 'Composite (embedded object)' }, + f_record: { type: 'record', label: 'Record (name-keyed map)' }, + }, + }; + const data = { + f_composite: { width: 10, height: 20 }, + f_record: { primary: { name: 'A', score: 9 }, backup: { name: 'B', score: 7 } }, + }; + + /** The value `
` drawn in the row labelled `label`. */ + function valueListFor(container: HTMLElement, label: string): HTMLElement { + const row = Array.from(container.querySelectorAll('div')).find( + (div) => div.firstElementChild?.textContent === label, + ); + expect(row, `a row labelled "${label}"`).toBeDefined(); + const list = row!.querySelector('dl'); + expect(list, `the "${label}" row draws a description list`).not.toBeNull(); + return list as HTMLElement; + } + + /** The terms directly under one `
`, minus the `aria-hidden` separators. */ + function termsOf(list: Element): string[] { + return Array.from(list.querySelectorAll(':scope > div > dt')).map((term) => { + const clone = term.cloneNode(true) as Element; + clone.querySelectorAll('[aria-hidden="true"]').forEach((node) => node.remove()); + return (clone.textContent ?? '').trim(); + }); + } + + it('a composite field renders labelled sub-values on the record page', () => { + const { container } = render( + , + ); + const list = valueListFor(container, 'Composite (embedded object)'); + expect(termsOf(list)).toEqual(['Width', 'Height']); + expect(list.textContent).toBe('Width 10 · Height 20'); + expect(container.textContent, 'no stored-JSON signature').not.toContain('{"width"'); + }); + + it('a record field renders one labelled group per entry on the record page', () => { + const { container } = render( + , + ); + const list = valueListFor(container, 'Record (name-keyed map)'); + expect(termsOf(list)).toEqual(['Primary', 'Backup']); + expect(list.textContent).toBe('Primary (Name A · Score 9) · Backup (Name B · Score 7)'); + expect(container.textContent, 'no stored-JSON signature').not.toContain('{"primary"'); + }); +}); 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 99da7760a6..ee24bde01f 100644 --- a/packages/plugin-detail/src/__tests__/summaryChip.badgeFitCensus-8464.test.tsx +++ b/packages/plugin-detail/src/__tests__/summaryChip.badgeFitCensus-8464.test.tsx @@ -28,7 +28,7 @@ * * | class | types | what the pill received | * |--------------------------|-------------------------------------------------------------|-----------------------------------------------------| - * | ✅ plain inline text | the other 35 | `Acme Corp`, or the JSON literal for the seven behind objectui#8481's declared json-literal fence, or a value-independent face | + * | ✅ plain inline text | the other 35 | `Acme Corp`, or the JSON literal for the seven behind objectui#8481's declared json-literal fence (five since objectui#11697: `composite` / `record` now draw labelled sub-values), 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 === ''` | @@ -143,8 +143,10 @@ const CENSUS: ReadonlyArray` of inline `dt` / `dd` text: no pill, no image, no control. + ['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'],