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
38 changes: 38 additions & 0 deletions .changeset/7179-grid-grouping-projection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
'@object-ui/core': patch
'@object-ui/plugin-grid': patch
'@object-ui/plugin-list': patch
---

Fix: a grid grouped by a field it does not also show as a column no longer collapses
every row into one `(empty)` group (objectui#7179).

`$select` was built from the view's `columns` and nothing else, so a view declaring
`grouping: { fields: [{ field: 'business_unit' }] }` on a field absent from its columns
never asked the server for that field. It was `undefined` on every row by the time
grouping ran, and the grouping label builder — correctly, for a genuinely empty value —
answered `(empty)` for all of them. The result was one collapsible group holding every
record, with no error, no warning and no empty state: a grid that looked like it grouped
and did not, reading as "these records have no value for this field".

The grouping fields are now unioned into the projection, at both places it is built —
`ObjectGrid` when it fetches for itself, and `ListView` when it fetches and hands the
rows down. Lookup grouping fields are unioned into `$expand` as well: a `select` that
fetches a bare foreign key without populating it buckets by raw id instead of by name,
which is a different wrong answer rather than a fix.

Authors do not need to mirror a grouping field in `columns` any more. That was never
required by `@objectstack/spec` — `grouping` is a sibling of `columns`, not a subset of
it — and the neighbouring view kinds (kanban, gantt, timeline) already unioned their
`groupByField` with no column needed. Refusing the configuration at author time was
considered and rejected: it would make the grid the odd one out and reject working
intent that the schema explicitly allows.

The union is guarded, and the guard is as load-bearing as the fix. A `grouping.fields[]`
entry carries a bare string that has never been through column validation, and some
backends answer an unknown `$select` key with an empty result set rather than ignoring
it. Unioned unguarded, a grouping field naming something the object does not declare
would have turned this bug into a strictly worse one — no rows at all, equally silently.
Grouping fields are therefore intersected with the object's declared fields and passed
through the same field-level-security gate as columns and predicate operands before they
reach the query.
4 changes: 4 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ export * from './utils/predicate-record.js';
// The other half of a view's field appetite: the fields its PREDICATES read,
// which the column-derived `$select` never asked the server for.
export * from './utils/predicate-fields.js';
// The THIRD source of a view's field appetite: the fields it GROUPS BY. The
// spec's `grouping` block is a sibling of `columns`, not a subset, so a grid
// may group by a field it never shows (objectui#7179).
export * from './utils/grouping-fields.js';
export * from './utils/normalize-list-view.js';
// The single home for the VALUE fallback prettifier (a stored value becomes a
// display string when nothing resolves it). `@object-ui/fields` and
Expand Down
84 changes: 84 additions & 0 deletions packages/core/src/utils/__tests__/grouping-fields.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* 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#7179 — the grouping-field harvester's own contract.
*
* The two renderer suites (`plugin-grid`'s `groupingProjection-7179` and
* `plugin-list`'s `ListView.groupingProjection-7179`) pin what reaches the
* QUERY. This pins the harvest itself, which is the half those two cannot
* fully reach: a malformed entry that crashes a consumer's own unrelated code
* before the projection is built is invisible to them, and the `null` case
* below is exactly that — `ObjectGrid`'s `groupValueFormatter` memo throws on
* it today, so the only place the harvester's handling of `null` is observable
* is here.
*/
import { describe, it, expect } from 'vitest';
import { collectGroupingFieldRefs } from '../grouping-fields';

describe('collectGroupingFieldRefs (objectui#7179)', () => {
it('harvests the field name from the spec shape', () => {
expect(
collectGroupingFieldRefs({ fields: [{ field: 'business_unit', order: 'asc', collapsed: false }] }),
).toEqual(['business_unit']);
});

it('preserves first-seen order across a multi-level block', () => {
expect(
collectGroupingFieldRefs({ fields: [{ field: 'business_unit' }, { field: 'region' }] }),
).toEqual(['business_unit', 'region']);
});

it('deduplicates a repeated field', () => {
expect(
collectGroupingFieldRefs({ fields: [{ field: 'region' }, { field: 'region' }] }),
).toEqual(['region']);
});

it('trims surrounding whitespace so a padded name is not sent as an unknown key', () => {
expect(collectGroupingFieldRefs({ fields: [{ field: ' region ' }] })).toEqual(['region']);
});

it.each([
['undefined', undefined],
['null', null],
['an empty block', {}],
['a non-array `fields`', { fields: 'business_unit' }],
['an empty `fields`', { fields: [] }],
])('harvests nothing from %s', (_label, input) => {
expect(collectGroupingFieldRefs(input)).toEqual([]);
});

it('contributes nothing for a NULL entry rather than throwing', () => {
// The shape no consumer survives today. The harvester must not be the
// thing that throws, so the crash stays attributable to its real owner.
expect(collectGroupingFieldRefs({ fields: [null, { field: 'region' }] })).toEqual(['region']);
});

it('contributes nothing for an entry with no `field`', () => {
expect(collectGroupingFieldRefs({ fields: [{}, { field: 'region' }] })).toEqual(['region']);
});

it('contributes nothing for a non-string `field`', () => {
expect(collectGroupingFieldRefs({ fields: [{ field: 42 }, { field: 'region' }] })).toEqual(['region']);
});

it('contributes nothing for an empty or whitespace-only `field`', () => {
expect(collectGroupingFieldRefs({ fields: [{ field: '' }, { field: ' ' }] })).toEqual([]);
});

it('REFUSES a bare string entry — the shorthand the spec does not accept', () => {
// `GroupingConfigSchema.fields` is an array of `$strict` OBJECTS. Reading a
// bare string anyway would be the lenient renderer-side alias AGENTS.md
// #0.1 forbids: it fossilizes a second de-facto contract instead of having
// the producer rejected at publish. It also could not work end to end —
// `useGroupedData` reads `f.field` off each entry, so a bare string groups
// by `undefined` no matter what the projection asks for.
expect(collectGroupingFieldRefs({ fields: ['business_unit'] })).toEqual([]);
});
});
79 changes: 79 additions & 0 deletions packages/core/src/utils/grouping-fields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* 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.
*/

/**
* The THIRD half of a view's field appetite (objectui#7179).
*
* A list view projects what it DISPLAYS — `$select` is built from `columns`.
* `predicate-fields.ts` covers the fields a view's PREDICATES read. This covers
* the fields it GROUPS BY, which is a third, independent source of demand: the
* spec's `grouping` block is a sibling of `columns`, not a subset of it, so a
* view may legitimately group by a field it never shows.
*
* ## Why it needed a fix rather than a gate
*
* `GroupingConfigSchema` accepts `grouping` with no matching column, and the
* neighbouring view kinds (kanban / gantt / timeline) already union their
* `groupByField` into the projection with no column required. Grouping by a
* field you do not want on screen is an ordinary thing to want. Refusing it at
* author time would make the grid the odd one out and reject working intent.
*
* ## The failure this closes
*
* With the grouping field absent from `$select` the server never returns it,
* so `useGroupedData` reads `undefined` on every row and `buildSegmentLabel`
* answers `(empty)` for all of them: ONE group holding every record, with no
* error, no warning and no empty state. It reads as "these records have no
* value for this field" — a plausible, wrong, actionable conclusion about the
* data rather than a visible bug in the view.
*
* ## ⛔ THE RESULT IS CANDIDATES, NOT VERIFIED FIELDS — the caller MUST gate it
*
* `GroupingFieldSchema.field` is a bare `z.ZodString`. Nothing in the schema
* requires it to name a field the object declares, and the whole premise of
* this harvest is that it has NOT been through column validation. Some backends
* answer an unknown `$select` key with an EMPTY RESULT SET rather than ignoring
* it — the cloud multi-tenant runtime does exactly that — so a single unknown
* grouping field put in the projection unguarded silently zeroes the whole
* list. That would convert this card's bug (one `(empty)` group holding every
* row) into a strictly worse one (no rows at all, still silent).
*
* So every caller intersects this result with the object's declared fields —
* {@link isProjectableField}, or the caller's equivalent known-field set —
* exactly as {@link collectPredicateFieldRefs}'s callers do, and for the same
* measured reason. Callers additionally FLS-gate it: a grouping field names a
* field just as capable of being denied as a column is, and the projection is
* what goes on the wire (objectui#6898).
*
* @param grouping - The view's `grouping` block in any authored state
* (`undefined`, malformed, or the spec shape). Anything that is not an entry
* carrying a non-empty string `field` contributes nothing, so a malformed
* block yields an empty harvest instead of a plausible wrong name.
* @returns Grouping field names in first-seen order, deduplicated.
*/
export function collectGroupingFieldRefs(grouping: unknown): string[] {
const fields = (grouping as { fields?: unknown } | null | undefined)?.fields;
if (!Array.isArray(fields)) return [];
const out: string[] = [];
const seen = new Set<string>();
for (const entry of fields) {
// The spec shape is `{ field, order, collapsed }`. A bare string is NOT
// accepted here even though it would be a natural shorthand: `grouping` is
// a `$strict` object schema in `@objectstack/spec`, so a bare string is
// off-spec metadata, and reading it anyway would be exactly the lenient
// renderer-side alias AGENTS.md #0.1 forbids — it fossilizes a second
// de-facto contract instead of having the producer rejected at publish.
const name = (entry as { field?: unknown } | null | undefined)?.field;
if (typeof name !== 'string') continue;
const trimmed = name.trim();
if (!trimmed || seen.has(trimmed)) continue;
seen.add(trimmed);
out.push(trimmed);
}
return out;
}
Loading
Loading