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
13 changes: 13 additions & 0 deletions .changeset/7217-grouping-null-entry-guard.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
---
'@object-ui/plugin-grid': patch
---

Grid: a malformed entry in `grouping.fields[]` no longer crashes the whole grid.

A `null` (or `undefined`) hole in the array was dereferenced with no guard at
two places — `ObjectGrid`'s `groupValueFormatter` memo and `useGroupedData`'s
`buildLevel` — throwing `TypeError: Cannot read properties of null (reading
'field')` during render, before any projection was built. Both sites now read
one normalized entry list, admitting exactly the entries `collectGroupingFieldRefs`
harvests into the query projection, so the usable grouping levels still group
and an unusable entry is simply dropped rather than taking the view down.
15 changes: 11 additions & 4 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,7 @@ import { resolveConditionalFormatting, buildExpandFields, buildExportFileName, c
import { usePermissions } from '@object-ui/permissions';
import { ChevronRight, ChevronDown, ChevronLeft, ChevronsLeft, ChevronsRight, Download, Rows2, Rows3, Rows4, AlignJustify, Type, Hash, Calendar, CheckSquare, User, Tag, Clock, Loader2 } from 'lucide-react';
import { useRowColor } from './useRowColor';
import { useGroupedData } from './useGroupedData';
import { useGroupedData, usableGroupingFields } from './useGroupedData';
import { GroupRow } from './GroupRow';
import { useColumnSummary } from './useColumnSummary';
import { resolveRowCrudAffordances, resolveRowRecordCrudAffordance } from './rowCrudAffordances';
Expand DownExpand Up@@ -2034,14 +2034,21 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// readable label for select/boolean fields rather than the raw value
// (e.g. "In Progress" instead of "in_progress", "Yes" instead of "true").
const groupValueFormatter = React.useMemo(() => {
const grouping = schema.grouping;
if (!grouping?.fields?.length) return undefined;
// [objectui#7217] ONE normalized entry list, shared with the
// `useGroupedData` call below. Reading `grouping.fields` raw here threw
// `TypeError: Cannot read properties of null (reading 'field')` on a null
// hole — the whole grid gone, during render, before any projection was
// built. `usableGroupingFields` admits exactly the entries
// `collectGroupingFieldRefs` harvests into the projection, so the grid can
// never group by an entry the query never asked for.
const groupingFields = usableGroupingFields(schema.grouping?.fields);
if (!groupingFields.length) return undefined;

// Per-field { value -> label } lookup, plus a per-field type so we can
// handle booleans / dates / users without dedicated option lists.
const lookup = new Map<string, { type?: string; options?: Map<string, string> }>();

for (const gf of grouping.fields) {
for (const gf of groupingFields) {
const fieldName = gf.field;
const objectDefField = objectSchema?.fields?.[fieldName];
// Try to find a column override matching this field for type/options
Expand Down
171 changes: 171 additions & 0 deletions packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
/**
* 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#7217 — a `null` entry in `grouping.fields[]` must not take the grid
* down.
*
* ## The defect
*
* `ObjectGrid`'s `groupValueFormatter` memo walked `grouping.fields` and read
* `gf.field` off every entry with no guard, so a single `null` hole threw
* `TypeError: Cannot read properties of null (reading 'field')` during render
* — the whole grid, gone, before any projection was built.
*
* `useGroupedData` is a SECOND dereference site of the same list (`const f =
* fields[depth]` then `f.field` / `f.order` / `f.collapsed`), so guarding the
* memo alone only moves the crash one call downstream. Both sites now read one
* normalized entry list — `usableGroupingFields` — and this file pins both:
* ablating either guard on its own turns these tests red.
*
* ## Why a guard, not a schema change (the reachability measurement)
*
* Author-time validation ALREADY refuses a null entry — `GroupingConfigSchema`
* types `fields` as an array of `$strict` objects, so `{ fields: [null] }`
* fails with `invalid_type` at `fields.0`, and objectui's own `ListViewSchema`
* inherits that by reference. The last two `it`s below measure exactly that,
* so the claim is checked rather than asserted in prose.
*
* That makes this a defensive guard rather than a validation gap — but the
* crash is still live, because NOTHING ON THE RENDER PATH RUNS THAT VALIDATOR.
* `ObjectGrid` reads `schema.grouping` straight off its props; `@object-ui/core`'s
* `validateSchema` is structural and never looks at the `grouping` key. A
* runtime-composed or generated schema therefore reaches the memo unparsed,
* which is the reachable path this pin closes.
*
* ## Test-source note
*
* The root vitest config aliases `@object-ui/*` to each package's `src`, and
* this file imports `../ObjectGrid` relatively, so no build step stands
* between the edit and the run — the ablation recorded in the PR body reads
* source directly.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';

import { ObjectGrid } from '../ObjectGrid';
import { registerAllFields } from '@object-ui/fields';
import { ActionProvider } from '@object-ui/react';
import { GroupingConfigSchema } from '@objectstack/spec/ui';
import { ListViewSchema } from '@object-ui/types/zod';

registerAllFields();

const ROWS = [
{ id: '1', name: 'Row 1', active: true },
{ id: '2', name: 'Row 2', active: false },
{ id: '3', name: 'Row 3', active: true },
];

/**
* Mount a grid whose `grouping.fields` is exactly `fields`.
*
* `data.provider: 'value'` keeps the rows inline, so `useGroupedData` runs over
* a NON-EMPTY array: the hook's dereference is only reached once there is a row
* to bucket, and a pin mounted on empty data would leave that half unmeasured.
*/
function renderGrid(fields: unknown[]) {
const schema: any = {
type: 'object-grid',
objectName: 'test_object',
columns: [
{ field: 'name', label: 'Name' },
{ field: 'active', label: 'Active', type: 'boolean' },
],
data: { provider: 'value', items: ROWS },
grouping: { fields },
};
return render(
<ActionProvider>
<ObjectGrid schema={schema} />
</ActionProvider>,
);
}

const groupLabels = () =>
Array.from(document.querySelectorAll('.group-label')).map((el) => el.textContent);

afterEach(() => cleanup());

describe('ObjectGrid — a null `grouping.fields[]` entry never crashes the grid (objectui#7217)', () => {
// ── PIN 1: THE DEFECT ───────────────────────────────────────────────────
it('renders instead of throwing when the only grouping entry is null', async () => {
expect(
() => renderGrid([null]),
'a null hole in `grouping.fields[]` threw a TypeError out of render and '
+ 'took the whole grid down before any projection was built',
).not.toThrow();
await waitFor(() => expect(document.body.textContent).toContain('Row 1'));
expect(document.body.textContent).toContain('Row 2');
expect(document.body.textContent).toContain('Row 3');
});

it('renders instead of throwing when the only grouping entry is undefined', async () => {
// Same defect class as `null`: a hole a trailing comma or a sparse
// generator leaves behind, which no dereference can survive.
expect(() => renderGrid([undefined])).not.toThrow();
await waitFor(() => expect(document.body.textContent).toContain('Row 1'));
});

// ── PIN 2: THE SURVIVING ENTRY STILL GROUPS ─────────────────────────────
// The guard must DROP the unusable entry, not abandon grouping altogether —
// otherwise a single hole silently degrades a working grouped view into a
// flat one, which is the objectui#7179 class of silent wrong answer.
it('still groups by the usable entry when a null precedes it', async () => {
expect(() => renderGrid([null, { field: 'active' }])).not.toThrow();
await waitFor(() => expect(groupLabels().length).toBeGreaterThan(0));
expect(groupLabels()).toEqual(expect.arrayContaining(['Yes', 'No']));
});

it('still groups by the usable entry when a null follows it at a deeper level', async () => {
// The second entry is the NESTED level, so this reaches `buildLevel`'s
// recursion rather than only its depth-0 call.
expect(() => renderGrid([{ field: 'active' }, null])).not.toThrow();
await waitFor(() => expect(groupLabels().length).toBeGreaterThan(0));
expect(groupLabels()).toEqual(expect.arrayContaining(['Yes', 'No']));
});

// ── PIN 3: REACHABILITY — the validator refuses it, the render path never runs one ──
it('author-time validation already refuses a null entry (`@objectstack/spec`)', () => {
const refused = GroupingConfigSchema.safeParse({ fields: [null] });
expect(refused.success).toBe(false);
expect(refused.success === false && refused.error.issues[0]).toMatchObject({
code: 'invalid_type',
path: ['fields', 0],
});
// Positive control: the well-formed entry the same schema accepts, so a
// schema that refused EVERYTHING could not pass the assertion above.
expect(GroupingConfigSchema.safeParse({ fields: [{ field: 'active' }] }).success).toBe(true);
});

it("objectui's own `ListViewSchema` inherits that refusal by reference", () => {
const refused = ListViewSchema.safeParse({
type: 'list-view',
objectName: 'test_object',
grouping: { fields: [null] },
});
expect(refused.success).toBe(false);
expect(
refused.success === false
&& refused.error.issues.some((i) => i.path.join('.') === 'grouping.fields.0'),
'`grouping` is imported into `ListViewSchema` from the spec by reference, so '
+ 'the entry-shape refusal must arrive with it',
).toBe(true);
// Positive control: the same payload with a well-formed entry is accepted,
// so the refusal above is about the null entry and not about the envelope.
expect(
ListViewSchema.safeParse({
type: 'list-view',
objectName: 'test_object',
grouping: { fields: [{ field: 'active' }] },
}).success,
).toBe(true);
});
});
71 changes: 67 additions & 4 deletions packages/plugin-grid/src/useGroupedData.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -207,6 +207,64 @@ function compareGroups(a: string, b: string, order: 'asc' | 'desc'): number {
return order === 'desc' ? -cmp : cmp;
}

/**
* One entry of the spec's `grouping.fields[]` as AUTHORED — `field` plus the
* optional `order` / `collapsed`.
*
* ⚠️ NOT the same type as `@object-ui/components`' `GroupingFieldEntry`, which
* is the grouping EDITOR's fully-populated value shape and requires `order`
* and `collapsed`. This one is `z.input` of the spec schema, where both carry
* defaults and are therefore optional, so the two are structurally different
* and each keeps its own name (objectui#6273 — one authority per exported
* name; a shared spelling for two shapes is the collision that gate exists to
* catch).
*/
export type UsableGroupingField = NonNullable<GroupingConfig['fields']>[number];

/**
* The `grouping.fields[]` entries a grid can actually group by (objectui#7217).
*
* ## Why this exists
*
* `grouping` is authored JSON and reaches the renderer unparsed — `ObjectGrid`
* reads `schema.grouping` straight off its props and `@object-ui/core`'s
* `validateSchema` is structural and never looks at the key. A `null` hole in
* the array (a trailing comma, a sparse generator, an agent-written block) was
* therefore dereferenced twice with no guard: once by `ObjectGrid`'s
* `groupValueFormatter` memo and once by this hook's `buildLevel`. Both threw
* `TypeError: Cannot read properties of null (reading 'field')` and took the
* whole grid down during render.
*
* ## The admission rule is the harvester's, deliberately
*
* An entry is usable when it is an object carrying a non-empty string `field`
* — exactly the entries `collectGroupingFieldRefs` (`@object-ui/core`) harvests
* into the projection. Keeping the two sets equal is the point: an entry the
* grid grouped by but the projection ignored would be fetched as `undefined`
* on every row and bucket every record into one `(empty)` group, which is the
* silent wrong answer objectui#7179 closed. This is a defensive normalizer,
* NOT a lenient alias — no off-spec spelling is taught to mean anything here;
* unusable entries are dropped, never coerced.
*
* ## Dropping the entry, not the grouping
*
* One bad entry must not flatten a working grouped view: the usable entries
* still group, at the levels they still occupy.
*
* @param fields - `grouping.fields` in any authored state.
* @returns The usable entries, in order, with their `order` / `collapsed`
* intact — the harvester answers with field NAMES, which is why this cannot
* simply route through it.
*/
export function usableGroupingFields(fields: unknown): UsableGroupingField[] {
if (!Array.isArray(fields)) return [];
return fields.filter((entry): entry is UsableGroupingField => {
if (entry === null || typeof entry !== 'object') return false;
const name = (entry as { field?: unknown }).field;
return typeof name === 'string' && name.trim() !== '';
});
}

/**
* Hook that groups a flat data array by the fields specified in GroupingConfig.
*
Expand All@@ -227,14 +285,19 @@ export function useGroupedData(
aggregations?: AggregationConfig[],
formatValue?: GroupValueFormatter,
): UseGroupedDataResult {
const fields = config?.fields;
const isGrouped = !!(fields && fields.length > 0);
// [objectui#7217] The SAME normalized list `ObjectGrid`'s formatter memo
// reads. Memoized on the raw array rather than on `config`: hosts rebuild
// the `{ grouping }` object literal every render, so keying on `config`
// would hand `groups` a fresh array identity on every render.
const rawFields = config?.fields;
const fields = useMemo(() => usableGroupingFields(rawFields), [rawFields]);
const isGrouped = fields.length > 0;

// Track which group keys have been explicitly toggled by the user.
const [toggledKeys, setToggledKeys] = useState<Record<string, boolean>>({});

const groups: GroupEntry[] = useMemo(() => {
if (!isGrouped || !fields) return [];
if (!isGrouped) return [];

/**
* Recursively build a tree of groups for the slice of rows at the current
Expand DownExpand Up@@ -308,7 +371,7 @@ export function useGroupedData(
const lastSegment = key.split('__').pop() || '';
const depthMatch = /^(\d+):/.exec(lastSegment);
const depth = depthMatch ? Number(depthMatch[1]) : 0;
const fieldDefault = !!fields?.[depth]?.collapsed;
const fieldDefault = !!fields[depth]?.collapsed;
return {
...prev,
[key]: prev[key] !== undefined ? !prev[key] : !fieldDefault,
Expand Down
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
13 changes: 13 additions & 0 deletions .changeset/7217-grouping-null-entry-guard.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
---
'@object-ui/plugin-grid': patch
---

Grid: a malformed entry in `grouping.fields[]` no longer crashes the whole grid.

A `null` (or `undefined`) hole in the array was dereferenced with no guard at
two places — `ObjectGrid`'s `groupValueFormatter` memo and `useGroupedData`'s
`buildLevel` — throwing `TypeError: Cannot read properties of null (reading
'field')` during render, before any projection was built. Both sites now read
one normalized entry list, admitting exactly the entries `collectGroupingFieldRefs`
harvests into the query projection, so the usable grouping levels still group
and an unusable entry is simply dropped rather than taking the view down.
15 changes: 11 additions & 4 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,7 @@ import { resolveConditionalFormatting, buildExpandFields, buildExportFileName, c
import { usePermissions } from '@object-ui/permissions';
import { ChevronRight, ChevronDown, ChevronLeft, ChevronsLeft, ChevronsRight, Download, Rows2, Rows3, Rows4, AlignJustify, Type, Hash, Calendar, CheckSquare, User, Tag, Clock, Loader2 } from 'lucide-react';
import { useRowColor } from './useRowColor';
import { useGroupedData } from './useGroupedData';
import { useGroupedData, usableGroupingFields } from './useGroupedData';
import { GroupRow } from './GroupRow';
import { useColumnSummary } from './useColumnSummary';
import { resolveRowCrudAffordances, resolveRowRecordCrudAffordance } from './rowCrudAffordances';
Expand DownExpand Up@@ -2034,14 +2034,21 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// readable label for select/boolean fields rather than the raw value
// (e.g. "In Progress" instead of "in_progress", "Yes" instead of "true").
const groupValueFormatter = React.useMemo(() => {
const grouping = schema.grouping;
if (!grouping?.fields?.length) return undefined;
// [objectui#7217] ONE normalized entry list, shared with the
// `useGroupedData` call below. Reading `grouping.fields` raw here threw
// `TypeError: Cannot read properties of null (reading 'field')` on a null
// hole — the whole grid gone, during render, before any projection was
// built. `usableGroupingFields` admits exactly the entries
// `collectGroupingFieldRefs` harvests into the projection, so the grid can
// never group by an entry the query never asked for.
const groupingFields = usableGroupingFields(schema.grouping?.fields);
if (!groupingFields.length) return undefined;

// Per-field { value -> label } lookup, plus a per-field type so we can
// handle booleans / dates / users without dedicated option lists.
const lookup = new Map<string, { type?: string; options?: Map<string, string> }>();

for (const gf of grouping.fields) {
for (const gf of groupingFields) {
const fieldName = gf.field;
const objectDefField = objectSchema?.fields?.[fieldName];
// Try to find a column override matching this field for type/options
Expand Down
171 changes: 171 additions & 0 deletions packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
/**
* 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#7217 — a `null` entry in `grouping.fields[]` must not take the grid
* down.
*
* ## The defect
*
* `ObjectGrid`'s `groupValueFormatter` memo walked `grouping.fields` and read
* `gf.field` off every entry with no guard, so a single `null` hole threw
* `TypeError: Cannot read properties of null (reading 'field')` during render
* — the whole grid, gone, before any projection was built.
*
* `useGroupedData` is a SECOND dereference site of the same list (`const f =
* fields[depth]` then `f.field` / `f.order` / `f.collapsed`), so guarding the
* memo alone only moves the crash one call downstream. Both sites now read one
* normalized entry list — `usableGroupingFields` — and this file pins both:
* ablating either guard on its own turns these tests red.
*
* ## Why a guard, not a schema change (the reachability measurement)
*
* Author-time validation ALREADY refuses a null entry — `GroupingConfigSchema`
* types `fields` as an array of `$strict` objects, so `{ fields: [null] }`
* fails with `invalid_type` at `fields.0`, and objectui's own `ListViewSchema`
* inherits that by reference. The last two `it`s below measure exactly that,
* so the claim is checked rather than asserted in prose.
*
* That makes this a defensive guard rather than a validation gap — but the
* crash is still live, because NOTHING ON THE RENDER PATH RUNS THAT VALIDATOR.
* `ObjectGrid` reads `schema.grouping` straight off its props; `@object-ui/core`'s
* `validateSchema` is structural and never looks at the `grouping` key. A
* runtime-composed or generated schema therefore reaches the memo unparsed,
* which is the reachable path this pin closes.
*
* ## Test-source note
*
* The root vitest config aliases `@object-ui/*` to each package's `src`, and
* this file imports `../ObjectGrid` relatively, so no build step stands
* between the edit and the run — the ablation recorded in the PR body reads
* source directly.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';

import { ObjectGrid } from '../ObjectGrid';
import { registerAllFields } from '@object-ui/fields';
import { ActionProvider } from '@object-ui/react';
import { GroupingConfigSchema } from '@objectstack/spec/ui';
import { ListViewSchema } from '@object-ui/types/zod';

registerAllFields();

const ROWS = [
{ id: '1', name: 'Row 1', active: true },
{ id: '2', name: 'Row 2', active: false },
{ id: '3', name: 'Row 3', active: true },
];

/**
* Mount a grid whose `grouping.fields` is exactly `fields`.
*
* `data.provider: 'value'` keeps the rows inline, so `useGroupedData` runs over
* a NON-EMPTY array: the hook's dereference is only reached once there is a row
* to bucket, and a pin mounted on empty data would leave that half unmeasured.
*/
function renderGrid(fields: unknown[]) {
const schema: any = {
type: 'object-grid',
objectName: 'test_object',
columns: [
{ field: 'name', label: 'Name' },
{ field: 'active', label: 'Active', type: 'boolean' },
],
data: { provider: 'value', items: ROWS },
grouping: { fields },
};
return render(
<ActionProvider>
<ObjectGrid schema={schema} />
</ActionProvider>,
);
}

const groupLabels = () =>
Array.from(document.querySelectorAll('.group-label')).map((el) => el.textContent);

afterEach(() => cleanup());

describe('ObjectGrid — a null `grouping.fields[]` entry never crashes the grid (objectui#7217)', () => {
// ── PIN 1: THE DEFECT ───────────────────────────────────────────────────
it('renders instead of throwing when the only grouping entry is null', async () => {
expect(
() => renderGrid([null]),
'a null hole in `grouping.fields[]` threw a TypeError out of render and '
+ 'took the whole grid down before any projection was built',
).not.toThrow();
await waitFor(() => expect(document.body.textContent).toContain('Row 1'));
expect(document.body.textContent).toContain('Row 2');
expect(document.body.textContent).toContain('Row 3');
});

it('renders instead of throwing when the only grouping entry is undefined', async () => {
// Same defect class as `null`: a hole a trailing comma or a sparse
// generator leaves behind, which no dereference can survive.
expect(() => renderGrid([undefined])).not.toThrow();
await waitFor(() => expect(document.body.textContent).toContain('Row 1'));
});

// ── PIN 2: THE SURVIVING ENTRY STILL GROUPS ─────────────────────────────
// The guard must DROP the unusable entry, not abandon grouping altogether —
// otherwise a single hole silently degrades a working grouped view into a
// flat one, which is the objectui#7179 class of silent wrong answer.
it('still groups by the usable entry when a null precedes it', async () => {
expect(() => renderGrid([null, { field: 'active' }])).not.toThrow();
await waitFor(() => expect(groupLabels().length).toBeGreaterThan(0));
expect(groupLabels()).toEqual(expect.arrayContaining(['Yes', 'No']));
});

it('still groups by the usable entry when a null follows it at a deeper level', async () => {
// The second entry is the NESTED level, so this reaches `buildLevel`'s
// recursion rather than only its depth-0 call.
expect(() => renderGrid([{ field: 'active' }, null])).not.toThrow();
await waitFor(() => expect(groupLabels().length).toBeGreaterThan(0));
expect(groupLabels()).toEqual(expect.arrayContaining(['Yes', 'No']));
});

// ── PIN 3: REACHABILITY — the validator refuses it, the render path never runs one ──
it('author-time validation already refuses a null entry (`@objectstack/spec`)', () => {
const refused = GroupingConfigSchema.safeParse({ fields: [null] });
expect(refused.success).toBe(false);
expect(refused.success === false && refused.error.issues[0]).toMatchObject({
code: 'invalid_type',
path: ['fields', 0],
});
// Positive control: the well-formed entry the same schema accepts, so a
// schema that refused EVERYTHING could not pass the assertion above.
expect(GroupingConfigSchema.safeParse({ fields: [{ field: 'active' }] }).success).toBe(true);
});

it("objectui's own `ListViewSchema` inherits that refusal by reference", () => {
const refused = ListViewSchema.safeParse({
type: 'list-view',
objectName: 'test_object',
grouping: { fields: [null] },
});
expect(refused.success).toBe(false);
expect(
refused.success === false
&& refused.error.issues.some((i) => i.path.join('.') === 'grouping.fields.0'),
'`grouping` is imported into `ListViewSchema` from the spec by reference, so '
+ 'the entry-shape refusal must arrive with it',
).toBe(true);
// Positive control: the same payload with a well-formed entry is accepted,
// so the refusal above is about the null entry and not about the envelope.
expect(
ListViewSchema.safeParse({
type: 'list-view',
objectName: 'test_object',
grouping: { fields: [{ field: 'active' }] },
}).success,
).toBe(true);
});
});
71 changes: 67 additions & 4 deletions packages/plugin-grid/src/useGroupedData.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -207,6 +207,64 @@ function compareGroups(a: string, b: string, order: 'asc' | 'desc'): number {
return order === 'desc' ? -cmp : cmp;
}

/**
* One entry of the spec's `grouping.fields[]` as AUTHORED — `field` plus the
* optional `order` / `collapsed`.
*
* ⚠️ NOT the same type as `@object-ui/components`' `GroupingFieldEntry`, which
* is the grouping EDITOR's fully-populated value shape and requires `order`
* and `collapsed`. This one is `z.input` of the spec schema, where both carry
* defaults and are therefore optional, so the two are structurally different
* and each keeps its own name (objectui#6273 — one authority per exported
* name; a shared spelling for two shapes is the collision that gate exists to
* catch).
*/
export type UsableGroupingField = NonNullable<GroupingConfig['fields']>[number];

/**
* The `grouping.fields[]` entries a grid can actually group by (objectui#7217).
*
* ## Why this exists
*
* `grouping` is authored JSON and reaches the renderer unparsed — `ObjectGrid`
* reads `schema.grouping` straight off its props and `@object-ui/core`'s
* `validateSchema` is structural and never looks at the key. A `null` hole in
* the array (a trailing comma, a sparse generator, an agent-written block) was
* therefore dereferenced twice with no guard: once by `ObjectGrid`'s
* `groupValueFormatter` memo and once by this hook's `buildLevel`. Both threw
* `TypeError: Cannot read properties of null (reading 'field')` and took the
* whole grid down during render.
*
* ## The admission rule is the harvester's, deliberately
*
* An entry is usable when it is an object carrying a non-empty string `field`
* — exactly the entries `collectGroupingFieldRefs` (`@object-ui/core`) harvests
* into the projection. Keeping the two sets equal is the point: an entry the
* grid grouped by but the projection ignored would be fetched as `undefined`
* on every row and bucket every record into one `(empty)` group, which is the
* silent wrong answer objectui#7179 closed. This is a defensive normalizer,
* NOT a lenient alias — no off-spec spelling is taught to mean anything here;
* unusable entries are dropped, never coerced.
*
* ## Dropping the entry, not the grouping
*
* One bad entry must not flatten a working grouped view: the usable entries
* still group, at the levels they still occupy.
*
* @param fields - `grouping.fields` in any authored state.
* @returns The usable entries, in order, with their `order` / `collapsed`
* intact — the harvester answers with field NAMES, which is why this cannot
* simply route through it.
*/
export function usableGroupingFields(fields: unknown): UsableGroupingField[] {
if (!Array.isArray(fields)) return [];
return fields.filter((entry): entry is UsableGroupingField => {
if (entry === null || typeof entry !== 'object') return false;
const name = (entry as { field?: unknown }).field;
return typeof name === 'string' && name.trim() !== '';
});
}

/**
* Hook that groups a flat data array by the fields specified in GroupingConfig.
*
Expand All@@ -227,14 +285,19 @@ export function useGroupedData(
aggregations?: AggregationConfig[],
formatValue?: GroupValueFormatter,
): UseGroupedDataResult {
const fields = config?.fields;
const isGrouped = !!(fields && fields.length > 0);
// [objectui#7217] The SAME normalized list `ObjectGrid`'s formatter memo
// reads. Memoized on the raw array rather than on `config`: hosts rebuild
// the `{ grouping }` object literal every render, so keying on `config`
// would hand `groups` a fresh array identity on every render.
const rawFields = config?.fields;
const fields = useMemo(() => usableGroupingFields(rawFields), [rawFields]);
const isGrouped = fields.length > 0;

// Track which group keys have been explicitly toggled by the user.
const [toggledKeys, setToggledKeys] = useState<Record<string, boolean>>({});

const groups: GroupEntry[] = useMemo(() => {
if (!isGrouped || !fields) return [];
if (!isGrouped) return [];

/**
* Recursively build a tree of groups for the slice of rows at the current
Expand DownExpand Up@@ -308,7 +371,7 @@ export function useGroupedData(
const lastSegment = key.split('__').pop() || '';
const depthMatch = /^(\d+):/.exec(lastSegment);
const depth = depthMatch ? Number(depthMatch[1]) : 0;
const fieldDefault = !!fields?.[depth]?.collapsed;
const fieldDefault = !!fields[depth]?.collapsed;
return {
...prev,
[key]: prev[key] !== undefined ? !prev[key] : !fieldDefault,
Expand Down
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
13 changes: 13 additions & 0 deletions .changeset/7217-grouping-null-entry-guard.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
---
'@object-ui/plugin-grid': patch
---

Grid: a malformed entry in `grouping.fields[]` no longer crashes the whole grid.

A `null` (or `undefined`) hole in the array was dereferenced with no guard at
two places — `ObjectGrid`'s `groupValueFormatter` memo and `useGroupedData`'s
`buildLevel` — throwing `TypeError: Cannot read properties of null (reading
'field')` during render, before any projection was built. Both sites now read
one normalized entry list, admitting exactly the entries `collectGroupingFieldRefs`
harvests into the query projection, so the usable grouping levels still group
and an unusable entry is simply dropped rather than taking the view down.
15 changes: 11 additions & 4 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,7 @@ import { resolveConditionalFormatting, buildExpandFields, buildExportFileName, c
import { usePermissions } from '@object-ui/permissions';
import { ChevronRight, ChevronDown, ChevronLeft, ChevronsLeft, ChevronsRight, Download, Rows2, Rows3, Rows4, AlignJustify, Type, Hash, Calendar, CheckSquare, User, Tag, Clock, Loader2 } from 'lucide-react';
import { useRowColor } from './useRowColor';
import { useGroupedData } from './useGroupedData';
import { useGroupedData, usableGroupingFields } from './useGroupedData';
import { GroupRow } from './GroupRow';
import { useColumnSummary } from './useColumnSummary';
import { resolveRowCrudAffordances, resolveRowRecordCrudAffordance } from './rowCrudAffordances';
Expand DownExpand Up@@ -2034,14 +2034,21 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// readable label for select/boolean fields rather than the raw value
// (e.g. "In Progress" instead of "in_progress", "Yes" instead of "true").
const groupValueFormatter = React.useMemo(() => {
const grouping = schema.grouping;
if (!grouping?.fields?.length) return undefined;
// [objectui#7217] ONE normalized entry list, shared with the
// `useGroupedData` call below. Reading `grouping.fields` raw here threw
// `TypeError: Cannot read properties of null (reading 'field')` on a null
// hole — the whole grid gone, during render, before any projection was
// built. `usableGroupingFields` admits exactly the entries
// `collectGroupingFieldRefs` harvests into the projection, so the grid can
// never group by an entry the query never asked for.
const groupingFields = usableGroupingFields(schema.grouping?.fields);
if (!groupingFields.length) return undefined;

// Per-field { value -> label } lookup, plus a per-field type so we can
// handle booleans / dates / users without dedicated option lists.
const lookup = new Map<string, { type?: string; options?: Map<string, string> }>();

for (const gf of grouping.fields) {
for (const gf of groupingFields) {
const fieldName = gf.field;
const objectDefField = objectSchema?.fields?.[fieldName];
// Try to find a column override matching this field for type/options
Expand Down
171 changes: 171 additions & 0 deletions packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
/**
* 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#7217 — a `null` entry in `grouping.fields[]` must not take the grid
* down.
*
* ## The defect
*
* `ObjectGrid`'s `groupValueFormatter` memo walked `grouping.fields` and read
* `gf.field` off every entry with no guard, so a single `null` hole threw
* `TypeError: Cannot read properties of null (reading 'field')` during render
* — the whole grid, gone, before any projection was built.
*
* `useGroupedData` is a SECOND dereference site of the same list (`const f =
* fields[depth]` then `f.field` / `f.order` / `f.collapsed`), so guarding the
* memo alone only moves the crash one call downstream. Both sites now read one
* normalized entry list — `usableGroupingFields` — and this file pins both:
* ablating either guard on its own turns these tests red.
*
* ## Why a guard, not a schema change (the reachability measurement)
*
* Author-time validation ALREADY refuses a null entry — `GroupingConfigSchema`
* types `fields` as an array of `$strict` objects, so `{ fields: [null] }`
* fails with `invalid_type` at `fields.0`, and objectui's own `ListViewSchema`
* inherits that by reference. The last two `it`s below measure exactly that,
* so the claim is checked rather than asserted in prose.
*
* That makes this a defensive guard rather than a validation gap — but the
* crash is still live, because NOTHING ON THE RENDER PATH RUNS THAT VALIDATOR.
* `ObjectGrid` reads `schema.grouping` straight off its props; `@object-ui/core`'s
* `validateSchema` is structural and never looks at the `grouping` key. A
* runtime-composed or generated schema therefore reaches the memo unparsed,
* which is the reachable path this pin closes.
*
* ## Test-source note
*
* The root vitest config aliases `@object-ui/*` to each package's `src`, and
* this file imports `../ObjectGrid` relatively, so no build step stands
* between the edit and the run — the ablation recorded in the PR body reads
* source directly.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';

import { ObjectGrid } from '../ObjectGrid';
import { registerAllFields } from '@object-ui/fields';
import { ActionProvider } from '@object-ui/react';
import { GroupingConfigSchema } from '@objectstack/spec/ui';
import { ListViewSchema } from '@object-ui/types/zod';

registerAllFields();

const ROWS = [
{ id: '1', name: 'Row 1', active: true },
{ id: '2', name: 'Row 2', active: false },
{ id: '3', name: 'Row 3', active: true },
];

/**
* Mount a grid whose `grouping.fields` is exactly `fields`.
*
* `data.provider: 'value'` keeps the rows inline, so `useGroupedData` runs over
* a NON-EMPTY array: the hook's dereference is only reached once there is a row
* to bucket, and a pin mounted on empty data would leave that half unmeasured.
*/
function renderGrid(fields: unknown[]) {
const schema: any = {
type: 'object-grid',
objectName: 'test_object',
columns: [
{ field: 'name', label: 'Name' },
{ field: 'active', label: 'Active', type: 'boolean' },
],
data: { provider: 'value', items: ROWS },
grouping: { fields },
};
return render(
<ActionProvider>
<ObjectGrid schema={schema} />
</ActionProvider>,
);
}

const groupLabels = () =>
Array.from(document.querySelectorAll('.group-label')).map((el) => el.textContent);

afterEach(() => cleanup());

describe('ObjectGrid — a null `grouping.fields[]` entry never crashes the grid (objectui#7217)', () => {
// ── PIN 1: THE DEFECT ───────────────────────────────────────────────────
it('renders instead of throwing when the only grouping entry is null', async () => {
expect(
() => renderGrid([null]),
'a null hole in `grouping.fields[]` threw a TypeError out of render and '
+ 'took the whole grid down before any projection was built',
).not.toThrow();
await waitFor(() => expect(document.body.textContent).toContain('Row 1'));
expect(document.body.textContent).toContain('Row 2');
expect(document.body.textContent).toContain('Row 3');
});

it('renders instead of throwing when the only grouping entry is undefined', async () => {
// Same defect class as `null`: a hole a trailing comma or a sparse
// generator leaves behind, which no dereference can survive.
expect(() => renderGrid([undefined])).not.toThrow();
await waitFor(() => expect(document.body.textContent).toContain('Row 1'));
});

// ── PIN 2: THE SURVIVING ENTRY STILL GROUPS ─────────────────────────────
// The guard must DROP the unusable entry, not abandon grouping altogether —
// otherwise a single hole silently degrades a working grouped view into a
// flat one, which is the objectui#7179 class of silent wrong answer.
it('still groups by the usable entry when a null precedes it', async () => {
expect(() => renderGrid([null, { field: 'active' }])).not.toThrow();
await waitFor(() => expect(groupLabels().length).toBeGreaterThan(0));
expect(groupLabels()).toEqual(expect.arrayContaining(['Yes', 'No']));
});

it('still groups by the usable entry when a null follows it at a deeper level', async () => {
// The second entry is the NESTED level, so this reaches `buildLevel`'s
// recursion rather than only its depth-0 call.
expect(() => renderGrid([{ field: 'active' }, null])).not.toThrow();
await waitFor(() => expect(groupLabels().length).toBeGreaterThan(0));
expect(groupLabels()).toEqual(expect.arrayContaining(['Yes', 'No']));
});

// ── PIN 3: REACHABILITY — the validator refuses it, the render path never runs one ──
it('author-time validation already refuses a null entry (`@objectstack/spec`)', () => {
const refused = GroupingConfigSchema.safeParse({ fields: [null] });
expect(refused.success).toBe(false);
expect(refused.success === false && refused.error.issues[0]).toMatchObject({
code: 'invalid_type',
path: ['fields', 0],
});
// Positive control: the well-formed entry the same schema accepts, so a
// schema that refused EVERYTHING could not pass the assertion above.
expect(GroupingConfigSchema.safeParse({ fields: [{ field: 'active' }] }).success).toBe(true);
});

it("objectui's own `ListViewSchema` inherits that refusal by reference", () => {
const refused = ListViewSchema.safeParse({
type: 'list-view',
objectName: 'test_object',
grouping: { fields: [null] },
});
expect(refused.success).toBe(false);
expect(
refused.success === false
&& refused.error.issues.some((i) => i.path.join('.') === 'grouping.fields.0'),
'`grouping` is imported into `ListViewSchema` from the spec by reference, so '
+ 'the entry-shape refusal must arrive with it',
).toBe(true);
// Positive control: the same payload with a well-formed entry is accepted,
// so the refusal above is about the null entry and not about the envelope.
expect(
ListViewSchema.safeParse({
type: 'list-view',
objectName: 'test_object',
grouping: { fields: [{ field: 'active' }] },
}).success,
).toBe(true);
});
});
71 changes: 67 additions & 4 deletions packages/plugin-grid/src/useGroupedData.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -207,6 +207,64 @@ function compareGroups(a: string, b: string, order: 'asc' | 'desc'): number {
return order === 'desc' ? -cmp : cmp;
}

/**
* One entry of the spec's `grouping.fields[]` as AUTHORED — `field` plus the
* optional `order` / `collapsed`.
*
* ⚠️ NOT the same type as `@object-ui/components`' `GroupingFieldEntry`, which
* is the grouping EDITOR's fully-populated value shape and requires `order`
* and `collapsed`. This one is `z.input` of the spec schema, where both carry
* defaults and are therefore optional, so the two are structurally different
* and each keeps its own name (objectui#6273 — one authority per exported
* name; a shared spelling for two shapes is the collision that gate exists to
* catch).
*/
export type UsableGroupingField = NonNullable<GroupingConfig['fields']>[number];

/**
* The `grouping.fields[]` entries a grid can actually group by (objectui#7217).
*
* ## Why this exists
*
* `grouping` is authored JSON and reaches the renderer unparsed — `ObjectGrid`
* reads `schema.grouping` straight off its props and `@object-ui/core`'s
* `validateSchema` is structural and never looks at the key. A `null` hole in
* the array (a trailing comma, a sparse generator, an agent-written block) was
* therefore dereferenced twice with no guard: once by `ObjectGrid`'s
* `groupValueFormatter` memo and once by this hook's `buildLevel`. Both threw
* `TypeError: Cannot read properties of null (reading 'field')` and took the
* whole grid down during render.
*
* ## The admission rule is the harvester's, deliberately
*
* An entry is usable when it is an object carrying a non-empty string `field`
* — exactly the entries `collectGroupingFieldRefs` (`@object-ui/core`) harvests
* into the projection. Keeping the two sets equal is the point: an entry the
* grid grouped by but the projection ignored would be fetched as `undefined`
* on every row and bucket every record into one `(empty)` group, which is the
* silent wrong answer objectui#7179 closed. This is a defensive normalizer,
* NOT a lenient alias — no off-spec spelling is taught to mean anything here;
* unusable entries are dropped, never coerced.
*
* ## Dropping the entry, not the grouping
*
* One bad entry must not flatten a working grouped view: the usable entries
* still group, at the levels they still occupy.
*
* @param fields - `grouping.fields` in any authored state.
* @returns The usable entries, in order, with their `order` / `collapsed`
* intact — the harvester answers with field NAMES, which is why this cannot
* simply route through it.
*/
export function usableGroupingFields(fields: unknown): UsableGroupingField[] {
if (!Array.isArray(fields)) return [];
return fields.filter((entry): entry is UsableGroupingField => {
if (entry === null || typeof entry !== 'object') return false;
const name = (entry as { field?: unknown }).field;
return typeof name === 'string' && name.trim() !== '';
});
}

/**
* Hook that groups a flat data array by the fields specified in GroupingConfig.
*
Expand All@@ -227,14 +285,19 @@ export function useGroupedData(
aggregations?: AggregationConfig[],
formatValue?: GroupValueFormatter,
): UseGroupedDataResult {
const fields = config?.fields;
const isGrouped = !!(fields && fields.length > 0);
// [objectui#7217] The SAME normalized list `ObjectGrid`'s formatter memo
// reads. Memoized on the raw array rather than on `config`: hosts rebuild
// the `{ grouping }` object literal every render, so keying on `config`
// would hand `groups` a fresh array identity on every render.
const rawFields = config?.fields;
const fields = useMemo(() => usableGroupingFields(rawFields), [rawFields]);
const isGrouped = fields.length > 0;

// Track which group keys have been explicitly toggled by the user.
const [toggledKeys, setToggledKeys] = useState<Record<string, boolean>>({});

const groups: GroupEntry[] = useMemo(() => {
if (!isGrouped || !fields) return [];
if (!isGrouped) return [];

/**
* Recursively build a tree of groups for the slice of rows at the current
Expand DownExpand Up@@ -308,7 +371,7 @@ export function useGroupedData(
const lastSegment = key.split('__').pop() || '';
const depthMatch = /^(\d+):/.exec(lastSegment);
const depth = depthMatch ? Number(depthMatch[1]) : 0;
const fieldDefault = !!fields?.[depth]?.collapsed;
const fieldDefault = !!fields[depth]?.collapsed;
return {
...prev,
[key]: prev[key] !== undefined ? !prev[key] : !fieldDefault,
Expand Down
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
13 changes: 13 additions & 0 deletions .changeset/7217-grouping-null-entry-guard.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
---
'@object-ui/plugin-grid': patch
---

Grid: a malformed entry in `grouping.fields[]` no longer crashes the whole grid.

A `null` (or `undefined`) hole in the array was dereferenced with no guard at
two places — `ObjectGrid`'s `groupValueFormatter` memo and `useGroupedData`'s
`buildLevel` — throwing `TypeError: Cannot read properties of null (reading
'field')` during render, before any projection was built. Both sites now read
one normalized entry list, admitting exactly the entries `collectGroupingFieldRefs`
harvests into the query projection, so the usable grouping levels still group
and an unusable entry is simply dropped rather than taking the view down.
15 changes: 11 additions & 4 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,7 @@ import { resolveConditionalFormatting, buildExpandFields, buildExportFileName, c
import { usePermissions } from '@object-ui/permissions';
import { ChevronRight, ChevronDown, ChevronLeft, ChevronsLeft, ChevronsRight, Download, Rows2, Rows3, Rows4, AlignJustify, Type, Hash, Calendar, CheckSquare, User, Tag, Clock, Loader2 } from 'lucide-react';
import { useRowColor } from './useRowColor';
import { useGroupedData } from './useGroupedData';
import { useGroupedData, usableGroupingFields } from './useGroupedData';
import { GroupRow } from './GroupRow';
import { useColumnSummary } from './useColumnSummary';
import { resolveRowCrudAffordances, resolveRowRecordCrudAffordance } from './rowCrudAffordances';
Expand DownExpand Up@@ -2034,14 +2034,21 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// readable label for select/boolean fields rather than the raw value
// (e.g. "In Progress" instead of "in_progress", "Yes" instead of "true").
const groupValueFormatter = React.useMemo(() => {
const grouping = schema.grouping;
if (!grouping?.fields?.length) return undefined;
// [objectui#7217] ONE normalized entry list, shared with the
// `useGroupedData` call below. Reading `grouping.fields` raw here threw
// `TypeError: Cannot read properties of null (reading 'field')` on a null
// hole — the whole grid gone, during render, before any projection was
// built. `usableGroupingFields` admits exactly the entries
// `collectGroupingFieldRefs` harvests into the projection, so the grid can
// never group by an entry the query never asked for.
const groupingFields = usableGroupingFields(schema.grouping?.fields);
if (!groupingFields.length) return undefined;

// Per-field { value -> label } lookup, plus a per-field type so we can
// handle booleans / dates / users without dedicated option lists.
const lookup = new Map<string, { type?: string; options?: Map<string, string> }>();

for (const gf of grouping.fields) {
for (const gf of groupingFields) {
const fieldName = gf.field;
const objectDefField = objectSchema?.fields?.[fieldName];
// Try to find a column override matching this field for type/options
Expand Down
171 changes: 171 additions & 0 deletions packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
/**
* 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#7217 — a `null` entry in `grouping.fields[]` must not take the grid
* down.
*
* ## The defect
*
* `ObjectGrid`'s `groupValueFormatter` memo walked `grouping.fields` and read
* `gf.field` off every entry with no guard, so a single `null` hole threw
* `TypeError: Cannot read properties of null (reading 'field')` during render
* — the whole grid, gone, before any projection was built.
*
* `useGroupedData` is a SECOND dereference site of the same list (`const f =
* fields[depth]` then `f.field` / `f.order` / `f.collapsed`), so guarding the
* memo alone only moves the crash one call downstream. Both sites now read one
* normalized entry list — `usableGroupingFields` — and this file pins both:
* ablating either guard on its own turns these tests red.
*
* ## Why a guard, not a schema change (the reachability measurement)
*
* Author-time validation ALREADY refuses a null entry — `GroupingConfigSchema`
* types `fields` as an array of `$strict` objects, so `{ fields: [null] }`
* fails with `invalid_type` at `fields.0`, and objectui's own `ListViewSchema`
* inherits that by reference. The last two `it`s below measure exactly that,
* so the claim is checked rather than asserted in prose.
*
* That makes this a defensive guard rather than a validation gap — but the
* crash is still live, because NOTHING ON THE RENDER PATH RUNS THAT VALIDATOR.
* `ObjectGrid` reads `schema.grouping` straight off its props; `@object-ui/core`'s
* `validateSchema` is structural and never looks at the `grouping` key. A
* runtime-composed or generated schema therefore reaches the memo unparsed,
* which is the reachable path this pin closes.
*
* ## Test-source note
*
* The root vitest config aliases `@object-ui/*` to each package's `src`, and
* this file imports `../ObjectGrid` relatively, so no build step stands
* between the edit and the run — the ablation recorded in the PR body reads
* source directly.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';

import { ObjectGrid } from '../ObjectGrid';
import { registerAllFields } from '@object-ui/fields';
import { ActionProvider } from '@object-ui/react';
import { GroupingConfigSchema } from '@objectstack/spec/ui';
import { ListViewSchema } from '@object-ui/types/zod';

registerAllFields();

const ROWS = [
{ id: '1', name: 'Row 1', active: true },
{ id: '2', name: 'Row 2', active: false },
{ id: '3', name: 'Row 3', active: true },
];

/**
* Mount a grid whose `grouping.fields` is exactly `fields`.
*
* `data.provider: 'value'` keeps the rows inline, so `useGroupedData` runs over
* a NON-EMPTY array: the hook's dereference is only reached once there is a row
* to bucket, and a pin mounted on empty data would leave that half unmeasured.
*/
function renderGrid(fields: unknown[]) {
const schema: any = {
type: 'object-grid',
objectName: 'test_object',
columns: [
{ field: 'name', label: 'Name' },
{ field: 'active', label: 'Active', type: 'boolean' },
],
data: { provider: 'value', items: ROWS },
grouping: { fields },
};
return render(
<ActionProvider>
<ObjectGrid schema={schema} />
</ActionProvider>,
);
}

const groupLabels = () =>
Array.from(document.querySelectorAll('.group-label')).map((el) => el.textContent);

afterEach(() => cleanup());

describe('ObjectGrid — a null `grouping.fields[]` entry never crashes the grid (objectui#7217)', () => {
// ── PIN 1: THE DEFECT ───────────────────────────────────────────────────
it('renders instead of throwing when the only grouping entry is null', async () => {
expect(
() => renderGrid([null]),
'a null hole in `grouping.fields[]` threw a TypeError out of render and '
+ 'took the whole grid down before any projection was built',
).not.toThrow();
await waitFor(() => expect(document.body.textContent).toContain('Row 1'));
expect(document.body.textContent).toContain('Row 2');
expect(document.body.textContent).toContain('Row 3');
});

it('renders instead of throwing when the only grouping entry is undefined', async () => {
// Same defect class as `null`: a hole a trailing comma or a sparse
// generator leaves behind, which no dereference can survive.
expect(() => renderGrid([undefined])).not.toThrow();
await waitFor(() => expect(document.body.textContent).toContain('Row 1'));
});

// ── PIN 2: THE SURVIVING ENTRY STILL GROUPS ─────────────────────────────
// The guard must DROP the unusable entry, not abandon grouping altogether —
// otherwise a single hole silently degrades a working grouped view into a
// flat one, which is the objectui#7179 class of silent wrong answer.
it('still groups by the usable entry when a null precedes it', async () => {
expect(() => renderGrid([null, { field: 'active' }])).not.toThrow();
await waitFor(() => expect(groupLabels().length).toBeGreaterThan(0));
expect(groupLabels()).toEqual(expect.arrayContaining(['Yes', 'No']));
});

it('still groups by the usable entry when a null follows it at a deeper level', async () => {
// The second entry is the NESTED level, so this reaches `buildLevel`'s
// recursion rather than only its depth-0 call.
expect(() => renderGrid([{ field: 'active' }, null])).not.toThrow();
await waitFor(() => expect(groupLabels().length).toBeGreaterThan(0));
expect(groupLabels()).toEqual(expect.arrayContaining(['Yes', 'No']));
});

// ── PIN 3: REACHABILITY — the validator refuses it, the render path never runs one ──
it('author-time validation already refuses a null entry (`@objectstack/spec`)', () => {
const refused = GroupingConfigSchema.safeParse({ fields: [null] });
expect(refused.success).toBe(false);
expect(refused.success === false && refused.error.issues[0]).toMatchObject({
code: 'invalid_type',
path: ['fields', 0],
});
// Positive control: the well-formed entry the same schema accepts, so a
// schema that refused EVERYTHING could not pass the assertion above.
expect(GroupingConfigSchema.safeParse({ fields: [{ field: 'active' }] }).success).toBe(true);
});

it("objectui's own `ListViewSchema` inherits that refusal by reference", () => {
const refused = ListViewSchema.safeParse({
type: 'list-view',
objectName: 'test_object',
grouping: { fields: [null] },
});
expect(refused.success).toBe(false);
expect(
refused.success === false
&& refused.error.issues.some((i) => i.path.join('.') === 'grouping.fields.0'),
'`grouping` is imported into `ListViewSchema` from the spec by reference, so '
+ 'the entry-shape refusal must arrive with it',
).toBe(true);
// Positive control: the same payload with a well-formed entry is accepted,
// so the refusal above is about the null entry and not about the envelope.
expect(
ListViewSchema.safeParse({
type: 'list-view',
objectName: 'test_object',
grouping: { fields: [{ field: 'active' }] },
}).success,
).toBe(true);
});
});
71 changes: 67 additions & 4 deletions packages/plugin-grid/src/useGroupedData.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -207,6 +207,64 @@ function compareGroups(a: string, b: string, order: 'asc' | 'desc'): number {
return order === 'desc' ? -cmp : cmp;
}

/**
* One entry of the spec's `grouping.fields[]` as AUTHORED — `field` plus the
* optional `order` / `collapsed`.
*
* ⚠️ NOT the same type as `@object-ui/components`' `GroupingFieldEntry`, which
* is the grouping EDITOR's fully-populated value shape and requires `order`
* and `collapsed`. This one is `z.input` of the spec schema, where both carry
* defaults and are therefore optional, so the two are structurally different
* and each keeps its own name (objectui#6273 — one authority per exported
* name; a shared spelling for two shapes is the collision that gate exists to
* catch).
*/
export type UsableGroupingField = NonNullable<GroupingConfig['fields']>[number];

/**
* The `grouping.fields[]` entries a grid can actually group by (objectui#7217).
*
* ## Why this exists
*
* `grouping` is authored JSON and reaches the renderer unparsed — `ObjectGrid`
* reads `schema.grouping` straight off its props and `@object-ui/core`'s
* `validateSchema` is structural and never looks at the key. A `null` hole in
* the array (a trailing comma, a sparse generator, an agent-written block) was
* therefore dereferenced twice with no guard: once by `ObjectGrid`'s
* `groupValueFormatter` memo and once by this hook's `buildLevel`. Both threw
* `TypeError: Cannot read properties of null (reading 'field')` and took the
* whole grid down during render.
*
* ## The admission rule is the harvester's, deliberately
*
* An entry is usable when it is an object carrying a non-empty string `field`
* — exactly the entries `collectGroupingFieldRefs` (`@object-ui/core`) harvests
* into the projection. Keeping the two sets equal is the point: an entry the
* grid grouped by but the projection ignored would be fetched as `undefined`
* on every row and bucket every record into one `(empty)` group, which is the
* silent wrong answer objectui#7179 closed. This is a defensive normalizer,
* NOT a lenient alias — no off-spec spelling is taught to mean anything here;
* unusable entries are dropped, never coerced.
*
* ## Dropping the entry, not the grouping
*
* One bad entry must not flatten a working grouped view: the usable entries
* still group, at the levels they still occupy.
*
* @param fields - `grouping.fields` in any authored state.
* @returns The usable entries, in order, with their `order` / `collapsed`
* intact — the harvester answers with field NAMES, which is why this cannot
* simply route through it.
*/
export function usableGroupingFields(fields: unknown): UsableGroupingField[] {
if (!Array.isArray(fields)) return [];
return fields.filter((entry): entry is UsableGroupingField => {
if (entry === null || typeof entry !== 'object') return false;
const name = (entry as { field?: unknown }).field;
return typeof name === 'string' && name.trim() !== '';
});
}

/**
* Hook that groups a flat data array by the fields specified in GroupingConfig.
*
Expand All@@ -227,14 +285,19 @@ export function useGroupedData(
aggregations?: AggregationConfig[],
formatValue?: GroupValueFormatter,
): UseGroupedDataResult {
const fields = config?.fields;
const isGrouped = !!(fields && fields.length > 0);
// [objectui#7217] The SAME normalized list `ObjectGrid`'s formatter memo
// reads. Memoized on the raw array rather than on `config`: hosts rebuild
// the `{ grouping }` object literal every render, so keying on `config`
// would hand `groups` a fresh array identity on every render.
const rawFields = config?.fields;
const fields = useMemo(() => usableGroupingFields(rawFields), [rawFields]);
const isGrouped = fields.length > 0;

// Track which group keys have been explicitly toggled by the user.
const [toggledKeys, setToggledKeys] = useState<Record<string, boolean>>({});

const groups: GroupEntry[] = useMemo(() => {
if (!isGrouped || !fields) return [];
if (!isGrouped) return [];

/**
* Recursively build a tree of groups for the slice of rows at the current
Expand DownExpand Up@@ -308,7 +371,7 @@ export function useGroupedData(
const lastSegment = key.split('__').pop() || '';
const depthMatch = /^(\d+):/.exec(lastSegment);
const depth = depthMatch ? Number(depthMatch[1]) : 0;
const fieldDefault = !!fields?.[depth]?.collapsed;
const fieldDefault = !!fields[depth]?.collapsed;
return {
...prev,
[key]: prev[key] !== undefined ? !prev[key] : !fieldDefault,
Expand Down
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
13 changes: 13 additions & 0 deletions .changeset/7217-grouping-null-entry-guard.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
---
'@object-ui/plugin-grid': patch
---

Grid: a malformed entry in `grouping.fields[]` no longer crashes the whole grid.

A `null` (or `undefined`) hole in the array was dereferenced with no guard at
two places — `ObjectGrid`'s `groupValueFormatter` memo and `useGroupedData`'s
`buildLevel` — throwing `TypeError: Cannot read properties of null (reading
'field')` during render, before any projection was built. Both sites now read
one normalized entry list, admitting exactly the entries `collectGroupingFieldRefs`
harvests into the query projection, so the usable grouping levels still group
and an unusable entry is simply dropped rather than taking the view down.
15 changes: 11 additions & 4 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,7 @@ import { resolveConditionalFormatting, buildExpandFields, buildExportFileName, c
import { usePermissions } from '@object-ui/permissions';
import { ChevronRight, ChevronDown, ChevronLeft, ChevronsLeft, ChevronsRight, Download, Rows2, Rows3, Rows4, AlignJustify, Type, Hash, Calendar, CheckSquare, User, Tag, Clock, Loader2 } from 'lucide-react';
import { useRowColor } from './useRowColor';
import { useGroupedData } from './useGroupedData';
import { useGroupedData, usableGroupingFields } from './useGroupedData';
import { GroupRow } from './GroupRow';
import { useColumnSummary } from './useColumnSummary';
import { resolveRowCrudAffordances, resolveRowRecordCrudAffordance } from './rowCrudAffordances';
Expand DownExpand Up@@ -2034,14 +2034,21 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// readable label for select/boolean fields rather than the raw value
// (e.g. "In Progress" instead of "in_progress", "Yes" instead of "true").
const groupValueFormatter = React.useMemo(() => {
const grouping = schema.grouping;
if (!grouping?.fields?.length) return undefined;
// [objectui#7217] ONE normalized entry list, shared with the
// `useGroupedData` call below. Reading `grouping.fields` raw here threw
// `TypeError: Cannot read properties of null (reading 'field')` on a null
// hole — the whole grid gone, during render, before any projection was
// built. `usableGroupingFields` admits exactly the entries
// `collectGroupingFieldRefs` harvests into the projection, so the grid can
// never group by an entry the query never asked for.
const groupingFields = usableGroupingFields(schema.grouping?.fields);
if (!groupingFields.length) return undefined;

// Per-field { value -> label } lookup, plus a per-field type so we can
// handle booleans / dates / users without dedicated option lists.
const lookup = new Map<string, { type?: string; options?: Map<string, string> }>();

for (const gf of grouping.fields) {
for (const gf of groupingFields) {
const fieldName = gf.field;
const objectDefField = objectSchema?.fields?.[fieldName];
// Try to find a column override matching this field for type/options
Expand Down
171 changes: 171 additions & 0 deletions packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
/**
* 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#7217 — a `null` entry in `grouping.fields[]` must not take the grid
* down.
*
* ## The defect
*
* `ObjectGrid`'s `groupValueFormatter` memo walked `grouping.fields` and read
* `gf.field` off every entry with no guard, so a single `null` hole threw
* `TypeError: Cannot read properties of null (reading 'field')` during render
* — the whole grid, gone, before any projection was built.
*
* `useGroupedData` is a SECOND dereference site of the same list (`const f =
* fields[depth]` then `f.field` / `f.order` / `f.collapsed`), so guarding the
* memo alone only moves the crash one call downstream. Both sites now read one
* normalized entry list — `usableGroupingFields` — and this file pins both:
* ablating either guard on its own turns these tests red.
*
* ## Why a guard, not a schema change (the reachability measurement)
*
* Author-time validation ALREADY refuses a null entry — `GroupingConfigSchema`
* types `fields` as an array of `$strict` objects, so `{ fields: [null] }`
* fails with `invalid_type` at `fields.0`, and objectui's own `ListViewSchema`
* inherits that by reference. The last two `it`s below measure exactly that,
* so the claim is checked rather than asserted in prose.
*
* That makes this a defensive guard rather than a validation gap — but the
* crash is still live, because NOTHING ON THE RENDER PATH RUNS THAT VALIDATOR.
* `ObjectGrid` reads `schema.grouping` straight off its props; `@object-ui/core`'s
* `validateSchema` is structural and never looks at the `grouping` key. A
* runtime-composed or generated schema therefore reaches the memo unparsed,
* which is the reachable path this pin closes.
*
* ## Test-source note
*
* The root vitest config aliases `@object-ui/*` to each package's `src`, and
* this file imports `../ObjectGrid` relatively, so no build step stands
* between the edit and the run — the ablation recorded in the PR body reads
* source directly.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';

import { ObjectGrid } from '../ObjectGrid';
import { registerAllFields } from '@object-ui/fields';
import { ActionProvider } from '@object-ui/react';
import { GroupingConfigSchema } from '@objectstack/spec/ui';
import { ListViewSchema } from '@object-ui/types/zod';

registerAllFields();

const ROWS = [
{ id: '1', name: 'Row 1', active: true },
{ id: '2', name: 'Row 2', active: false },
{ id: '3', name: 'Row 3', active: true },
];

/**
* Mount a grid whose `grouping.fields` is exactly `fields`.
*
* `data.provider: 'value'` keeps the rows inline, so `useGroupedData` runs over
* a NON-EMPTY array: the hook's dereference is only reached once there is a row
* to bucket, and a pin mounted on empty data would leave that half unmeasured.
*/
function renderGrid(fields: unknown[]) {
const schema: any = {
type: 'object-grid',
objectName: 'test_object',
columns: [
{ field: 'name', label: 'Name' },
{ field: 'active', label: 'Active', type: 'boolean' },
],
data: { provider: 'value', items: ROWS },
grouping: { fields },
};
return render(
<ActionProvider>
<ObjectGrid schema={schema} />
</ActionProvider>,
);
}

const groupLabels = () =>
Array.from(document.querySelectorAll('.group-label')).map((el) => el.textContent);

afterEach(() => cleanup());

describe('ObjectGrid — a null `grouping.fields[]` entry never crashes the grid (objectui#7217)', () => {
// ── PIN 1: THE DEFECT ───────────────────────────────────────────────────
it('renders instead of throwing when the only grouping entry is null', async () => {
expect(
() => renderGrid([null]),
'a null hole in `grouping.fields[]` threw a TypeError out of render and '
+ 'took the whole grid down before any projection was built',
).not.toThrow();
await waitFor(() => expect(document.body.textContent).toContain('Row 1'));
expect(document.body.textContent).toContain('Row 2');
expect(document.body.textContent).toContain('Row 3');
});

it('renders instead of throwing when the only grouping entry is undefined', async () => {
// Same defect class as `null`: a hole a trailing comma or a sparse
// generator leaves behind, which no dereference can survive.
expect(() => renderGrid([undefined])).not.toThrow();
await waitFor(() => expect(document.body.textContent).toContain('Row 1'));
});

// ── PIN 2: THE SURVIVING ENTRY STILL GROUPS ─────────────────────────────
// The guard must DROP the unusable entry, not abandon grouping altogether —
// otherwise a single hole silently degrades a working grouped view into a
// flat one, which is the objectui#7179 class of silent wrong answer.
it('still groups by the usable entry when a null precedes it', async () => {
expect(() => renderGrid([null, { field: 'active' }])).not.toThrow();
await waitFor(() => expect(groupLabels().length).toBeGreaterThan(0));
expect(groupLabels()).toEqual(expect.arrayContaining(['Yes', 'No']));
});

it('still groups by the usable entry when a null follows it at a deeper level', async () => {
// The second entry is the NESTED level, so this reaches `buildLevel`'s
// recursion rather than only its depth-0 call.
expect(() => renderGrid([{ field: 'active' }, null])).not.toThrow();
await waitFor(() => expect(groupLabels().length).toBeGreaterThan(0));
expect(groupLabels()).toEqual(expect.arrayContaining(['Yes', 'No']));
});

// ── PIN 3: REACHABILITY — the validator refuses it, the render path never runs one ──
it('author-time validation already refuses a null entry (`@objectstack/spec`)', () => {
const refused = GroupingConfigSchema.safeParse({ fields: [null] });
expect(refused.success).toBe(false);
expect(refused.success === false && refused.error.issues[0]).toMatchObject({
code: 'invalid_type',
path: ['fields', 0],
});
// Positive control: the well-formed entry the same schema accepts, so a
// schema that refused EVERYTHING could not pass the assertion above.
expect(GroupingConfigSchema.safeParse({ fields: [{ field: 'active' }] }).success).toBe(true);
});

it("objectui's own `ListViewSchema` inherits that refusal by reference", () => {
const refused = ListViewSchema.safeParse({
type: 'list-view',
objectName: 'test_object',
grouping: { fields: [null] },
});
expect(refused.success).toBe(false);
expect(
refused.success === false
&& refused.error.issues.some((i) => i.path.join('.') === 'grouping.fields.0'),
'`grouping` is imported into `ListViewSchema` from the spec by reference, so '
+ 'the entry-shape refusal must arrive with it',
).toBe(true);
// Positive control: the same payload with a well-formed entry is accepted,
// so the refusal above is about the null entry and not about the envelope.
expect(
ListViewSchema.safeParse({
type: 'list-view',
objectName: 'test_object',
grouping: { fields: [{ field: 'active' }] },
}).success,
).toBe(true);
});
});
71 changes: 67 additions & 4 deletions packages/plugin-grid/src/useGroupedData.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -207,6 +207,64 @@ function compareGroups(a: string, b: string, order: 'asc' | 'desc'): number {
return order === 'desc' ? -cmp : cmp;
}

/**
* One entry of the spec's `grouping.fields[]` as AUTHORED — `field` plus the
* optional `order` / `collapsed`.
*
* ⚠️ NOT the same type as `@object-ui/components`' `GroupingFieldEntry`, which
* is the grouping EDITOR's fully-populated value shape and requires `order`
* and `collapsed`. This one is `z.input` of the spec schema, where both carry
* defaults and are therefore optional, so the two are structurally different
* and each keeps its own name (objectui#6273 — one authority per exported
* name; a shared spelling for two shapes is the collision that gate exists to
* catch).
*/
export type UsableGroupingField = NonNullable<GroupingConfig['fields']>[number];

/**
* The `grouping.fields[]` entries a grid can actually group by (objectui#7217).
*
* ## Why this exists
*
* `grouping` is authored JSON and reaches the renderer unparsed — `ObjectGrid`
* reads `schema.grouping` straight off its props and `@object-ui/core`'s
* `validateSchema` is structural and never looks at the key. A `null` hole in
* the array (a trailing comma, a sparse generator, an agent-written block) was
* therefore dereferenced twice with no guard: once by `ObjectGrid`'s
* `groupValueFormatter` memo and once by this hook's `buildLevel`. Both threw
* `TypeError: Cannot read properties of null (reading 'field')` and took the
* whole grid down during render.
*
* ## The admission rule is the harvester's, deliberately
*
* An entry is usable when it is an object carrying a non-empty string `field`
* — exactly the entries `collectGroupingFieldRefs` (`@object-ui/core`) harvests
* into the projection. Keeping the two sets equal is the point: an entry the
* grid grouped by but the projection ignored would be fetched as `undefined`
* on every row and bucket every record into one `(empty)` group, which is the
* silent wrong answer objectui#7179 closed. This is a defensive normalizer,
* NOT a lenient alias — no off-spec spelling is taught to mean anything here;
* unusable entries are dropped, never coerced.
*
* ## Dropping the entry, not the grouping
*
* One bad entry must not flatten a working grouped view: the usable entries
* still group, at the levels they still occupy.
*
* @param fields - `grouping.fields` in any authored state.
* @returns The usable entries, in order, with their `order` / `collapsed`
* intact — the harvester answers with field NAMES, which is why this cannot
* simply route through it.
*/
export function usableGroupingFields(fields: unknown): UsableGroupingField[] {
if (!Array.isArray(fields)) return [];
return fields.filter((entry): entry is UsableGroupingField => {
if (entry === null || typeof entry !== 'object') return false;
const name = (entry as { field?: unknown }).field;
return typeof name === 'string' && name.trim() !== '';
});
}

/**
* Hook that groups a flat data array by the fields specified in GroupingConfig.
*
Expand All@@ -227,14 +285,19 @@ export function useGroupedData(
aggregations?: AggregationConfig[],
formatValue?: GroupValueFormatter,
): UseGroupedDataResult {
const fields = config?.fields;
const isGrouped = !!(fields && fields.length > 0);
// [objectui#7217] The SAME normalized list `ObjectGrid`'s formatter memo
// reads. Memoized on the raw array rather than on `config`: hosts rebuild
// the `{ grouping }` object literal every render, so keying on `config`
// would hand `groups` a fresh array identity on every render.
const rawFields = config?.fields;
const fields = useMemo(() => usableGroupingFields(rawFields), [rawFields]);
const isGrouped = fields.length > 0;

// Track which group keys have been explicitly toggled by the user.
const [toggledKeys, setToggledKeys] = useState<Record<string, boolean>>({});

const groups: GroupEntry[] = useMemo(() => {
if (!isGrouped || !fields) return [];
if (!isGrouped) return [];

/**
* Recursively build a tree of groups for the slice of rows at the current
Expand DownExpand Up@@ -308,7 +371,7 @@ export function useGroupedData(
const lastSegment = key.split('__').pop() || '';
const depthMatch = /^(\d+):/.exec(lastSegment);
const depth = depthMatch ? Number(depthMatch[1]) : 0;
const fieldDefault = !!fields?.[depth]?.collapsed;
const fieldDefault = !!fields[depth]?.collapsed;
return {
...prev,
[key]: prev[key] !== undefined ? !prev[key] : !fieldDefault,
Expand Down
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
13 changes: 13 additions & 0 deletions .changeset/7217-grouping-null-entry-guard.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
---
'@object-ui/plugin-grid': patch
---

Grid: a malformed entry in `grouping.fields[]` no longer crashes the whole grid.

A `null` (or `undefined`) hole in the array was dereferenced with no guard at
two places — `ObjectGrid`'s `groupValueFormatter` memo and `useGroupedData`'s
`buildLevel` — throwing `TypeError: Cannot read properties of null (reading
'field')` during render, before any projection was built. Both sites now read
one normalized entry list, admitting exactly the entries `collectGroupingFieldRefs`
harvests into the query projection, so the usable grouping levels still group
and an unusable entry is simply dropped rather than taking the view down.
15 changes: 11 additions & 4 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,7 @@ import { resolveConditionalFormatting, buildExpandFields, buildExportFileName, c
import { usePermissions } from '@object-ui/permissions';
import { ChevronRight, ChevronDown, ChevronLeft, ChevronsLeft, ChevronsRight, Download, Rows2, Rows3, Rows4, AlignJustify, Type, Hash, Calendar, CheckSquare, User, Tag, Clock, Loader2 } from 'lucide-react';
import { useRowColor } from './useRowColor';
import { useGroupedData } from './useGroupedData';
import { useGroupedData, usableGroupingFields } from './useGroupedData';
import { GroupRow } from './GroupRow';
import { useColumnSummary } from './useColumnSummary';
import { resolveRowCrudAffordances, resolveRowRecordCrudAffordance } from './rowCrudAffordances';
Expand DownExpand Up@@ -2034,14 +2034,21 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// readable label for select/boolean fields rather than the raw value
// (e.g. "In Progress" instead of "in_progress", "Yes" instead of "true").
const groupValueFormatter = React.useMemo(() => {
const grouping = schema.grouping;
if (!grouping?.fields?.length) return undefined;
// [objectui#7217] ONE normalized entry list, shared with the
// `useGroupedData` call below. Reading `grouping.fields` raw here threw
// `TypeError: Cannot read properties of null (reading 'field')` on a null
// hole — the whole grid gone, during render, before any projection was
// built. `usableGroupingFields` admits exactly the entries
// `collectGroupingFieldRefs` harvests into the projection, so the grid can
// never group by an entry the query never asked for.
const groupingFields = usableGroupingFields(schema.grouping?.fields);
if (!groupingFields.length) return undefined;

// Per-field { value -> label } lookup, plus a per-field type so we can
// handle booleans / dates / users without dedicated option lists.
const lookup = new Map<string, { type?: string; options?: Map<string, string> }>();

for (const gf of grouping.fields) {
for (const gf of groupingFields) {
const fieldName = gf.field;
const objectDefField = objectSchema?.fields?.[fieldName];
// Try to find a column override matching this field for type/options
Expand Down
171 changes: 171 additions & 0 deletions packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
/**
* 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#7217 — a `null` entry in `grouping.fields[]` must not take the grid
* down.
*
* ## The defect
*
* `ObjectGrid`'s `groupValueFormatter` memo walked `grouping.fields` and read
* `gf.field` off every entry with no guard, so a single `null` hole threw
* `TypeError: Cannot read properties of null (reading 'field')` during render
* — the whole grid, gone, before any projection was built.
*
* `useGroupedData` is a SECOND dereference site of the same list (`const f =
* fields[depth]` then `f.field` / `f.order` / `f.collapsed`), so guarding the
* memo alone only moves the crash one call downstream. Both sites now read one
* normalized entry list — `usableGroupingFields` — and this file pins both:
* ablating either guard on its own turns these tests red.
*
* ## Why a guard, not a schema change (the reachability measurement)
*
* Author-time validation ALREADY refuses a null entry — `GroupingConfigSchema`
* types `fields` as an array of `$strict` objects, so `{ fields: [null] }`
* fails with `invalid_type` at `fields.0`, and objectui's own `ListViewSchema`
* inherits that by reference. The last two `it`s below measure exactly that,
* so the claim is checked rather than asserted in prose.
*
* That makes this a defensive guard rather than a validation gap — but the
* crash is still live, because NOTHING ON THE RENDER PATH RUNS THAT VALIDATOR.
* `ObjectGrid` reads `schema.grouping` straight off its props; `@object-ui/core`'s
* `validateSchema` is structural and never looks at the `grouping` key. A
* runtime-composed or generated schema therefore reaches the memo unparsed,
* which is the reachable path this pin closes.
*
* ## Test-source note
*
* The root vitest config aliases `@object-ui/*` to each package's `src`, and
* this file imports `../ObjectGrid` relatively, so no build step stands
* between the edit and the run — the ablation recorded in the PR body reads
* source directly.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';

import { ObjectGrid } from '../ObjectGrid';
import { registerAllFields } from '@object-ui/fields';
import { ActionProvider } from '@object-ui/react';
import { GroupingConfigSchema } from '@objectstack/spec/ui';
import { ListViewSchema } from '@object-ui/types/zod';

registerAllFields();

const ROWS = [
{ id: '1', name: 'Row 1', active: true },
{ id: '2', name: 'Row 2', active: false },
{ id: '3', name: 'Row 3', active: true },
];

/**
* Mount a grid whose `grouping.fields` is exactly `fields`.
*
* `data.provider: 'value'` keeps the rows inline, so `useGroupedData` runs over
* a NON-EMPTY array: the hook's dereference is only reached once there is a row
* to bucket, and a pin mounted on empty data would leave that half unmeasured.
*/
function renderGrid(fields: unknown[]) {
const schema: any = {
type: 'object-grid',
objectName: 'test_object',
columns: [
{ field: 'name', label: 'Name' },
{ field: 'active', label: 'Active', type: 'boolean' },
],
data: { provider: 'value', items: ROWS },
grouping: { fields },
};
return render(
<ActionProvider>
<ObjectGrid schema={schema} />
</ActionProvider>,
);
}

const groupLabels = () =>
Array.from(document.querySelectorAll('.group-label')).map((el) => el.textContent);

afterEach(() => cleanup());

describe('ObjectGrid — a null `grouping.fields[]` entry never crashes the grid (objectui#7217)', () => {
// ── PIN 1: THE DEFECT ───────────────────────────────────────────────────
it('renders instead of throwing when the only grouping entry is null', async () => {
expect(
() => renderGrid([null]),
'a null hole in `grouping.fields[]` threw a TypeError out of render and '
+ 'took the whole grid down before any projection was built',
).not.toThrow();
await waitFor(() => expect(document.body.textContent).toContain('Row 1'));
expect(document.body.textContent).toContain('Row 2');
expect(document.body.textContent).toContain('Row 3');
});

it('renders instead of throwing when the only grouping entry is undefined', async () => {
// Same defect class as `null`: a hole a trailing comma or a sparse
// generator leaves behind, which no dereference can survive.
expect(() => renderGrid([undefined])).not.toThrow();
await waitFor(() => expect(document.body.textContent).toContain('Row 1'));
});

// ── PIN 2: THE SURVIVING ENTRY STILL GROUPS ─────────────────────────────
// The guard must DROP the unusable entry, not abandon grouping altogether —
// otherwise a single hole silently degrades a working grouped view into a
// flat one, which is the objectui#7179 class of silent wrong answer.
it('still groups by the usable entry when a null precedes it', async () => {
expect(() => renderGrid([null, { field: 'active' }])).not.toThrow();
await waitFor(() => expect(groupLabels().length).toBeGreaterThan(0));
expect(groupLabels()).toEqual(expect.arrayContaining(['Yes', 'No']));
});

it('still groups by the usable entry when a null follows it at a deeper level', async () => {
// The second entry is the NESTED level, so this reaches `buildLevel`'s
// recursion rather than only its depth-0 call.
expect(() => renderGrid([{ field: 'active' }, null])).not.toThrow();
await waitFor(() => expect(groupLabels().length).toBeGreaterThan(0));
expect(groupLabels()).toEqual(expect.arrayContaining(['Yes', 'No']));
});

// ── PIN 3: REACHABILITY — the validator refuses it, the render path never runs one ──
it('author-time validation already refuses a null entry (`@objectstack/spec`)', () => {
const refused = GroupingConfigSchema.safeParse({ fields: [null] });
expect(refused.success).toBe(false);
expect(refused.success === false && refused.error.issues[0]).toMatchObject({
code: 'invalid_type',
path: ['fields', 0],
});
// Positive control: the well-formed entry the same schema accepts, so a
// schema that refused EVERYTHING could not pass the assertion above.
expect(GroupingConfigSchema.safeParse({ fields: [{ field: 'active' }] }).success).toBe(true);
});

it("objectui's own `ListViewSchema` inherits that refusal by reference", () => {
const refused = ListViewSchema.safeParse({
type: 'list-view',
objectName: 'test_object',
grouping: { fields: [null] },
});
expect(refused.success).toBe(false);
expect(
refused.success === false
&& refused.error.issues.some((i) => i.path.join('.') === 'grouping.fields.0'),
'`grouping` is imported into `ListViewSchema` from the spec by reference, so '
+ 'the entry-shape refusal must arrive with it',
).toBe(true);
// Positive control: the same payload with a well-formed entry is accepted,
// so the refusal above is about the null entry and not about the envelope.
expect(
ListViewSchema.safeParse({
type: 'list-view',
objectName: 'test_object',
grouping: { fields: [{ field: 'active' }] },
}).success,
).toBe(true);
});
});
71 changes: 67 additions & 4 deletions packages/plugin-grid/src/useGroupedData.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -207,6 +207,64 @@ function compareGroups(a: string, b: string, order: 'asc' | 'desc'): number {
return order === 'desc' ? -cmp : cmp;
}

/**
* One entry of the spec's `grouping.fields[]` as AUTHORED — `field` plus the
* optional `order` / `collapsed`.
*
* ⚠️ NOT the same type as `@object-ui/components`' `GroupingFieldEntry`, which
* is the grouping EDITOR's fully-populated value shape and requires `order`
* and `collapsed`. This one is `z.input` of the spec schema, where both carry
* defaults and are therefore optional, so the two are structurally different
* and each keeps its own name (objectui#6273 — one authority per exported
* name; a shared spelling for two shapes is the collision that gate exists to
* catch).
*/
export type UsableGroupingField = NonNullable<GroupingConfig['fields']>[number];

/**
* The `grouping.fields[]` entries a grid can actually group by (objectui#7217).
*
* ## Why this exists
*
* `grouping` is authored JSON and reaches the renderer unparsed — `ObjectGrid`
* reads `schema.grouping` straight off its props and `@object-ui/core`'s
* `validateSchema` is structural and never looks at the key. A `null` hole in
* the array (a trailing comma, a sparse generator, an agent-written block) was
* therefore dereferenced twice with no guard: once by `ObjectGrid`'s
* `groupValueFormatter` memo and once by this hook's `buildLevel`. Both threw
* `TypeError: Cannot read properties of null (reading 'field')` and took the
* whole grid down during render.
*
* ## The admission rule is the harvester's, deliberately
*
* An entry is usable when it is an object carrying a non-empty string `field`
* — exactly the entries `collectGroupingFieldRefs` (`@object-ui/core`) harvests
* into the projection. Keeping the two sets equal is the point: an entry the
* grid grouped by but the projection ignored would be fetched as `undefined`
* on every row and bucket every record into one `(empty)` group, which is the
* silent wrong answer objectui#7179 closed. This is a defensive normalizer,
* NOT a lenient alias — no off-spec spelling is taught to mean anything here;
* unusable entries are dropped, never coerced.
*
* ## Dropping the entry, not the grouping
*
* One bad entry must not flatten a working grouped view: the usable entries
* still group, at the levels they still occupy.
*
* @param fields - `grouping.fields` in any authored state.
* @returns The usable entries, in order, with their `order` / `collapsed`
* intact — the harvester answers with field NAMES, which is why this cannot
* simply route through it.
*/
export function usableGroupingFields(fields: unknown): UsableGroupingField[] {
if (!Array.isArray(fields)) return [];
return fields.filter((entry): entry is UsableGroupingField => {
if (entry === null || typeof entry !== 'object') return false;
const name = (entry as { field?: unknown }).field;
return typeof name === 'string' && name.trim() !== '';
});
}

/**
* Hook that groups a flat data array by the fields specified in GroupingConfig.
*
Expand All@@ -227,14 +285,19 @@ export function useGroupedData(
aggregations?: AggregationConfig[],
formatValue?: GroupValueFormatter,
): UseGroupedDataResult {
const fields = config?.fields;
const isGrouped = !!(fields && fields.length > 0);
// [objectui#7217] The SAME normalized list `ObjectGrid`'s formatter memo
// reads. Memoized on the raw array rather than on `config`: hosts rebuild
// the `{ grouping }` object literal every render, so keying on `config`
// would hand `groups` a fresh array identity on every render.
const rawFields = config?.fields;
const fields = useMemo(() => usableGroupingFields(rawFields), [rawFields]);
const isGrouped = fields.length > 0;

// Track which group keys have been explicitly toggled by the user.
const [toggledKeys, setToggledKeys] = useState<Record<string, boolean>>({});

const groups: GroupEntry[] = useMemo(() => {
if (!isGrouped || !fields) return [];
if (!isGrouped) return [];

/**
* Recursively build a tree of groups for the slice of rows at the current
Expand DownExpand Up@@ -308,7 +371,7 @@ export function useGroupedData(
const lastSegment = key.split('__').pop() || '';
const depthMatch = /^(\d+):/.exec(lastSegment);
const depth = depthMatch ? Number(depthMatch[1]) : 0;
const fieldDefault = !!fields?.[depth]?.collapsed;
const fieldDefault = !!fields[depth]?.collapsed;
return {
...prev,
[key]: prev[key] !== undefined ? !prev[key] : !fieldDefault,
Expand Down
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
13 changes: 13 additions & 0 deletions .changeset/7217-grouping-null-entry-guard.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
---
'@object-ui/plugin-grid': patch
---

Grid: a malformed entry in `grouping.fields[]` no longer crashes the whole grid.

A `null` (or `undefined`) hole in the array was dereferenced with no guard at
two places — `ObjectGrid`'s `groupValueFormatter` memo and `useGroupedData`'s
`buildLevel` — throwing `TypeError: Cannot read properties of null (reading
'field')` during render, before any projection was built. Both sites now read
one normalized entry list, admitting exactly the entries `collectGroupingFieldRefs`
harvests into the query projection, so the usable grouping levels still group
and an unusable entry is simply dropped rather than taking the view down.
15 changes: 11 additions & 4 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,7 @@ import { resolveConditionalFormatting, buildExpandFields, buildExportFileName, c
import { usePermissions } from '@object-ui/permissions';
import { ChevronRight, ChevronDown, ChevronLeft, ChevronsLeft, ChevronsRight, Download, Rows2, Rows3, Rows4, AlignJustify, Type, Hash, Calendar, CheckSquare, User, Tag, Clock, Loader2 } from 'lucide-react';
import { useRowColor } from './useRowColor';
import { useGroupedData } from './useGroupedData';
import { useGroupedData, usableGroupingFields } from './useGroupedData';
import { GroupRow } from './GroupRow';
import { useColumnSummary } from './useColumnSummary';
import { resolveRowCrudAffordances, resolveRowRecordCrudAffordance } from './rowCrudAffordances';
Expand DownExpand Up@@ -2034,14 +2034,21 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// readable label for select/boolean fields rather than the raw value
// (e.g. "In Progress" instead of "in_progress", "Yes" instead of "true").
const groupValueFormatter = React.useMemo(() => {
const grouping = schema.grouping;
if (!grouping?.fields?.length) return undefined;
// [objectui#7217] ONE normalized entry list, shared with the
// `useGroupedData` call below. Reading `grouping.fields` raw here threw
// `TypeError: Cannot read properties of null (reading 'field')` on a null
// hole — the whole grid gone, during render, before any projection was
// built. `usableGroupingFields` admits exactly the entries
// `collectGroupingFieldRefs` harvests into the projection, so the grid can
// never group by an entry the query never asked for.
const groupingFields = usableGroupingFields(schema.grouping?.fields);
if (!groupingFields.length) return undefined;

// Per-field { value -> label } lookup, plus a per-field type so we can
// handle booleans / dates / users without dedicated option lists.
const lookup = new Map<string, { type?: string; options?: Map<string, string> }>();

for (const gf of grouping.fields) {
for (const gf of groupingFields) {
const fieldName = gf.field;
const objectDefField = objectSchema?.fields?.[fieldName];
// Try to find a column override matching this field for type/options
Expand Down
171 changes: 171 additions & 0 deletions packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
/**
* 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#7217 — a `null` entry in `grouping.fields[]` must not take the grid
* down.
*
* ## The defect
*
* `ObjectGrid`'s `groupValueFormatter` memo walked `grouping.fields` and read
* `gf.field` off every entry with no guard, so a single `null` hole threw
* `TypeError: Cannot read properties of null (reading 'field')` during render
* — the whole grid, gone, before any projection was built.
*
* `useGroupedData` is a SECOND dereference site of the same list (`const f =
* fields[depth]` then `f.field` / `f.order` / `f.collapsed`), so guarding the
* memo alone only moves the crash one call downstream. Both sites now read one
* normalized entry list — `usableGroupingFields` — and this file pins both:
* ablating either guard on its own turns these tests red.
*
* ## Why a guard, not a schema change (the reachability measurement)
*
* Author-time validation ALREADY refuses a null entry — `GroupingConfigSchema`
* types `fields` as an array of `$strict` objects, so `{ fields: [null] }`
* fails with `invalid_type` at `fields.0`, and objectui's own `ListViewSchema`
* inherits that by reference. The last two `it`s below measure exactly that,
* so the claim is checked rather than asserted in prose.
*
* That makes this a defensive guard rather than a validation gap — but the
* crash is still live, because NOTHING ON THE RENDER PATH RUNS THAT VALIDATOR.
* `ObjectGrid` reads `schema.grouping` straight off its props; `@object-ui/core`'s
* `validateSchema` is structural and never looks at the `grouping` key. A
* runtime-composed or generated schema therefore reaches the memo unparsed,
* which is the reachable path this pin closes.
*
* ## Test-source note
*
* The root vitest config aliases `@object-ui/*` to each package's `src`, and
* this file imports `../ObjectGrid` relatively, so no build step stands
* between the edit and the run — the ablation recorded in the PR body reads
* source directly.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';

import { ObjectGrid } from '../ObjectGrid';
import { registerAllFields } from '@object-ui/fields';
import { ActionProvider } from '@object-ui/react';
import { GroupingConfigSchema } from '@objectstack/spec/ui';
import { ListViewSchema } from '@object-ui/types/zod';

registerAllFields();

const ROWS = [
{ id: '1', name: 'Row 1', active: true },
{ id: '2', name: 'Row 2', active: false },
{ id: '3', name: 'Row 3', active: true },
];

/**
* Mount a grid whose `grouping.fields` is exactly `fields`.
*
* `data.provider: 'value'` keeps the rows inline, so `useGroupedData` runs over
* a NON-EMPTY array: the hook's dereference is only reached once there is a row
* to bucket, and a pin mounted on empty data would leave that half unmeasured.
*/
function renderGrid(fields: unknown[]) {
const schema: any = {
type: 'object-grid',
objectName: 'test_object',
columns: [
{ field: 'name', label: 'Name' },
{ field: 'active', label: 'Active', type: 'boolean' },
],
data: { provider: 'value', items: ROWS },
grouping: { fields },
};
return render(
<ActionProvider>
<ObjectGrid schema={schema} />
</ActionProvider>,
);
}

const groupLabels = () =>
Array.from(document.querySelectorAll('.group-label')).map((el) => el.textContent);

afterEach(() => cleanup());

describe('ObjectGrid — a null `grouping.fields[]` entry never crashes the grid (objectui#7217)', () => {
// ── PIN 1: THE DEFECT ───────────────────────────────────────────────────
it('renders instead of throwing when the only grouping entry is null', async () => {
expect(
() => renderGrid([null]),
'a null hole in `grouping.fields[]` threw a TypeError out of render and '
+ 'took the whole grid down before any projection was built',
).not.toThrow();
await waitFor(() => expect(document.body.textContent).toContain('Row 1'));
expect(document.body.textContent).toContain('Row 2');
expect(document.body.textContent).toContain('Row 3');
});

it('renders instead of throwing when the only grouping entry is undefined', async () => {
// Same defect class as `null`: a hole a trailing comma or a sparse
// generator leaves behind, which no dereference can survive.
expect(() => renderGrid([undefined])).not.toThrow();
await waitFor(() => expect(document.body.textContent).toContain('Row 1'));
});

// ── PIN 2: THE SURVIVING ENTRY STILL GROUPS ─────────────────────────────
// The guard must DROP the unusable entry, not abandon grouping altogether —
// otherwise a single hole silently degrades a working grouped view into a
// flat one, which is the objectui#7179 class of silent wrong answer.
it('still groups by the usable entry when a null precedes it', async () => {
expect(() => renderGrid([null, { field: 'active' }])).not.toThrow();
await waitFor(() => expect(groupLabels().length).toBeGreaterThan(0));
expect(groupLabels()).toEqual(expect.arrayContaining(['Yes', 'No']));
});

it('still groups by the usable entry when a null follows it at a deeper level', async () => {
// The second entry is the NESTED level, so this reaches `buildLevel`'s
// recursion rather than only its depth-0 call.
expect(() => renderGrid([{ field: 'active' }, null])).not.toThrow();
await waitFor(() => expect(groupLabels().length).toBeGreaterThan(0));
expect(groupLabels()).toEqual(expect.arrayContaining(['Yes', 'No']));
});

// ── PIN 3: REACHABILITY — the validator refuses it, the render path never runs one ──
it('author-time validation already refuses a null entry (`@objectstack/spec`)', () => {
const refused = GroupingConfigSchema.safeParse({ fields: [null] });
expect(refused.success).toBe(false);
expect(refused.success === false && refused.error.issues[0]).toMatchObject({
code: 'invalid_type',
path: ['fields', 0],
});
// Positive control: the well-formed entry the same schema accepts, so a
// schema that refused EVERYTHING could not pass the assertion above.
expect(GroupingConfigSchema.safeParse({ fields: [{ field: 'active' }] }).success).toBe(true);
});

it("objectui's own `ListViewSchema` inherits that refusal by reference", () => {
const refused = ListViewSchema.safeParse({
type: 'list-view',
objectName: 'test_object',
grouping: { fields: [null] },
});
expect(refused.success).toBe(false);
expect(
refused.success === false
&& refused.error.issues.some((i) => i.path.join('.') === 'grouping.fields.0'),
'`grouping` is imported into `ListViewSchema` from the spec by reference, so '
+ 'the entry-shape refusal must arrive with it',
).toBe(true);
// Positive control: the same payload with a well-formed entry is accepted,
// so the refusal above is about the null entry and not about the envelope.
expect(
ListViewSchema.safeParse({
type: 'list-view',
objectName: 'test_object',
grouping: { fields: [{ field: 'active' }] },
}).success,
).toBe(true);
});
});
71 changes: 67 additions & 4 deletions packages/plugin-grid/src/useGroupedData.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -207,6 +207,64 @@ function compareGroups(a: string, b: string, order: 'asc' | 'desc'): number {
return order === 'desc' ? -cmp : cmp;
}

/**
* One entry of the spec's `grouping.fields[]` as AUTHORED — `field` plus the
* optional `order` / `collapsed`.
*
* ⚠️ NOT the same type as `@object-ui/components`' `GroupingFieldEntry`, which
* is the grouping EDITOR's fully-populated value shape and requires `order`
* and `collapsed`. This one is `z.input` of the spec schema, where both carry
* defaults and are therefore optional, so the two are structurally different
* and each keeps its own name (objectui#6273 — one authority per exported
* name; a shared spelling for two shapes is the collision that gate exists to
* catch).
*/
export type UsableGroupingField = NonNullable<GroupingConfig['fields']>[number];

/**
* The `grouping.fields[]` entries a grid can actually group by (objectui#7217).
*
* ## Why this exists
*
* `grouping` is authored JSON and reaches the renderer unparsed — `ObjectGrid`
* reads `schema.grouping` straight off its props and `@object-ui/core`'s
* `validateSchema` is structural and never looks at the key. A `null` hole in
* the array (a trailing comma, a sparse generator, an agent-written block) was
* therefore dereferenced twice with no guard: once by `ObjectGrid`'s
* `groupValueFormatter` memo and once by this hook's `buildLevel`. Both threw
* `TypeError: Cannot read properties of null (reading 'field')` and took the
* whole grid down during render.
*
* ## The admission rule is the harvester's, deliberately
*
* An entry is usable when it is an object carrying a non-empty string `field`
* — exactly the entries `collectGroupingFieldRefs` (`@object-ui/core`) harvests
* into the projection. Keeping the two sets equal is the point: an entry the
* grid grouped by but the projection ignored would be fetched as `undefined`
* on every row and bucket every record into one `(empty)` group, which is the
* silent wrong answer objectui#7179 closed. This is a defensive normalizer,
* NOT a lenient alias — no off-spec spelling is taught to mean anything here;
* unusable entries are dropped, never coerced.
*
* ## Dropping the entry, not the grouping
*
* One bad entry must not flatten a working grouped view: the usable entries
* still group, at the levels they still occupy.
*
* @param fields - `grouping.fields` in any authored state.
* @returns The usable entries, in order, with their `order` / `collapsed`
* intact — the harvester answers with field NAMES, which is why this cannot
* simply route through it.
*/
export function usableGroupingFields(fields: unknown): UsableGroupingField[] {
if (!Array.isArray(fields)) return [];
return fields.filter((entry): entry is UsableGroupingField => {
if (entry === null || typeof entry !== 'object') return false;
const name = (entry as { field?: unknown }).field;
return typeof name === 'string' && name.trim() !== '';
});
}

/**
* Hook that groups a flat data array by the fields specified in GroupingConfig.
*
Expand All@@ -227,14 +285,19 @@ export function useGroupedData(
aggregations?: AggregationConfig[],
formatValue?: GroupValueFormatter,
): UseGroupedDataResult {
const fields = config?.fields;
const isGrouped = !!(fields && fields.length > 0);
// [objectui#7217] The SAME normalized list `ObjectGrid`'s formatter memo
// reads. Memoized on the raw array rather than on `config`: hosts rebuild
// the `{ grouping }` object literal every render, so keying on `config`
// would hand `groups` a fresh array identity on every render.
const rawFields = config?.fields;
const fields = useMemo(() => usableGroupingFields(rawFields), [rawFields]);
const isGrouped = fields.length > 0;

// Track which group keys have been explicitly toggled by the user.
const [toggledKeys, setToggledKeys] = useState<Record<string, boolean>>({});

const groups: GroupEntry[] = useMemo(() => {
if (!isGrouped || !fields) return [];
if (!isGrouped) return [];

/**
* Recursively build a tree of groups for the slice of rows at the current
Expand DownExpand Up@@ -308,7 +371,7 @@ export function useGroupedData(
const lastSegment = key.split('__').pop() || '';
const depthMatch = /^(\d+):/.exec(lastSegment);
const depth = depthMatch ? Number(depthMatch[1]) : 0;
const fieldDefault = !!fields?.[depth]?.collapsed;
const fieldDefault = !!fields[depth]?.collapsed;
return {
...prev,
[key]: prev[key] !== undefined ? !prev[key] : !fieldDefault,
Expand Down
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
13 changes: 13 additions & 0 deletions .changeset/7217-grouping-null-entry-guard.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
---
'@object-ui/plugin-grid': patch
---

Grid: a malformed entry in `grouping.fields[]` no longer crashes the whole grid.

A `null` (or `undefined`) hole in the array was dereferenced with no guard at
two places — `ObjectGrid`'s `groupValueFormatter` memo and `useGroupedData`'s
`buildLevel` — throwing `TypeError: Cannot read properties of null (reading
'field')` during render, before any projection was built. Both sites now read
one normalized entry list, admitting exactly the entries `collectGroupingFieldRefs`
harvests into the query projection, so the usable grouping levels still group
and an unusable entry is simply dropped rather than taking the view down.
15 changes: 11 additions & 4 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,7 @@ import { resolveConditionalFormatting, buildExpandFields, buildExportFileName, c
import { usePermissions } from '@object-ui/permissions';
import { ChevronRight, ChevronDown, ChevronLeft, ChevronsLeft, ChevronsRight, Download, Rows2, Rows3, Rows4, AlignJustify, Type, Hash, Calendar, CheckSquare, User, Tag, Clock, Loader2 } from 'lucide-react';
import { useRowColor } from './useRowColor';
import { useGroupedData } from './useGroupedData';
import { useGroupedData, usableGroupingFields } from './useGroupedData';
import { GroupRow } from './GroupRow';
import { useColumnSummary } from './useColumnSummary';
import { resolveRowCrudAffordances, resolveRowRecordCrudAffordance } from './rowCrudAffordances';
Expand DownExpand Up@@ -2034,14 +2034,21 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// readable label for select/boolean fields rather than the raw value
// (e.g. "In Progress" instead of "in_progress", "Yes" instead of "true").
const groupValueFormatter = React.useMemo(() => {
const grouping = schema.grouping;
if (!grouping?.fields?.length) return undefined;
// [objectui#7217] ONE normalized entry list, shared with the
// `useGroupedData` call below. Reading `grouping.fields` raw here threw
// `TypeError: Cannot read properties of null (reading 'field')` on a null
// hole — the whole grid gone, during render, before any projection was
// built. `usableGroupingFields` admits exactly the entries
// `collectGroupingFieldRefs` harvests into the projection, so the grid can
// never group by an entry the query never asked for.
const groupingFields = usableGroupingFields(schema.grouping?.fields);
if (!groupingFields.length) return undefined;

// Per-field { value -> label } lookup, plus a per-field type so we can
// handle booleans / dates / users without dedicated option lists.
const lookup = new Map<string, { type?: string; options?: Map<string, string> }>();

for (const gf of grouping.fields) {
for (const gf of groupingFields) {
const fieldName = gf.field;
const objectDefField = objectSchema?.fields?.[fieldName];
// Try to find a column override matching this field for type/options
Expand Down
171 changes: 171 additions & 0 deletions packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
/**
* 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#7217 — a `null` entry in `grouping.fields[]` must not take the grid
* down.
*
* ## The defect
*
* `ObjectGrid`'s `groupValueFormatter` memo walked `grouping.fields` and read
* `gf.field` off every entry with no guard, so a single `null` hole threw
* `TypeError: Cannot read properties of null (reading 'field')` during render
* — the whole grid, gone, before any projection was built.
*
* `useGroupedData` is a SECOND dereference site of the same list (`const f =
* fields[depth]` then `f.field` / `f.order` / `f.collapsed`), so guarding the
* memo alone only moves the crash one call downstream. Both sites now read one
* normalized entry list — `usableGroupingFields` — and this file pins both:
* ablating either guard on its own turns these tests red.
*
* ## Why a guard, not a schema change (the reachability measurement)
*
* Author-time validation ALREADY refuses a null entry — `GroupingConfigSchema`
* types `fields` as an array of `$strict` objects, so `{ fields: [null] }`
* fails with `invalid_type` at `fields.0`, and objectui's own `ListViewSchema`
* inherits that by reference. The last two `it`s below measure exactly that,
* so the claim is checked rather than asserted in prose.
*
* That makes this a defensive guard rather than a validation gap — but the
* crash is still live, because NOTHING ON THE RENDER PATH RUNS THAT VALIDATOR.
* `ObjectGrid` reads `schema.grouping` straight off its props; `@object-ui/core`'s
* `validateSchema` is structural and never looks at the `grouping` key. A
* runtime-composed or generated schema therefore reaches the memo unparsed,
* which is the reachable path this pin closes.
*
* ## Test-source note
*
* The root vitest config aliases `@object-ui/*` to each package's `src`, and
* this file imports `../ObjectGrid` relatively, so no build step stands
* between the edit and the run — the ablation recorded in the PR body reads
* source directly.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';

import { ObjectGrid } from '../ObjectGrid';
import { registerAllFields } from '@object-ui/fields';
import { ActionProvider } from '@object-ui/react';
import { GroupingConfigSchema } from '@objectstack/spec/ui';
import { ListViewSchema } from '@object-ui/types/zod';

registerAllFields();

const ROWS = [
{ id: '1', name: 'Row 1', active: true },
{ id: '2', name: 'Row 2', active: false },
{ id: '3', name: 'Row 3', active: true },
];

/**
* Mount a grid whose `grouping.fields` is exactly `fields`.
*
* `data.provider: 'value'` keeps the rows inline, so `useGroupedData` runs over
* a NON-EMPTY array: the hook's dereference is only reached once there is a row
* to bucket, and a pin mounted on empty data would leave that half unmeasured.
*/
function renderGrid(fields: unknown[]) {
const schema: any = {
type: 'object-grid',
objectName: 'test_object',
columns: [
{ field: 'name', label: 'Name' },
{ field: 'active', label: 'Active', type: 'boolean' },
],
data: { provider: 'value', items: ROWS },
grouping: { fields },
};
return render(
<ActionProvider>
<ObjectGrid schema={schema} />
</ActionProvider>,
);
}

const groupLabels = () =>
Array.from(document.querySelectorAll('.group-label')).map((el) => el.textContent);

afterEach(() => cleanup());

describe('ObjectGrid — a null `grouping.fields[]` entry never crashes the grid (objectui#7217)', () => {
// ── PIN 1: THE DEFECT ───────────────────────────────────────────────────
it('renders instead of throwing when the only grouping entry is null', async () => {
expect(
() => renderGrid([null]),
'a null hole in `grouping.fields[]` threw a TypeError out of render and '
+ 'took the whole grid down before any projection was built',
).not.toThrow();
await waitFor(() => expect(document.body.textContent).toContain('Row 1'));
expect(document.body.textContent).toContain('Row 2');
expect(document.body.textContent).toContain('Row 3');
});

it('renders instead of throwing when the only grouping entry is undefined', async () => {
// Same defect class as `null`: a hole a trailing comma or a sparse
// generator leaves behind, which no dereference can survive.
expect(() => renderGrid([undefined])).not.toThrow();
await waitFor(() => expect(document.body.textContent).toContain('Row 1'));
});

// ── PIN 2: THE SURVIVING ENTRY STILL GROUPS ─────────────────────────────
// The guard must DROP the unusable entry, not abandon grouping altogether —
// otherwise a single hole silently degrades a working grouped view into a
// flat one, which is the objectui#7179 class of silent wrong answer.
it('still groups by the usable entry when a null precedes it', async () => {
expect(() => renderGrid([null, { field: 'active' }])).not.toThrow();
await waitFor(() => expect(groupLabels().length).toBeGreaterThan(0));
expect(groupLabels()).toEqual(expect.arrayContaining(['Yes', 'No']));
});

it('still groups by the usable entry when a null follows it at a deeper level', async () => {
// The second entry is the NESTED level, so this reaches `buildLevel`'s
// recursion rather than only its depth-0 call.
expect(() => renderGrid([{ field: 'active' }, null])).not.toThrow();
await waitFor(() => expect(groupLabels().length).toBeGreaterThan(0));
expect(groupLabels()).toEqual(expect.arrayContaining(['Yes', 'No']));
});

// ── PIN 3: REACHABILITY — the validator refuses it, the render path never runs one ──
it('author-time validation already refuses a null entry (`@objectstack/spec`)', () => {
const refused = GroupingConfigSchema.safeParse({ fields: [null] });
expect(refused.success).toBe(false);
expect(refused.success === false && refused.error.issues[0]).toMatchObject({
code: 'invalid_type',
path: ['fields', 0],
});
// Positive control: the well-formed entry the same schema accepts, so a
// schema that refused EVERYTHING could not pass the assertion above.
expect(GroupingConfigSchema.safeParse({ fields: [{ field: 'active' }] }).success).toBe(true);
});

it("objectui's own `ListViewSchema` inherits that refusal by reference", () => {
const refused = ListViewSchema.safeParse({
type: 'list-view',
objectName: 'test_object',
grouping: { fields: [null] },
});
expect(refused.success).toBe(false);
expect(
refused.success === false
&& refused.error.issues.some((i) => i.path.join('.') === 'grouping.fields.0'),
'`grouping` is imported into `ListViewSchema` from the spec by reference, so '
+ 'the entry-shape refusal must arrive with it',
).toBe(true);
// Positive control: the same payload with a well-formed entry is accepted,
// so the refusal above is about the null entry and not about the envelope.
expect(
ListViewSchema.safeParse({
type: 'list-view',
objectName: 'test_object',
grouping: { fields: [{ field: 'active' }] },
}).success,
).toBe(true);
});
});
71 changes: 67 additions & 4 deletions packages/plugin-grid/src/useGroupedData.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -207,6 +207,64 @@ function compareGroups(a: string, b: string, order: 'asc' | 'desc'): number {
return order === 'desc' ? -cmp : cmp;
}

/**
* One entry of the spec's `grouping.fields[]` as AUTHORED — `field` plus the
* optional `order` / `collapsed`.
*
* ⚠️ NOT the same type as `@object-ui/components`' `GroupingFieldEntry`, which
* is the grouping EDITOR's fully-populated value shape and requires `order`
* and `collapsed`. This one is `z.input` of the spec schema, where both carry
* defaults and are therefore optional, so the two are structurally different
* and each keeps its own name (objectui#6273 — one authority per exported
* name; a shared spelling for two shapes is the collision that gate exists to
* catch).
*/
export type UsableGroupingField = NonNullable<GroupingConfig['fields']>[number];

/**
* The `grouping.fields[]` entries a grid can actually group by (objectui#7217).
*
* ## Why this exists
*
* `grouping` is authored JSON and reaches the renderer unparsed — `ObjectGrid`
* reads `schema.grouping` straight off its props and `@object-ui/core`'s
* `validateSchema` is structural and never looks at the key. A `null` hole in
* the array (a trailing comma, a sparse generator, an agent-written block) was
* therefore dereferenced twice with no guard: once by `ObjectGrid`'s
* `groupValueFormatter` memo and once by this hook's `buildLevel`. Both threw
* `TypeError: Cannot read properties of null (reading 'field')` and took the
* whole grid down during render.
*
* ## The admission rule is the harvester's, deliberately
*
* An entry is usable when it is an object carrying a non-empty string `field`
* — exactly the entries `collectGroupingFieldRefs` (`@object-ui/core`) harvests
* into the projection. Keeping the two sets equal is the point: an entry the
* grid grouped by but the projection ignored would be fetched as `undefined`
* on every row and bucket every record into one `(empty)` group, which is the
* silent wrong answer objectui#7179 closed. This is a defensive normalizer,
* NOT a lenient alias — no off-spec spelling is taught to mean anything here;
* unusable entries are dropped, never coerced.
*
* ## Dropping the entry, not the grouping
*
* One bad entry must not flatten a working grouped view: the usable entries
* still group, at the levels they still occupy.
*
* @param fields - `grouping.fields` in any authored state.
* @returns The usable entries, in order, with their `order` / `collapsed`
* intact — the harvester answers with field NAMES, which is why this cannot
* simply route through it.
*/
export function usableGroupingFields(fields: unknown): UsableGroupingField[] {
if (!Array.isArray(fields)) return [];
return fields.filter((entry): entry is UsableGroupingField => {
if (entry === null || typeof entry !== 'object') return false;
const name = (entry as { field?: unknown }).field;
return typeof name === 'string' && name.trim() !== '';
});
}

/**
* Hook that groups a flat data array by the fields specified in GroupingConfig.
*
Expand All@@ -227,14 +285,19 @@ export function useGroupedData(
aggregations?: AggregationConfig[],
formatValue?: GroupValueFormatter,
): UseGroupedDataResult {
const fields = config?.fields;
const isGrouped = !!(fields && fields.length > 0);
// [objectui#7217] The SAME normalized list `ObjectGrid`'s formatter memo
// reads. Memoized on the raw array rather than on `config`: hosts rebuild
// the `{ grouping }` object literal every render, so keying on `config`
// would hand `groups` a fresh array identity on every render.
const rawFields = config?.fields;
const fields = useMemo(() => usableGroupingFields(rawFields), [rawFields]);
const isGrouped = fields.length > 0;

// Track which group keys have been explicitly toggled by the user.
const [toggledKeys, setToggledKeys] = useState<Record<string, boolean>>({});

const groups: GroupEntry[] = useMemo(() => {
if (!isGrouped || !fields) return [];
if (!isGrouped) return [];

/**
* Recursively build a tree of groups for the slice of rows at the current
Expand DownExpand Up@@ -308,7 +371,7 @@ export function useGroupedData(
const lastSegment = key.split('__').pop() || '';
const depthMatch = /^(\d+):/.exec(lastSegment);
const depth = depthMatch ? Number(depthMatch[1]) : 0;
const fieldDefault = !!fields?.[depth]?.collapsed;
const fieldDefault = !!fields[depth]?.collapsed;
return {
...prev,
[key]: prev[key] !== undefined ? !prev[key] : !fieldDefault,
Expand Down
Loading