Skip to content

Commit b0bf413

Browse files
fix(plugin-grid): group-header labels read the object field's options only; the column options read is retired (objectui#11544) (#11563)
Fixes #11544 Clause-②: no Retire, not widen (triage ruling `5966937083`): a grouped `object-grid` labels its group headers from the object field's `options` only. `ListColumnSchema` declares no `options` and is strict, so the column-level read honoured a key every publish refuses. Session `session_01FjqrwXPfSMkSfkKYDSRkN2`, branch `claude/issue-11544-group-header-field-options`. ## What changed - `packages/plugin-grid/src/ObjectGrid.tsx`, `groupValueFormatter`: the label source `colOverride?.options || objectDefField?.options` is now `objectDefField?.options`. The column lookup stays, because it also supplies `type`, a declared `ListColumn` member (a `boolean` column respells `"true"` / `"false"` as Yes / No). The lookup is now typed as `ListColumn`, with the same `c is ListColumn` guard `maskedGroupingSignature` already uses, instead of `as any[]`. As a result, an undeclared column-member read at this site no longer compiles (proved below). - `packages/plugin-grid/src/__tests__/gridGroupingMembers-8071.test.tsx`: the two cases that pinned `columns[].options` as behaviour are turned to pin the field's labels: - "the object field's `options` decide the header label, and a column `options` beside them is not read". It has three legs. The field's labels reach the header. A column carrying `options` beside the field's still shows the FIELD's labels. The same column with no field options shows the stored values. - "`order` sorts the RENDERED labels, so the field's options move the groups". This is `order: 'asc'` over the field's labels, with a no-field-labels control. - The header note ("pinned below AS BEHAVIOUR and reported as a finding") is rewritten to say the read is retired. `renderGrouped` now takes the host `objectFields` runtime prop, which is how field options reach an inline-rows grid without a schema read. - `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`: the `object-grid.grouping` `pins` descriptor follows the fixture; it had said the read was pinned as behaviour and not repaired. This is the move PR #11540 made to its own descriptor. It is text only: nothing asserts on `pins` content. - `.changeset/11544-group-header-field-options.md`: `@object-ui/plugin-grid` patch. ## Premise, measured on `origin/main` `d0c0c7fe9` (after PR #11554) - `@objectstack/spec` 17.6.0 `ListColumnSchema` has fourteen keys (field, label, width, align, hidden, sortable, resizable, wrap, type, pinned, summary, prefix, link, action). `safeParse({ field, options })` answers `unrecognized_keys` naming `options`, and the control `{ field, type: 'select' }` parses. - objectui `ObjectGridSchema.safeParse` with a column `options` answers `invalid_union` at `columns`, and the same grid with a column `type` parses. - One read site. A grep for `.options` in `ObjectGrid.tsx` finds only the `groupValueFormatter` line reading it off a column; every other hit reads a field. A repo grep for the column-override lookup finds only this memo. Other `col.options` hits (`GridField`, `RecordPickerDialog`, the flow inspector) read different column schemas, not `ListColumn`, and none of them labels a group. - In-repo producers of column `options`: none. The `options` spreads in app-shell build detail-form fields and import targets, not grid columns. - PM hypothesis 1 ("the now-unused column lookup is removed if nothing else in the memo needs it") is half-falsified: `type` needs the lookup, so it stays, typed. ## Tests (HEAD `92bddae`) - Fixture plus parity ledger: `pnpm exec vitest run --maxWorkers=2 packages/plugin-grid/src/__tests__/gridGroupingMembers-8071.test.tsx apps/console/src/__tests__/registry-inputs-spec-parity.test.ts` gives `Test Files 2 passed (2)` and `Tests 265 passed (265)`. - Whole package: `pnpm exec vitest run --maxWorkers=2 packages/plugin-grid/` gives `Test Files 182 passed (182)` and `Tests 1691 passed (1691)`. - `pnpm --filter @object-ui/plugin-grid type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`) exits 0. `--listFiles` on `tsconfig.test.json` lists the fixture once and `ObjectGrid.tsx` once. - Dependency closure: `pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-grid^...' build` exits 0. ## Ablation (one-shot, run from the committed fix through objectstack's `scripts/ablation-replace.mjs` in wrap mode) - **Behaviour.** Anchor `const rawOptions = objectDefField?.options;`, replaced with `const rawOptions = (colOverride as any)?.options || objectDefField?.options;` (anchor count 1 to 0, blob `004444984f55` to `316153fc4622`). Predicted: the label case goes red and everything else stays green. Observed: `Tests 1 failed | 9 passed (10)`, `expected [ 'Column Lost', 'Column Won' ] to deeply equal [ 'Closed Lost', 'Closed Won' ]`. Restore: blob equals HEAD, and `git diff HEAD` is empty. - **Type.** The same anchor, replaced with `const rawOptions = colOverride?.options || objectDefField?.options;`. `tsc --noEmit` gives `error TS2339: Property 'options' does not exist on type` (the `ListColumn` object shape). Restore: blob equals HEAD. - The `order` case stays green under the behaviour ablation by design. It carries no column `options`; it pins that `order` sorts the field's labels, which both trees do. ## Gates (HEAD `92bddae`, each exit 0) - `pnpm check:control-bytes`: `check-control-bytes: OK`. - `pnpm check:new-line-citations`: `VERDICT new-cross-file-line-citations: 0 new citation(s)`. - `node scripts/check-changeset-presence.mjs`: `3 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)`. - `node scripts/check-changeset-no-major.mjs`, `check-changeset-fixed.mjs` and `check-changeset-overwrite.mjs`: all report OK. - `pnpm check:changeset-claims` is report-only. It names six pending changesets that mention `ObjectGrid.tsx` or the parity ledger. I re-read each paragraph, and none makes a claim about group-header labels or column `options`. - `pnpm check:pending-changeset-literals`: `No test source names a pending changeset`. - `pnpm check:test-path-roots`, `node scripts/check-vi-mock-specifiers.mjs`, `node scripts/check-vi-mock-inherit.mjs`, `pnpm check:spec-symbols` and `node scripts/check-type-check-coverage.mjs`: all report OK. ## Narrowings (declared) - **Lint (CI-owned).** A narrowed run over the three changed source files. The population is from eslint's own config: `eslint --print-config` resolves all three, so none is ignored. `--format json` counts 3 files. `eslint.config.js` sets no `parserOptions.project` / `projectService`, so type-aware linting is off, and this diff cannot move an untouched file's verdict. In package form (inline config honoured, as `pnpm lint` runs it): 0 errors. In root `--no-inline-config` form: 1 error, `react-hooks/static-components`, at the annotated `AuthoredEmptyIcon` site. It is identical at BASE (BASE 1 error / 210 warnings, head 1 / 209). - Build-reading gates (`check:published-dist`, `check:node-esm-load`, `check:phantom-deps` and the rest) and the full sharded `pnpm test` belong to CI. This diff adds no import, export or dependency. ## Acceptance notes - The parity ledger's slice-17 history paragraph, which says the group-label memo reads `columns[].options`, is left as written. It records what that slice did, the way a changelog entry does. Only the live `pins` descriptor changed. - No doc page or README described the column-options read (grep of `content/docs` and `packages/plugin-grid/README.md`), so there is no doc edit. - The parity ledger is a shared file. This PR edits one string in its `object-grid.grouping` entry. - objectstack#21464 types `columns` by reference once its `.objectui-sha` covers this landing. That stays on objectstack#21464, per triage. --- _Generated by [Claude Code](https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 072b7e8 commit b0bf413

4 files changed

Lines changed: 118 additions & 46 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@object-ui/plugin-grid': patch
3+
---
4+
5+
A grouped `object-grid` labels its group headers from the object field's
6+
`options` only; a column's `options` no longer relabels them (objectui#11544).
7+
8+
`ListColumnSchema` (`@objectstack/spec/ui`) is a strict object with no
9+
`options` member, so a view that authors `options` on a column is refused at
10+
publish with `unrecognized_keys`, and `ObjectGridSchema` refuses the column
11+
too. `ObjectGrid`'s group-header label memo (`groupValueFormatter`) read that
12+
key anyway: a column's `options` won over the object field's, and decided both
13+
the header text and, through `order`, the order of the groups. That read is
14+
retired rather than declared upstream; the authors measurement behind the
15+
ruling found no view that writes a column-level `options`.
16+
17+
**Behaviour change.** A grid handed a column that carries `options` (which
18+
validation refuses) now shows the object field's option labels in the group
19+
headers, or the stored value when the field has no options. A column's
20+
declared `type` still respells a grouped value (a `boolean` column reads
21+
Yes / No), and a grid whose columns carry no `options` is unchanged.

‎apps/console/src/__tests__/registry-inputs-spec-parity.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2974,7 +2974,7 @@ const MEMBER_PINS: Record<string, MemberPin> = {
29742974
},
29752975
'object-grid.grouping': {
29762976
file: 'packages/plugin-grid/src/__tests__/gridGroupingMembers-8071.test.tsx',
2977-
pins: 'The one member `GroupingConfigSchema` declares (`fields`) and the three its strict entries declare (`field`, `order`, `collapsed`), at the group headers the grid paints AND at the projection it asks the server for. `order` sorts the headers by their RENDERED LABEL rather than by the stored value, `collapsed` is a DEFAULT that the first click inverts rather than a state, `fields` is ORDERED so the second entry nests inside the first and each level sorts on its own entry, and an unusable entry (a `null` hole, the bare-string shorthand the strict schema refuses, a blank name) is DROPPED — never coerced, never fatal — with the usable one still grouping. The query half is what keeps the screen honest: a grouped field the columns never mention is UNIONED into `$select`, because without it the server never returns it and ONE `(empty)` group holds every record (objectui#7179); the entries it groups by and the entries it projects are asserted as the same set. ⭐ THE SHARED MEMO with `columns` is pinned here, and it is what makes these two keys non-disjoint: `groupValueFormatter` derives the header labels from `schema.grouping` AND `schema.columns`, so `columns[].type: \'boolean\'` respells a grouped `"true"`/`"false"` as Yes/No, and because `order` sorts the rendered labels a column override MOVES the groups. ⚠️ That memo also reads `columns[].options`, which `ListColumnSchema` does not declare and, being strict, REFUSES at publish — the `declared != enforced` split, same family objectui#6458 retired from the cell branch, outside the region `columnReadBoundary-6458` bounds. Pinned as behaviour with its own absent-member control and handed back as a finding, ⛔ not repaired here. New file (objectui#8071 slice 17).',
2977+
pins: 'The one member `GroupingConfigSchema` declares (`fields`) and the three its strict entries declare (`field`, `order`, `collapsed`), at the group headers the grid paints AND at the projection it asks the server for. `order` sorts the headers by their RENDERED LABEL rather than by the stored value, `collapsed` is a DEFAULT that the first click inverts rather than a state, `fields` is ORDERED so the second entry nests inside the first and each level sorts on its own entry, and an unusable entry (a `null` hole, the bare-string shorthand the strict schema refuses, a blank name) is DROPPED — never coerced, never fatal — with the usable one still grouping. The query half is what keeps the screen honest: a grouped field the columns never mention is UNIONED into `$select`, because without it the server never returns it and ONE `(empty)` group holds every record (objectui#7179); the entries it groups by and the entries it projects are asserted as the same set. ⭐ THE SHARED MEMO with `columns` is pinned here, and it is what makes these two keys non-disjoint: `groupValueFormatter` derives the header labels from `schema.grouping` AND `schema.columns`, so `columns[].type: \'boolean\'` respells a grouped `"true"`/`"false"` as Yes/No. A select value\'s header LABEL comes from the object field\'s `options` only, and because `order` sorts the rendered labels the field\'s options MOVE the groups. The memo\'s former read of `columns[].options`, which `ListColumnSchema` does not declare and, being strict, REFUSES at publish, is retired (objectui#11544): a column carrying `options` beside the field\'s is pinned as not read, with a no-field-options control. New file (objectui#8071 slice 17).',
29782978
},
29792979
'object-grid.sort': {
29802980
file: 'packages/plugin-grid/src/__tests__/gridArrayArmOrderby-8973.test.tsx',

‎packages/plugin-grid/src/ObjectGrid.tsx‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2975,6 +2975,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
29752975
// Build a per-field value formatter so group headers display the human
29762976
// readable label for select/boolean fields rather than the raw value
29772977
// (e.g. "In Progress" instead of "in_progress", "Yes" instead of "true").
2978+
// A select value's label is the OBJECT FIELD's option label (objectui#11544).
29782979
const groupValueFormatter = React.useMemo(() => {
29792980
// [objectui#7217] ONE normalized entry list, shared with the
29802981
// `useGroupedData` call below. Reading `grouping.fields` raw here threw
@@ -2993,12 +2994,20 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
29932994
for (const gf of groupingFields) {
29942995
const fieldName = gf.field;
29952996
const objectDefField = objectFields?.[fieldName];
2996-
// Try to find a column override matching this field for type/options
2997-
const cols = normalizeColumns(schema.columns) as any[] | undefined;
2998-
const colOverride = cols?.find?.((c) => typeof c === 'object' && c?.field === fieldName);
2997+
// The authored column for this field may respell its TYPE — `type` is a
2998+
// declared `ListColumn` member, and it is how a `"true"` / `"false"` text
2999+
// value reads Yes / No. It never supplies the value LABELS
3000+
// (objectui#11544): `options` is not a `ListColumn` member, and the
3001+
// strict `ListColumnSchema` refuses it at publish, so the labels come
3002+
// from the object field's `options` only. The lookup is typed as
3003+
// `ListColumn` so a read of an undeclared column member does not compile.
3004+
const cols: ReadonlyArray<string | ListColumn> = normalizeColumns(schema.columns) ?? [];
3005+
const colOverride = cols.find(
3006+
(c): c is ListColumn => typeof c === 'object' && c !== null && c.field === fieldName,
3007+
);
29993008

30003009
const type = colOverride?.type || objectDefField?.type;
3001-
const rawOptions = colOverride?.options || objectDefField?.options;
3010+
const rawOptions = objectDefField?.options;
30023011

30033012
const optionsMap = new Map<string, string>();
30043013
if (Array.isArray(rawOptions) && rawOptions.length > 0) {

‎packages/plugin-grid/src/__tests__/gridGroupingMembers-8071.test.tsx‎

Lines changed: 83 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -26,26 +26,26 @@
2626
* ⭐ **`grouping` and `columns` ARE NOT DISJOINT, and this file is where that
2727
* shows.** `groupValueFormatter` derives the group-header labels from
2828
* `schema.grouping` AND `schema.columns` in one memo: the column override for a
29-
* grouped field decides how its value is SPELLED in the header. Slice 16
30-
* measured the four `object-grid` keys as separable and recorded exactly this
31-
* qualification; the two cases below are it, in behaviour.
29+
* grouped field decides, through its declared `type`, how its value is SPELLED
30+
* in the header. Slice 16 measured the four `object-grid` keys as separable
31+
* and recorded exactly this qualification; the `columns[].type` case below is
32+
* it, in behaviour.
3233
*
33-
* ⚠️ **One of those two reads a member `ListColumnSchema` REFUSES.** The memo
34-
* reads `columns[].options`, which is not among `ListColumn`'s fourteen
35-
* declared members — a strict object, so an author who writes it is refused at
36-
* publish with `unrecognized_keys` while this renderer honours it. That is the
37-
* `declared != enforced` split AGENTS.md #0.1 exists to stop, and it is the
38-
* same family objectui#6458 retired from `generateColumns()`'s cell branch;
39-
* `columnReadBoundary-6458` bounds THAT branch to the empty set and this memo
40-
* is outside it. ⛔ It is pinned below AS BEHAVIOUR and reported as a finding —
41-
* ⛔ not repaired here, because the repair is a retirement decision with its own
42-
* authors measurement, not a member pin.
34+
* ⭐ **A select value's header LABEL comes from the OBJECT FIELD's `options`,
35+
* and from nothing a column authors (objectui#11544).** The memo used to read
36+
* `columns[].options` first, which is not among `ListColumn`'s fourteen
37+
* declared members: the strict `ListColumnSchema` refuses it at publish with
38+
* `unrecognized_keys`, while this renderer honoured it. That read is retired,
39+
* not declared upstream; the authors measurement behind the ruling found no
40+
* view that writes a column-level `options`. The label cases below pin the
41+
* field's labels, and the column `options` beside them is the off-contract
42+
* input whose labels must not appear.
4343
*
4444
* ⛔ What a declaration can never publish:
4545
*
4646
* - **`order` sorts the group HEADERS by their rendered LABEL, not by the
47-
* stored value.** So the same member produces a different order once a
48-
* column override renames the values it sorts.
47+
* stored value.** So the same member produces a different order once the
48+
* object field's options rename the values it sorts.
4949
* - **`collapsed` is a DEFAULT, not a state.** It decides what an untouched
5050
* group looks like, and the first click inverts that default rather than
5151
* setting it.
@@ -78,10 +78,16 @@ const ROWS = [
7878
{ id: '3', name: 'Alan', stage: 'won', flag: 'false' },
7979
];
8080

81-
/** Render a grouped `object-grid` over inline rows — no host, no dataSource. */
81+
/**
82+
* Render a grouped `object-grid` over inline rows — no dataSource. An
83+
* `objectFields` catalogue is handed down the way a host hands one down (the
84+
* runtime prop, never authored metadata), which is how the object field's
85+
* `options` reach the grid without a schema read.
86+
*/
8287
function renderGrouped(
8388
grouping: unknown,
8489
columns: unknown[] = [{ field: 'name', label: 'Name' }],
90+
objectFields?: Record<string, unknown>,
8591
) {
8692
return render(
8793
<ActionProvider>
@@ -92,11 +98,23 @@ function renderGrouped(
9298
columns,
9399
grouping,
94100
} as any}
101+
objectFields={objectFields}
95102
/>
96103
</ActionProvider>,
97104
);
98105
}
99106

107+
/** The object field `stage` as a select whose options relabel both values. */
108+
const stageField = (labels: { won: string; lost: string }) => ({
109+
stage: {
110+
type: 'select',
111+
options: [
112+
{ value: 'won', label: labels.won },
113+
{ value: 'lost', label: labels.lost },
114+
],
115+
},
116+
});
117+
100118
/** Every group header label, in render order. */
101119
const groupLabels = (): string[] =>
102120
Array.from(document.querySelectorAll('.group-label')).map((el) => (el.textContent ?? '').trim());
@@ -256,47 +274,71 @@ describe('object-grid `grouping` × `columns` — the SHARED memo (objectui#8071
256274
await settled();
257275
expect(groupLabels()).toEqual(['No', 'Yes']);
258276
});
277+
});
259278

260-
it('⚠️ an UNDECLARED `columns[].options` decides the header label too — pinned as behaviour', async () => {
261-
// `options` is NOT a member of `ListColumnSchema`, which is strict: an
262-
// author who writes it is refused at publish. This memo reads it anyway,
263-
// and the header is where it shows. Pinned as the behaviour that is there,
264-
// reported as a finding, ⛔ not repaired here.
279+
describe('object-grid group-header LABELS come from the object field (objectui#11544)', () => {
280+
it('the object field\'s `options` decide the header label, and a column `options` beside them is not read', async () => {
281+
// The field's labels reach the header.
282+
renderGrouped(
283+
{ fields: [{ field: 'stage' }] },
284+
[{ field: 'stage', label: 'Stage' }],
285+
stageField({ won: 'Closed Won', lost: 'Closed Lost' }),
286+
);
287+
await settled();
288+
expect(groupLabels()).toEqual(['Closed Lost', 'Closed Won']);
289+
cleanup();
290+
// The retirement: `options` is not a `ListColumn` member and the strict
291+
// `ListColumnSchema` refuses it at publish, so a column carrying it is
292+
// off-contract input. Its labels never reach the header; the field's do.
293+
renderGrouped(
294+
{ fields: [{ field: 'stage' }] },
295+
[
296+
{
297+
field: 'stage',
298+
label: 'Stage',
299+
options: [
300+
{ value: 'won', label: 'Column Won' },
301+
{ value: 'lost', label: 'Column Lost' },
302+
],
303+
},
304+
],
305+
stageField({ won: 'Closed Won', lost: 'Closed Lost' }),
306+
);
307+
await settled();
308+
expect(groupLabels()).toEqual(['Closed Lost', 'Closed Won']);
309+
cleanup();
310+
// With no field options, the same column `options` relabels nothing: the
311+
// header shows the stored values.
265312
renderGrouped({ fields: [{ field: 'stage' }] }, [
266313
{
267314
field: 'stage',
268315
label: 'Stage',
269316
options: [
270-
{ value: 'won', label: 'Closed Won' },
271-
{ value: 'lost', label: 'Closed Lost' },
317+
{ value: 'won', label: 'Column Won' },
318+
{ value: 'lost', label: 'Column Lost' },
272319
],
273320
},
274321
]);
275322
await settled();
276-
expect(groupLabels()).toEqual(['Closed Lost', 'Closed Won']);
277-
cleanup();
278-
// The control that makes the row above a reading of the MEMBER rather than
279-
// of the values: the same grouping, the same column, no `options`.
280-
renderGrouped({ fields: [{ field: 'stage' }] }, [{ field: 'stage', label: 'Stage' }]);
281-
await settled();
282323
expect(groupLabels()).toEqual(['lost', 'won']);
283324
});
284325

285-
it('`order` sorts the RENDERED labels, so a column override moves the groups', async () => {
326+
it('`order` sorts the RENDERED labels, so the field\'s options move the groups', async () => {
286327
// `lost` < `won` on the stored values; `Closed Won` < `Zeta` on the
287328
// rendered ones. Same `order: 'asc'`, different order — which is only
288-
// visible because the two members are read in one memo.
289-
renderGrouped({ fields: [{ field: 'stage', order: 'asc' }] }, [
290-
{
291-
field: 'stage',
292-
label: 'Stage',
293-
options: [
294-
{ value: 'won', label: 'Closed Won' },
295-
{ value: 'lost', label: 'Zeta' },
296-
],
297-
},
298-
]);
329+
// visible because `order` sorts what the label memo produced.
330+
renderGrouped(
331+
{ fields: [{ field: 'stage', order: 'asc' }] },
332+
[{ field: 'name', label: 'Name' }],
333+
stageField({ won: 'Closed Won', lost: 'Zeta' }),
334+
);
299335
await settled();
300336
expect(groupLabels()).toEqual(['Closed Won', 'Zeta']);
337+
cleanup();
338+
// Control: the same `order` without the field's labels sorts the stored
339+
// values, so the row above is a reading of the labels, not of `order`.
340+
renderGrouped({ fields: [{ field: 'stage', order: 'asc' }] });
341+
await settled();
342+
expect(groupLabels()).toEqual(['lost', 'won']);
301343
});
302344
});

0 commit comments

Comments
 (0)