diff --git a/.changeset/11544-group-header-field-options.md b/.changeset/11544-group-header-field-options.md new file mode 100644 index 0000000000..521cb6d244 --- /dev/null +++ b/.changeset/11544-group-header-field-options.md @@ -0,0 +1,21 @@ +--- +'@object-ui/plugin-grid': patch +--- + +A grouped `object-grid` labels its group headers from the object field's +`options` only; a column's `options` no longer relabels them (objectui#11544). + +`ListColumnSchema` (`@objectstack/spec/ui`) is a strict object with no +`options` member, so a view that authors `options` on a column is refused at +publish with `unrecognized_keys`, and `ObjectGridSchema` refuses the column +too. `ObjectGrid`'s group-header label memo (`groupValueFormatter`) read that +key anyway: a column's `options` won over the object field's, and decided both +the header text and, through `order`, the order of the groups. That read is +retired rather than declared upstream; the authors measurement behind the +ruling found no view that writes a column-level `options`. + +**Behaviour change.** A grid handed a column that carries `options` (which +validation refuses) now shows the object field's option labels in the group +headers, or the stored value when the field has no options. A column's +declared `type` still respells a grouped value (a `boolean` column reads +Yes / No), and a grid whose columns carry no `options` is unchanged. diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 83dafcb816..0b66c21955 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -2983,7 +2983,7 @@ const MEMBER_PINS: Record = { }, 'object-grid.grouping': { file: 'packages/plugin-grid/src/__tests__/gridGroupingMembers-8071.test.tsx', - 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).', + 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).', }, 'object-grid.sort': { file: 'packages/plugin-grid/src/__tests__/gridArrayArmOrderby-8973.test.tsx', diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index ba53274d75..004444984f 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -2975,6 +2975,7 @@ export const ObjectGrid: React.FC = ({ // Build a per-field value formatter so group headers display the human // readable label for select/boolean fields rather than the raw value // (e.g. "In Progress" instead of "in_progress", "Yes" instead of "true"). + // A select value's label is the OBJECT FIELD's option label (objectui#11544). const groupValueFormatter = React.useMemo(() => { // [objectui#7217] ONE normalized entry list, shared with the // `useGroupedData` call below. Reading `grouping.fields` raw here threw @@ -2993,12 +2994,20 @@ export const ObjectGrid: React.FC = ({ for (const gf of groupingFields) { const fieldName = gf.field; const objectDefField = objectFields?.[fieldName]; - // Try to find a column override matching this field for type/options - const cols = normalizeColumns(schema.columns) as any[] | undefined; - const colOverride = cols?.find?.((c) => typeof c === 'object' && c?.field === fieldName); + // The authored column for this field may respell its TYPE — `type` is a + // declared `ListColumn` member, and it is how a `"true"` / `"false"` text + // value reads Yes / No. It never supplies the value LABELS + // (objectui#11544): `options` is not a `ListColumn` member, and the + // strict `ListColumnSchema` refuses it at publish, so the labels come + // from the object field's `options` only. The lookup is typed as + // `ListColumn` so a read of an undeclared column member does not compile. + const cols: ReadonlyArray = normalizeColumns(schema.columns) ?? []; + const colOverride = cols.find( + (c): c is ListColumn => typeof c === 'object' && c !== null && c.field === fieldName, + ); const type = colOverride?.type || objectDefField?.type; - const rawOptions = colOverride?.options || objectDefField?.options; + const rawOptions = objectDefField?.options; const optionsMap = new Map(); if (Array.isArray(rawOptions) && rawOptions.length > 0) { diff --git a/packages/plugin-grid/src/__tests__/gridGroupingMembers-8071.test.tsx b/packages/plugin-grid/src/__tests__/gridGroupingMembers-8071.test.tsx index 605363de92..03339dfef8 100644 --- a/packages/plugin-grid/src/__tests__/gridGroupingMembers-8071.test.tsx +++ b/packages/plugin-grid/src/__tests__/gridGroupingMembers-8071.test.tsx @@ -26,26 +26,26 @@ * ⭐ **`grouping` and `columns` ARE NOT DISJOINT, and this file is where that * shows.** `groupValueFormatter` derives the group-header labels from * `schema.grouping` AND `schema.columns` in one memo: the column override for a - * grouped field decides how its value is SPELLED in the header. Slice 16 - * measured the four `object-grid` keys as separable and recorded exactly this - * qualification; the two cases below are it, in behaviour. + * grouped field decides, through its declared `type`, how its value is SPELLED + * in the header. Slice 16 measured the four `object-grid` keys as separable + * and recorded exactly this qualification; the `columns[].type` case below is + * it, in behaviour. * - * ⚠️ **One of those two reads a member `ListColumnSchema` REFUSES.** The memo - * reads `columns[].options`, which is not among `ListColumn`'s fourteen - * declared members — a strict object, so an author who writes it is refused at - * publish with `unrecognized_keys` while this renderer honours it. That is the - * `declared != enforced` split AGENTS.md #0.1 exists to stop, and it is the - * same family objectui#6458 retired from `generateColumns()`'s cell branch; - * `columnReadBoundary-6458` bounds THAT branch to the empty set and this memo - * is outside it. ⛔ It is pinned below AS BEHAVIOUR and reported as a finding — - * ⛔ not repaired here, because the repair is a retirement decision with its own - * authors measurement, not a member pin. + * ⭐ **A select value's header LABEL comes from the OBJECT FIELD's `options`, + * and from nothing a column authors (objectui#11544).** The memo used to read + * `columns[].options` first, which is not among `ListColumn`'s fourteen + * declared members: the strict `ListColumnSchema` refuses it at publish with + * `unrecognized_keys`, while this renderer honoured it. That read is retired, + * not declared upstream; the authors measurement behind the ruling found no + * view that writes a column-level `options`. The label cases below pin the + * field's labels, and the column `options` beside them is the off-contract + * input whose labels must not appear. * * ⛔ What a declaration can never publish: * * - **`order` sorts the group HEADERS by their rendered LABEL, not by the - * stored value.** So the same member produces a different order once a - * column override renames the values it sorts. + * stored value.** So the same member produces a different order once the + * object field's options rename the values it sorts. * - **`collapsed` is a DEFAULT, not a state.** It decides what an untouched * group looks like, and the first click inverts that default rather than * setting it. @@ -78,10 +78,16 @@ const ROWS = [ { id: '3', name: 'Alan', stage: 'won', flag: 'false' }, ]; -/** Render a grouped `object-grid` over inline rows — no host, no dataSource. */ +/** + * Render a grouped `object-grid` over inline rows — no dataSource. An + * `objectFields` catalogue is handed down the way a host hands one down (the + * runtime prop, never authored metadata), which is how the object field's + * `options` reach the grid without a schema read. + */ function renderGrouped( grouping: unknown, columns: unknown[] = [{ field: 'name', label: 'Name' }], + objectFields?: Record, ) { return render( @@ -92,11 +98,23 @@ function renderGrouped( columns, grouping, } as any} + objectFields={objectFields} /> , ); } +/** The object field `stage` as a select whose options relabel both values. */ +const stageField = (labels: { won: string; lost: string }) => ({ + stage: { + type: 'select', + options: [ + { value: 'won', label: labels.won }, + { value: 'lost', label: labels.lost }, + ], + }, +}); + /** Every group header label, in render order. */ const groupLabels = (): string[] => 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 await settled(); expect(groupLabels()).toEqual(['No', 'Yes']); }); +}); - it('⚠️ an UNDECLARED `columns[].options` decides the header label too — pinned as behaviour', async () => { - // `options` is NOT a member of `ListColumnSchema`, which is strict: an - // author who writes it is refused at publish. This memo reads it anyway, - // and the header is where it shows. Pinned as the behaviour that is there, - // reported as a finding, ⛔ not repaired here. +describe('object-grid group-header LABELS come from the object field (objectui#11544)', () => { + it('the object field\'s `options` decide the header label, and a column `options` beside them is not read', async () => { + // The field's labels reach the header. + renderGrouped( + { fields: [{ field: 'stage' }] }, + [{ field: 'stage', label: 'Stage' }], + stageField({ won: 'Closed Won', lost: 'Closed Lost' }), + ); + await settled(); + expect(groupLabels()).toEqual(['Closed Lost', 'Closed Won']); + cleanup(); + // The retirement: `options` is not a `ListColumn` member and the strict + // `ListColumnSchema` refuses it at publish, so a column carrying it is + // off-contract input. Its labels never reach the header; the field's do. + renderGrouped( + { fields: [{ field: 'stage' }] }, + [ + { + field: 'stage', + label: 'Stage', + options: [ + { value: 'won', label: 'Column Won' }, + { value: 'lost', label: 'Column Lost' }, + ], + }, + ], + stageField({ won: 'Closed Won', lost: 'Closed Lost' }), + ); + await settled(); + expect(groupLabels()).toEqual(['Closed Lost', 'Closed Won']); + cleanup(); + // With no field options, the same column `options` relabels nothing: the + // header shows the stored values. renderGrouped({ fields: [{ field: 'stage' }] }, [ { field: 'stage', label: 'Stage', options: [ - { value: 'won', label: 'Closed Won' }, - { value: 'lost', label: 'Closed Lost' }, + { value: 'won', label: 'Column Won' }, + { value: 'lost', label: 'Column Lost' }, ], }, ]); await settled(); - expect(groupLabels()).toEqual(['Closed Lost', 'Closed Won']); - cleanup(); - // The control that makes the row above a reading of the MEMBER rather than - // of the values: the same grouping, the same column, no `options`. - renderGrouped({ fields: [{ field: 'stage' }] }, [{ field: 'stage', label: 'Stage' }]); - await settled(); expect(groupLabels()).toEqual(['lost', 'won']); }); - it('`order` sorts the RENDERED labels, so a column override moves the groups', async () => { + it('`order` sorts the RENDERED labels, so the field\'s options move the groups', async () => { // `lost` < `won` on the stored values; `Closed Won` < `Zeta` on the // rendered ones. Same `order: 'asc'`, different order — which is only - // visible because the two members are read in one memo. - renderGrouped({ fields: [{ field: 'stage', order: 'asc' }] }, [ - { - field: 'stage', - label: 'Stage', - options: [ - { value: 'won', label: 'Closed Won' }, - { value: 'lost', label: 'Zeta' }, - ], - }, - ]); + // visible because `order` sorts what the label memo produced. + renderGrouped( + { fields: [{ field: 'stage', order: 'asc' }] }, + [{ field: 'name', label: 'Name' }], + stageField({ won: 'Closed Won', lost: 'Zeta' }), + ); await settled(); expect(groupLabels()).toEqual(['Closed Won', 'Zeta']); + cleanup(); + // Control: the same `order` without the field's labels sorts the stored + // values, so the row above is a reading of the labels, not of `order`. + renderGrouped({ fields: [{ field: 'stage', order: 'asc' }] }); + await settled(); + expect(groupLabels()).toEqual(['lost', 'won']); }); });