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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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