Skip to content

Commit db4cb6b

Browse files
fix(fields): composite and record values read as labelled sub-values, not raw JSON (objectui#11697) (#11749)
Fixes #11697 Clause-②: no A `composite` or `record` field value now reads as labelled sub-values on the record page, not as its stored JSON. The audit log actor half of the original card is objectui#11701, which this pull request does not touch and which remains open. ## What changed - `packages/fields/src/index.tsx`, `buildStandardCellRendererMap`: `composite` maps to `CompositeCellRenderer` and `record` to `RecordMapCellRenderer`. Both are module-local and share one body, `StructuredValueCell`, which sits beside `JsonCellRenderer`. `json` and `object` keep `JsonCellRenderer`. The stringify inside `JsonCellRenderer` moved into a helper, `compactJsonText`, with the same bytes, so the JSON cell and the nested sub-values below use one spelling. - The face is one truncated line (`block max-w-full truncate`) with the full text in its `title`, the same contract as `TruncatedText`. It is a description list (dl, dt and dd elements). The separators and brackets are `aria-hidden` and sit inside the dt and dd elements, so the list stays valid. - composite: `Width 10 · Height 20` - record: `Primary (Name A · Score 9) · Backup (Name B · Score 7)`, one labelled group per entry name. A record entry that is not a populated sub-object is drawn as a pair. - A number sub-value goes through `formatNumberFieldValue`, the call `NumberCellRenderer` makes, with no declared `scale`. A boolean is the locale's word from `useBooleanValueLabel`, which reads the existing `common.yes` / `common.no` keys. A string is drawn as itself, and a floor member as `EmptyValue`. A nested object or array stays compact JSON. - Unchanged: `null`, `undefined` and `''` draw `EmptyValue`. `[]` and `{}` keep their literal, so the objectui#8474 pin and the objectui#8481 json-literal fence pins pass without edits. A non-object value, including a string that holds JSON, is drawn as it is and never parsed (AGENTS.md #0.1). - Raw view: none existed, so none was added. The record page's copy button still copies the stored JSON, because `composite` and `record` are inline-excluded and keep the copy affordance. Nothing is added to the package entry: no export, prop, type member or language-pack key. ### Why `DetailSection.tsx` is untouched The record page row already draws its values as one truncated line with a `title`: the text and address cells do this. The cell contract expresses the labelled face, so the record page needed no branch of its own. The record-page pin below drives the real `DetailSection`. ### Pins that moved with the face - `AddressCellRenderer.test.tsx`: its control "leaves genuinely structural types stringifying as JSON" now lists `json` and `object` only. - `summaryChip.badgeFitCensus-8464.test.tsx` (plugin-detail): the composite and record rows now read `Id acct-1 · Name Acme Corp`. They are still `fit`, because the face is plain inline text, so `CHIP_UNFIT_RENDERER_TYPES` is unchanged. Docs: the cell renderer section of `content/docs/fields/object.mdx` said `composite` and `record` share the JSON cell. It now describes the new face. Changeset: `.changeset/11697-composite-record-display.md` (`@object-ui/fields`: patch). ## Premise check: the dispatch's mechanism hypotheses, measured 1. **Path.** The record page reaches the value through `DetailSection`, then `resolveCellRendererType`, then `getCellRenderer`. Confirmed by the ablation below: reverting only the two table entries turns the `DetailSection` pin red. 2. **No sub-field declaration reaches the renderer.** On the installed `@objectstack/spec` 17.6.0, `FieldSchema.safeParse` of a composite field refuses `fields` and `subFields` as `unrecognized_keys`. The control, `relatedListColumns`, parses. The schema's 75 keys include no `fields`, `subFields`, `schema` or `itemSchema`. objectstack main `60ccda5a` `field.zod.ts` is the same. In objectui, the `FieldMetadata` union has no composite or record member, and `ObjectFieldMetadata.schema` exists only for `type: 'object'`, which stays JSON. So the labels are humanized keys. No spec-side declaration was added. 3. **Producers.** The Field Zoo seed values (`f_composite: { width: 10, height: 20 }`, and `f_record` with `primary` / `backup` entries) are the test fixtures. objectstack's SQL driver fidelity test and the dogfood field-zoo matrix write a `record` with scalar entries (`{ home: '+1', work: '+2' }`). That shape is pinned as pairs. 4. **Humanizer.** `humanizeLabel` from `@object-ui/core`. It is already imported here, and it is the key fallback the boolean cell's "Off" badge uses. The camelCase-splitting key convention, `humanizeFieldKey`, lives in `@object-ui/plugin-dashboard`, outside this package's dependency edge. See Acceptance notes. 5. **Precedent.** `address` made the same move (objectui#4037), with one face for the grid and the detail page. ## Tests: all at HEAD `22274e7` - `pnpm exec vitest run packages/fields/ --maxWorkers=2`: `Test Files 238 passed | 1 skipped (239)`, `Tests 3723 passed | 7 skipped (3730)`, exit 0. - `pnpm exec vitest run packages/plugin-detail/ --maxWorkers=2`: `Test Files 245 passed | 1 skipped (246)`, `Tests 2408 passed | 8 skipped (2416)`, exit 0. - New pins: - `packages/fields/src/__tests__/compositeRecordCell-11697.test.tsx`: 9 tests. They cover the composite pairs, record groups, scalar record entries, nested JSON, the per-type sub-value faces (the number compared against the `number` cell's own text), the one-line contract with the `title`, and the unchanged floor and fallbacks. `json` and `object` are the control. - `packages/plugin-detail/src/__tests__/DetailSection.compositeRecord-11697.test.tsx`: 2 tests. This is the triage pin: a composite field renders labelled sub-values on the record page, and so does the record field. - `pnpm --filter @object-ui/fields type-check` and `pnpm --filter @object-ui/plugin-detail type-check`: exit 0. `--listFilesOnly` shows that both programs include the new test files. - `pnpm exec eslint` on the 5 touched source and test files: 0 errors. No warning falls on an added line. - `pnpm check:control-bytes`, `check:test-path-roots`, `check:changeset-claims`, `check:pending-changeset-literals`, `check:new-line-citations` (0 new citations), `check:doc-fences`, `check:doc-types`, `check:doc-example-ids`, `docs:check-links`, and `node scripts/check-changeset-presence.mjs`: all exit 0. - NOT MEASURED: `check:doc-snippets`, `check:doc-examples`. Reason: their prerequisite is a scoped build of 34 packages, and they exited 2 with "THE GATE COULD NOT RUN". This diff adds no TypeScript fence: the one new block is `plaintext`. CI runs them. - Not run locally: the other `getCellRenderer` consumers (grid, kanban, gallery, tree, report, dashboard, console). No test outside `fields` and `plugin-detail` names `composite` or `record` or reads `listCellRendererTypes`, according to `git grep`. The published surface is byte-unchanged, and CI runs the farm. ## Ablation (reverse verification), after committing the fix `ablation-replace.mjs` put the two table entries back to `JsonCellRenderer` (anchor hit once, blob `6e801a91c25e` became `ff9c729d76ec`). On disk, `composite: JsonCellRenderer` counted 1 and `composite: CompositeCellRenderer` counted 0. The two new pin files then ran `Tests 8 failed | 3 passed (11)`. All 6 contract tests and both record-page pins went red. The 3 green tests are the floor and fallback cases and the `json` / `object` control, which do not depend on the fix. After the restore, the blob equals HEAD `6e801a91c25e` and `git diff HEAD` is empty. No build was needed: the vitest alias resolves `@object-ui/fields` to `src`. Browser look, not a pin: the built `dist` was server-rendered with the built component and fields stylesheets and viewed in Chromium. Each face is one line (20 to 21px high). At 120px and 180px the line ends in an ellipsis (`text-overflow: ellipsis`, `white-space: nowrap`), and the `title` holds the full text. This was not checked in the running console app. ## Acceptance notes - **camelCase keys are not split.** A key such as `unitPrice` reads `UnitPrice`, because `humanizeLabel` is the value convention. Moving `humanizeFieldKey` into `@object-ui/core` is the convergence that the core docstring says needs its own card. Carrier: none. This is noted here, not filed. - **Labels are not translatable.** No sub-field declaration exists, so no i18n key exists either. A spec-side sub-field declaration would be a protocol change and is not proposed here (four-axis reasoning in the report). - **The read-only form face is unchanged.** `ObjectField`'s read-only branch, the form widget, still shows pretty-printed JSON for these types. The card's "Where" named it, but the triage correction routes the record page through the cell table, and this pull request does not change that widget. Carrier: none. Noted, not filed. - The same face now shows in every host that draws from the cell table: grid cells, kanban and gallery cards, tree rows, report cells, the highlights strip and the summary chip. --- _Generated by [Claude Code](https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 055d350 commit db4cb6b

7 files changed

Lines changed: 549 additions & 23 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/fields': patch
3+
---
4+
5+
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.
6+
7+
- **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.
8+
- **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.
9+
- **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.
10+
- **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.
11+
12+
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')`.

‎content/docs/fields/object.mdx‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,7 @@ The object field stores data as parsed JSON:
131131
## Cell Renderer
132132

133133
In tables/grids, an `object` value is rendered by the JSON cell renderer —
134-
`getCellRenderer('object')` resolves to this same component, shared with `json`,
135-
`composite` and `record`:
134+
`getCellRenderer('object')` resolves to this same component, shared with `json`:
136135

137136
```ts
138137
import { JsonCellRenderer } from '@object-ui/fields';
@@ -142,6 +141,24 @@ import { JsonCellRenderer } from '@object-ui/fields';
142141
// A null or empty value renders an em-dash instead.
143142
```
144143

144+
`composite` and `record` values are not drawn as JSON. Their cell — in a grid and
145+
on the record page alike — is one line of labelled sub-values, with the full text
146+
in the `title`:
147+
148+
```plaintext
149+
// composite { width: 10, height: 20 }
150+
Width 10 · Height 20
151+
152+
// record { primary: { name: "A", score: 9 }, backup: { name: "B", score: 7 } }
153+
Primary (Name A · Score 9) · Backup (Name B · Score 7)
154+
```
155+
156+
A label is the humanized key: the field metadata declares no sub-fields to take a
157+
label from. A number sub-value is formatted as the number cell formats it, a boolean
158+
reads as Yes / No in the reader's language, a string reads as itself, and an object or
159+
an array nested inside a sub-value stays compact JSON.
160+
An empty value, `[]`, `{}` and a non-object value read as the JSON cell reads them.
161+
145162
## Schema Validation
146163

147164
For typed object fields, you can define a schema in two formats:

‎packages/fields/src/__tests__/AddressCellRenderer.test.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,9 @@ describe('address display renderer — controls (unchanged surfaces)', () => {
158158

159159
it('leaves genuinely structural types stringifying as JSON', () => {
160160
// `address` moved out of the JSON bucket; `json` / `object` stay in it.
161-
for (const type of ['json', 'object', 'composite', 'record']) {
161+
// `composite` / `record` moved out too (objectui#11697): they draw labelled
162+
// sub-values, pinned in `compositeRecordCell-11697.test.tsx`.
163+
for (const type of ['json', 'object']) {
162164
const { container } = renderThroughDisplayRegistry(type, { a: 1 });
163165
expect(container.textContent).toBe('{"a":1}');
164166
}
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#11697: a `composite` or `record` value read as its stored JSON on
11+
* the record page — `{"width":10,"height":20}` in a monospace face — because
12+
* the cell-renderer table mapped both types to `JsonCellRenderer`. They now
13+
* draw labelled sub-values on one line.
14+
*
15+
* Every case resolves its renderer through `getCellRenderer`, the resolver the
16+
* record page and the grid both call, so a regressed table entry goes red here
17+
* and not only a regressed renderer. The record-page end of the path is pinned
18+
* in `@object-ui/plugin-detail`'s `DetailSection.compositeRecord-11697`.
19+
*
20+
* Values are the platform's own producers where one exists: the showcase Field
21+
* Zoo seeds `f_composite` / `f_record`, and the SQL driver's fidelity test and
22+
* the dogfood field-zoo matrix write a `record` whose entries are scalars.
23+
*/
24+
25+
import { describe, it, expect, afterEach } from 'vitest';
26+
import React from 'react';
27+
import { render, cleanup } from '@testing-library/react';
28+
import type { FieldMetadata } from '@object-ui/types';
29+
import { getCellRenderer, resolveCellRendererType } from '../index';
30+
31+
afterEach(() => cleanup());
32+
33+
/** Resolve + render exactly the way `DetailSection` builds a read-mode value. */
34+
function renderCell(type: string, value: unknown) {
35+
const Renderer = getCellRenderer(resolveCellRendererType({ type }) || type);
36+
const field = { type, name: `f_${type}` } as unknown as FieldMetadata;
37+
return render(<Renderer value={value} field={field} />);
38+
}
39+
40+
/** What assistive technology reads in an element: its text minus `aria-hidden` nodes. */
41+
function spokenText(element: Element): string {
42+
const clone = element.cloneNode(true) as Element;
43+
clone.querySelectorAll('[aria-hidden="true"]').forEach((node) => node.remove());
44+
return (clone.textContent ?? '').trim();
45+
}
46+
47+
/** The `dt` / `dd` pairs directly under one `<dl>`, as spoken. */
48+
function pairsOf(list: Element): Array<[term: string, definition: string]> {
49+
return Array.from(list.querySelectorAll(':scope > div')).map((group) => [
50+
spokenText(group.querySelector(':scope > dt')!),
51+
spokenText(group.querySelector(':scope > dd')!),
52+
]);
53+
}
54+
55+
/** The face's outer `<dl>`; fails loudly when the cell drew something else. */
56+
function outerList(container: HTMLElement): HTMLElement {
57+
const outer = container.firstElementChild as HTMLElement | null;
58+
expect(outer?.tagName, 'the face is a description list').toBe('DL');
59+
return outer!;
60+
}
61+
62+
describe('objectui#11697 — composite and record values render as labelled sub-values', () => {
63+
it('a composite value renders each sub-field as a labelled pair, not as its JSON', () => {
64+
// The Field Zoo seed for `f_composite`, the value the card reported.
65+
const { container } = renderCell('composite', { width: 10, height: 20 });
66+
const outer = outerList(container);
67+
68+
expect(pairsOf(outer)).toEqual([
69+
['Width', '10'],
70+
['Height', '20'],
71+
]);
72+
expect(container.textContent).toBe('Width 10 · Height 20');
73+
expect(container.textContent, 'no stored-JSON signature').not.toMatch(/[{}"]/);
74+
});
75+
76+
it('a record value renders one labelled group per entry name, holding that entry’s pairs', () => {
77+
// The Field Zoo seed for `f_record`.
78+
const { container } = renderCell('record', {
79+
primary: { name: 'A', score: 9 },
80+
backup: { name: 'B', score: 7 },
81+
});
82+
const outer = outerList(container);
83+
84+
const entries = Array.from(outer.querySelectorAll(':scope > div'));
85+
expect(entries.map((entry) => spokenText(entry.querySelector(':scope > dt')!))).toEqual([
86+
'Primary',
87+
'Backup',
88+
]);
89+
const groups = entries.map((entry) => entry.querySelector(':scope > dd > dl')!);
90+
expect(pairsOf(groups[0])).toEqual([
91+
['Name', 'A'],
92+
['Score', '9'],
93+
]);
94+
expect(pairsOf(groups[1])).toEqual([
95+
['Name', 'B'],
96+
['Score', '7'],
97+
]);
98+
expect(container.textContent).toBe('Primary (Name A · Score 9) · Backup (Name B · Score 7)');
99+
expect(container.textContent, 'no stored-JSON signature').not.toMatch(/[{}"]/);
100+
});
101+
102+
it('a record entry that is not a sub-object is drawn as a pair, with no group', () => {
103+
// The shape the SQL driver's fidelity test and the dogfood matrix write.
104+
const { container } = renderCell('record', { home: '+1', work: '+2' });
105+
const outer = outerList(container);
106+
107+
expect(pairsOf(outer)).toEqual([
108+
['Home', '+1'],
109+
['Work', '+2'],
110+
]);
111+
expect(outer.querySelector('dd dl'), 'a scalar entry opens no group').toBeNull();
112+
});
113+
114+
it('a nested object or array inside a sub-value stays compact JSON', () => {
115+
const composite = renderCell('composite', { size: { w: 1 }, tags: ['a', 'b'] });
116+
expect(pairsOf(outerList(composite.container))).toEqual([
117+
['Size', '{"w":1}'],
118+
['Tags', '["a","b"]'],
119+
]);
120+
expect(
121+
composite.container.querySelector('dd dl'),
122+
'a composite never groups: its sub-field holds an object, it is not a map entry',
123+
).toBeNull();
124+
cleanup();
125+
126+
const record = renderCell('record', { primary: { dims: { w: 1 } } });
127+
expect(record.container.textContent).toBe('Primary (Dims {"w":1})');
128+
});
129+
130+
it('each scalar sub-value reads through this package’s face for its type', () => {
131+
const { container } = renderCell('composite', {
132+
total: 1234.5,
133+
active: true,
134+
archived: false,
135+
note: null,
136+
code: 'x-1',
137+
});
138+
const pairs = pairsOf(outerList(container));
139+
140+
// The number is the `number` cell's own text for a field with no `scale`:
141+
// compared against that cell, not against a literal, so the two agree by
142+
// construction rather than by a copy.
143+
const numberCell = render(
144+
React.createElement(getCellRenderer('number'), {
145+
value: 1234.5,
146+
field: { type: 'number', name: 'n' } as FieldMetadata,
147+
}),
148+
);
149+
expect(pairs[0]).toEqual(['Total', numberCell.container.textContent]);
150+
expect(pairs[0][1], 'grouped, natural precision').toBe('1,234.5');
151+
// A boolean is the locale's word, the boolean-as-text face.
152+
expect(pairs[1]).toEqual(['Active', 'Yes']);
153+
expect(pairs[2]).toEqual(['Archived', 'No']);
154+
// A floor member is the shared affordance, with its accessible name.
155+
const noteValue = outerList(container).querySelectorAll(':scope > div > dd')[3];
156+
expect(
157+
noteValue.querySelector('[data-slot="empty-value"]')?.getAttribute('aria-label'),
158+
'an unset sub-value is the shared "No value" affordance',
159+
).toBe('No value');
160+
expect(pairs[4]).toEqual(['Code', 'x-1']);
161+
});
162+
163+
it('the face is one truncated line whose title carries the full text', () => {
164+
const { container } = renderCell('record', {
165+
primary: { name: 'A', score: 9 },
166+
backup: { name: 'B', score: 7 },
167+
});
168+
const outer = outerList(container);
169+
for (const token of ['block', 'max-w-full', 'truncate']) {
170+
expect(outer.classList.contains(token), `the line carries \`${token}\``).toBe(true);
171+
}
172+
expect(outer.getAttribute('title'), 'the hover text is the line itself').toBe(container.textContent);
173+
});
174+
});
175+
176+
describe('objectui#11697 — what does NOT move', () => {
177+
for (const type of ['composite', 'record'] as const) {
178+
it(`\`${type}\`: the floor and the unrecognized shapes answer exactly as the JSON cell does`, () => {
179+
for (const empty of [null, undefined, '']) {
180+
const { container } = renderCell(type, empty);
181+
expect(
182+
container.querySelector('[data-slot="empty-value"]'),
183+
`${type} holding ${JSON.stringify(empty)}: the shared affordance`,
184+
).not.toBeNull();
185+
expect(container.querySelector('dl')).toBeNull();
186+
cleanup();
187+
}
188+
// The literals objectui#8474 and objectui#8481's fence pinned, and a
189+
// string that happens to hold JSON: drawn as it is, never parsed.
190+
for (const [input, text] of [
191+
[[], '[]'],
192+
[{}, '{}'],
193+
['{"width":10}', '{"width":10}'],
194+
] as const) {
195+
const { container } = renderCell(type, input);
196+
expect(container.textContent, `${type} holding ${JSON.stringify(input)}`).toBe(text);
197+
expect(container.querySelector('dl')).toBeNull();
198+
cleanup();
199+
}
200+
});
201+
}
202+
203+
it('`json` and `object` keep the compact JSON face', () => {
204+
for (const type of ['json', 'object']) {
205+
const { container } = renderCell(type, { width: 10, height: 20 });
206+
expect(container.textContent, `${type}: a free-form JSON value reads as JSON`).toBe(
207+
'{"width":10,"height":20}',
208+
);
209+
cleanup();
210+
}
211+
});
212+
});

0 commit comments

Comments
 (0)