Skip to content
27 changes: 27 additions & 0 deletions .changeset/20831-object-block-grouping-typed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@objectstack/spec': minor
---

The `grouping` prop of the `object-grid` and `object-kanban` page blocks is now judged by the list view's own `GroupingConfigSchema`, so a padded grouping field name or a wrong-shaped value is refused on these blocks exactly as it is on `list-view` (#20831).

Clause-②: yes (narrowing)

<!-- adr-0087: registered ui-object-block-grouping-config-typed -->

**BREAKING**: `ComponentPropsMap['object-grid'].grouping` and `ComponentPropsMap['object-kanban'].grouping` were `z.unknown()`, so any value parsed. Both now take `GroupingConfigSchema` by reference: `{ fields: [{ field, order?, collapsed? }, ...] }`, at least one entry, each `field` the stored name with no leading or trailing whitespace. Both renderers already read exactly that shape: the grid groups by every `grouping.fields[i].field` and reads `order` / `collapsed`, and the kanban board takes `grouping.fields[0].field` as its swimlane field when no `swimlaneField` is authored. A value outside it validated green before and rendered one `(empty)` group on the grid, or one swimlane holding every card on the board, with no error.

What is refused now, and the one-line fix for each:

| Authored `grouping` | Refused as | Write instead |
| --- | --- | --- |
| `{ fields: [{ field: ' business_unit ' }] }` | `custom` at `grouping.fields.0.field`, naming the received value | `{ fields: [{ field: 'business_unit' }] }` |
| `'business_unit'` (a bare string) | `invalid_type` at `grouping` | `{ fields: [{ field: 'business_unit' }] }` |
| `42`, `true`, or any other non-object | `invalid_type` at `grouping` | delete the key; it never grouped anything |
| `{ fields: [] }` | `too_small` at `grouping.fields` | delete the key |
| `{ fields: [...], showCounts: true }` (an undeclared key) | `unrecognized_keys` at `grouping` | delete the undeclared key |

On `object-kanban`, an authored `swimlaneField` still wins over `grouping`; when both are set, deleting `grouping` is the whole migration. A fully-spelled grouping parses byte-identically; a short entry `{ field }` parses clean and gains the list view's defaults (`order: 'asc'`, `collapsed: false`), which is how the grid already read it.

Where it surfaces: the component-props gate reports a refused value as a `component-props-invalid` finding at the offending path on `os validate`, `os build` and `os lint` (advisory, as every finding of that gate is). A page component's `properties` are not parsed on the metadata save path, so a stored page keeps loading and rendering as it does today until its source is fixed; the ADR-0087 semantic entry `ui-object-block-grouping-config-typed` carries the same table for `os migrate meta`.

The published JSON Schemas for `ObjectGridProps` and `ObjectKanbanProps` now describe the `grouping` shape; the non-padded field-name rule is a runtime refinement the JSON Schema does not express, recorded for both as `grouping.fields.element.field` in `dropped-refinements.baseline.json`, beside the same site on every list-view schema.
16 changes: 14 additions & 2 deletions content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -705,7 +705,7 @@ Sort field and direction pair
| **searchableFields** | `string[]` | optional | Fields the toolbar search queries; a non-empty list enables search |
| **showSearch** | `boolean` | optional | Show the search box (read only when `searchableFields` is absent) |
| **rowHeight** | `any` | optional | Row density mode (e.g. compact / comfortable) |
| **grouping** | `any` | optional | Row grouping config |
| **grouping** | `{ fields: object[] }` | optional | Row grouping config |
| **aggregations** | `any` | optional | Group aggregation config (sum/avg/… per column) |
| **conditionalFormatting** | `any` | optional | Conditional row/cell formatting rules |
| **rowColor** | `any` | optional | Row color rules |
Expand Down Expand Up @@ -757,6 +757,12 @@ Sort field and direction pair
| **field** | `string` | ✅ | Field name to sort by |
| **order** | `Enum<'asc' \| 'desc'>` | ✅ | Sort direction |

### Nested Shape: `ObjectGridProps.grouping`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **fields** | `{ field: string; order: Enum<'asc' \| 'desc'>; collapsed: boolean }[]` | ✅ | Fields to group by, in nesting order — the first entry is the outermost group and each later entry nests one level deeper (at least one field); the same order as the group header query's `groupBy` |

### Nested Shape: `ObjectGridProps.data[provider='object']`

| Property | Type | Required | Description |
Expand Down Expand Up @@ -806,7 +812,7 @@ Sort field and direction pair
| **titleField** | `string` | optional | Legacy fallback for `cardTitle` (the board reads `cardTitle \|\| titleField`). Prefer `cardTitle` |
| **cardFields** | `string[]` | optional | Fields rendered on each card |
| **swimlaneField** | `string` | optional | Field for horizontal swimlanes (in addition to columns) |
| **grouping** | `any` | optional | View grouping config; its first field is the swimlane fallback |
| **grouping** | `{ fields: object[] }` | optional | View grouping config; its first field is the swimlane fallback |
| **quickAdd** | `never` | optional | [REMOVED] `object-kanban` property `quickAdd` was removed in @objectstack/spec 17 (ADR-0049) — the board forwarded it, but the per-column affordance is gated on both `quickAdd` and `onQuickAdd`, and `onQuickAdd` is a host-supplied function JSON cannot carry and no producer ever put on an `object-kanban` node, so authoring it was a parse-clean no-op. Delete the key. The quick-add control is unchanged on the `kanban-ui` block, where a React host supplies the `onQuickAdd` slot the control needs. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
| **coverImageField** | `string` | optional | Image field rendered as the card cover |
| **conditionalFormatting** | `any` | optional | Card conditional formatting rules |
Expand All @@ -822,6 +828,12 @@ View filter rule
| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator |
| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. |

### Nested Shape: `ObjectKanbanProps.grouping`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **fields** | `{ field: string; order: Enum<'asc' \| 'desc'>; collapsed: boolean }[]` | ✅ | Fields to group by, in nesting order — the first entry is the outermost group and each later entry nests one level deeper (at least one field); the same order as the group header query's `groupBy` |

### Nested Shape: `ObjectKanbanProps.navigation`

| Property | Type | Required | Description |
Expand Down
8 changes: 5 additions & 3 deletions packages/spec/dropped-refinements.baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"measured": {
"zod": "4.4.3",
"publishedSchemasWithDroppedRefinements": 212,
"droppedRefinementSites": 610,
"droppedRefinementSites": 612,
"refinementSitesThatDidProject": 369,
"refinementSitesWithNoJsonFormToCompare": 0
},
Expand Down Expand Up @@ -1326,12 +1326,14 @@
},
"ui/ObjectGridProps": {
"sites": [
"filter.element"
"filter.element",
"grouping.fields.element.field"
]
},
"ui/ObjectKanbanProps": {
"sites": [
"filter.element"
"filter.element",
"grouping.fields.element.field"
]
},
"ui/ObjectListView": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

// The grouping prop on the two object blocks whose renderers read a grouping
// config. Typed by reference to the list view's own GroupingConfigSchema, so
// every door that carries a grouping judges it with the one schema. Deliberately
// NOT a D2 conversion: a padded field name is refused with guidance naming the
// field, never trimmed in silence.
export const entry: SemanticMigration = {
id: 'ui-object-block-grouping-config-typed',
// No backticks in `surface` — build-upgrade-guide renders it inside a code
// span already, and a nested backtick would close it.
surface:
'the grouping property of the object-grid and object-kanban page blocks '
+ "(ComponentPropsMap['object-grid' | 'object-kanban'].grouping), which was z.unknown and "
+ 'therefore accepted any value: a padded field name such as '
+ "{ fields: [{ field: ' business_unit ' }] }, a number, a bare field-name string, an "
+ 'empty fields list, or keys the grouping config does not declare',
replacement:
'the grouping config a list view carries, `GroupingConfigSchema` from '
+ '`@objectstack/spec/ui`: `{ fields: [{ field, order?, collapsed? }, ...] }` with at least '
+ 'one entry, each `field` naming the record field exactly as it is stored, with no leading '
+ 'or trailing whitespace, `order` one of `asc` / `desc` and `collapsed` a boolean. A padded '
+ "name is rewritten unpadded (`' business_unit '` becomes `'business_unit'`); a bare "
+ "string `'business_unit'` becomes `{ fields: [{ field: 'business_unit' }] }`; an empty "
+ '`fields` list, a number, or any other value is deleted, since it never grouped anything. '
+ 'On `object-kanban`, `swimlaneField` still wins when both are authored, and deleting '
+ '`grouping` is the whole migration there when `swimlaneField` is set',
reason:
'Both blocks\' renderers read the list view\'s grouping shape and nothing else: the grid '
+ 'groups its rows by every `grouping.fields[i].field` (its server-side group header query '
+ 'and its row projection) and reads `order` and `collapsed` per level, and the kanban board '
+ 'takes `grouping.fields[0].field` as its swimlane field when no `swimlaneField` is '
+ 'authored, looking that raw name up on every card. The list view has refused a padded '
+ 'grouping field name since protocol 17.5, and a list view\'s `grouping` is a closed shape; '
+ 'these two doors declared the same prop as `z.unknown`, so the same value that list view '
+ 'refuses validated green here and rendered wrong with no error — the grid showed one '
+ '`(empty)` group holding every row, the board one swimlane holding every card. The prop is '
+ 'kept, not retired: the board\'s fallback is a live reader of the grouping config. '
+ 'The rewrite is left to the author on purpose: a trimming rule would make a padded and an '
+ 'unpadded name silently equivalent, which is the consumer tolerance the contract refuses, '
+ 'and a bare string, a number or an empty list has no mapping that says what grouping was '
+ 'meant. Metadata AT REST is left exactly as stored — `properties` on a page component is '
+ 'not parsed on the save path, so a stored page keeps loading and renders as it does today; '
+ 'the component-props gate reports such a value as an advisory `component-props-invalid` '
+ 'finding at the offending path on `os validate`, `os build` and `os lint`, a padded name '
+ 'with the received value and the unpadded name to write. ADR-0049 / ADR-0087.',
acceptanceCriteria:
'Every `object-grid` and `object-kanban` node in your pages either omits `grouping` or '
+ 'carries `{ fields: [...] }` with at least one entry whose `field` is the unpadded stored '
+ 'name. `os validate` reports no `component-props-invalid` finding under '
+ '`properties.grouping` for these blocks. A well-formed grouping parses byte-identically to '
+ 'before when `order` and `collapsed` are spelled out; a short entry `{ field }` parses clean '
+ 'and gains the list view\'s defaults (`order: asc`, `collapsed: false`), which is how the '
+ 'grid already read it. After the rewrite, a grid that showed one `(empty)` group shows one '
+ 'group per value of that field, and a board that showed one swimlane shows one swimlane per value — '
+ 'check that this is the grouping you meant.',
};
55 changes: 55 additions & 0 deletions packages/spec/src/migrations/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17882,6 +17882,61 @@ const step18: MigrationStep = {
+ 'anything), and behaviour that seems to need one is a renderer capability request '
+ 'against objectui, not a metadata key.',
},
// The grouping prop on the two object blocks whose renderers read a grouping
// config. Typed by reference to the list view's own GroupingConfigSchema, so
// every door that carries a grouping judges it with the one schema. Deliberately
// NOT a D2 conversion: a padded field name is refused with guidance naming the
// field, never trimmed in silence.
{
id: 'ui-object-block-grouping-config-typed',
// No backticks in `surface` — build-upgrade-guide renders it inside a code
// span already, and a nested backtick would close it.
surface:
'the grouping property of the object-grid and object-kanban page blocks '
+ "(ComponentPropsMap['object-grid' | 'object-kanban'].grouping), which was z.unknown and "
+ 'therefore accepted any value: a padded field name such as '
+ "{ fields: [{ field: ' business_unit ' }] }, a number, a bare field-name string, an "
+ 'empty fields list, or keys the grouping config does not declare',
replacement:
'the grouping config a list view carries, `GroupingConfigSchema` from '
+ '`@objectstack/spec/ui`: `{ fields: [{ field, order?, collapsed? }, ...] }` with at least '
+ 'one entry, each `field` naming the record field exactly as it is stored, with no leading '
+ 'or trailing whitespace, `order` one of `asc` / `desc` and `collapsed` a boolean. A padded '
+ "name is rewritten unpadded (`' business_unit '` becomes `'business_unit'`); a bare "
+ "string `'business_unit'` becomes `{ fields: [{ field: 'business_unit' }] }`; an empty "
+ '`fields` list, a number, or any other value is deleted, since it never grouped anything. '
+ 'On `object-kanban`, `swimlaneField` still wins when both are authored, and deleting '
+ '`grouping` is the whole migration there when `swimlaneField` is set',
reason:
'Both blocks\' renderers read the list view\'s grouping shape and nothing else: the grid '
+ 'groups its rows by every `grouping.fields[i].field` (its server-side group header query '
+ 'and its row projection) and reads `order` and `collapsed` per level, and the kanban board '
+ 'takes `grouping.fields[0].field` as its swimlane field when no `swimlaneField` is '
+ 'authored, looking that raw name up on every card. The list view has refused a padded '
+ 'grouping field name since protocol 17.5, and a list view\'s `grouping` is a closed shape; '
+ 'these two doors declared the same prop as `z.unknown`, so the same value that list view '
+ 'refuses validated green here and rendered wrong with no error — the grid showed one '
+ '`(empty)` group holding every row, the board one swimlane holding every card. The prop is '
+ 'kept, not retired: the board\'s fallback is a live reader of the grouping config. '
+ 'The rewrite is left to the author on purpose: a trimming rule would make a padded and an '
+ 'unpadded name silently equivalent, which is the consumer tolerance the contract refuses, '
+ 'and a bare string, a number or an empty list has no mapping that says what grouping was '
+ 'meant. Metadata AT REST is left exactly as stored — `properties` on a page component is '
+ 'not parsed on the save path, so a stored page keeps loading and renders as it does today; '
+ 'the component-props gate reports such a value as an advisory `component-props-invalid` '
+ 'finding at the offending path on `os validate`, `os build` and `os lint`, a padded name '
+ 'with the received value and the unpadded name to write. ADR-0049 / ADR-0087.',
acceptanceCriteria:
'Every `object-grid` and `object-kanban` node in your pages either omits `grouping` or '
+ 'carries `{ fields: [...] }` with at least one entry whose `field` is the unpadded stored '
+ 'name. `os validate` reports no `component-props-invalid` finding under '
+ '`properties.grouping` for these blocks. A well-formed grouping parses byte-identically to '
+ 'before when `order` and `collapsed` are spelled out; a short entry `{ field }` parses clean '
+ 'and gains the list view\'s defaults (`order: asc`, `collapsed: false`), which is how the '
+ 'grid already read it. After the rewrite, a grid that showed one `(empty)` group shows one '
+ 'group per value of that field, and a board that showed one swimlane shows one swimlane per value — '
+ 'check that this is the grouping you meant.',
},
{
id: 'ui-object-grid-page-size-positive-integer-refused',
surface: '`object-grid` page-component page sizes '
Expand Down
Loading
Loading