Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/11544-group-header-field-options.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -2983,7 +2983,7 @@ const MEMBER_PINS: Record<string, MemberPin> = {
},
'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',
Expand Down
17 changes: 13 additions & 4 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2975,6 +2975,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// 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
Expand All @@ -2993,12 +2994,20 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
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<string | ListColumn> = 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<string, string>();
if (Array.isArray(rawOptions) && rawOptions.length > 0) {
Expand Down
124 changes: 83 additions & 41 deletions packages/plugin-grid/src/__tests__/gridGroupingMembers-8071.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, unknown>,
) {
return render(
<ActionProvider>
Expand All @@ -92,11 +98,23 @@ function renderGrouped(
columns,
grouping,
} as any}
objectFields={objectFields}
/>
</ActionProvider>,
);
}

/** 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());
Expand Down Expand Up @@ -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']);
});
});
Loading