You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit b00fc55
Browse filesBrowse the repository at this point in the historyBrowse files
feat(spec,lint): a list-view grouping level can order its groups by another field (grouping.fields[].orderBy) (#25828)
Fixes#25786
Clause-②: yes
Metadata-protocol: added
A list-view grouping level can now order its groups by another field of
the same object: `{ field: 'period_label', orderBy: 'period_start',
order: 'desc' }`. Before this, a period label grouped as text (`Nov
2026`, `Oct 2026`, `Q1 2027`, `Q4 2025`), so HotCRM's forecast list
carried a second, outer grouping level on `period_start` and showed two
header rows per forecast.
## What changes
- **Spec (`packages/spec/src/ui/view.zod.ts`).** `GroupingFieldSchema`
gains `orderBy?: string`. It is optional with no default; absent, or
naming the grouping field itself, means the level orders by its own key
exactly as before. Its name is refused padded, as `field`'s is (#17360's
ruling). The rule is a native `.regex()`, so the published JSON Schema
states it as `pattern` instead of adding an `x-dropped-refinements`
site.
- **Compiler (`packages/spec/src/ui/view-grouping-query.ts`).**
`compileListViewGroupQuery` adds one node per ordered level, `min` over
`orderBy` for `order: 'asc'` and `max` for `'desc'`, aliased by
`columnSummaryAlias` (`min_period_start`). Each group sits where its
first row would sit if the rows were sorted by `orderBy`.
- The grouping key stays the only `groupBy` column, so there is still
one header per label.
- A column summary declaring the same `min` is the same node.
- An alias landing on a grouped field's column is `alias_collision`.
- A blank `orderBy` is `grouping_order_by_blank` (`INVALID_QUERY` /
400).
- In a multi-level grouping the outer level folds exactly (the min of
mins, the max of maxes).
- `depth` limits the nodes to the grouped levels.
- A new export, `listViewGroupOrderColumn(grouping, level)`, names the
header-row column a level sorts by.
- **Lint (`packages/lint/src/validate-list-view-field-refs.ts`).**
`grouping.fields[].orderBy` is judged the way the server judges it, on
`os validate`, `os build` and the runtime view save door:
- an unknown name is `list-view-field-unknown` (error);
- a dotted path is `list-view-field-dotted` (error), on a new
`aggregate` axis;
- a type `min` / `max` cannot order on every backend is the new
`list-view-group-order-unorderable` (error). The pair is asked of
`isAggregateCompatibleWithFieldType`, as the engine's type door asks it,
so the string classes (collation-dependent) and the virtual `formula`
are refused, along with every other type outside the numeric, temporal
and boolean classes.
- The rule has an `os explain` entry.
## What the server needs (measured, as the order asked)
No new server code. The group sort stays the consumer's
(`EngineAggregateOptions` carries no `orderBy`, and `order` was already
applied by the consumer over the header rows). What the header rows
lacked was the value to sort by, and the existing aggregate door already
serves it:
- `POST /api/v1/data/:object/query` → `protocol.findData` →
`engine.aggregate` answers `min` / `max` over `date` and `datetime`
operands on both tiers. That is driver-sql pushdown, and the in-memory
`applyInMemoryAggregation` behind a driver that does not push down.
- The values are identical in value and `typeof` per column on both
tiers, asc and desc. This is pinned in
`packages/rest/src/list-view-grouping-query-door.test.ts` §11, with the
tier asserted by the fork counters, not assumed.
- The door refuses what the lint refuses, `400 INVALID_FIELD`:
- an unknown or dotted operand at `findData`'s ingress
(`assertAggregationFieldsExist`);
- an unorderable type at `engine.aggregate`'s type door.
- §11 pins a `text` operand and a dotted `period_start.year`.
- Paged groups: the header query is compiled unpaged, so the consumer
sorts the whole header set. A `limit` on the header query is sliced by
`findData` in emission order before any sort, so a paged header set
honours neither `order` nor `orderBy`. That is the limit already
recorded on `GroupingConfigSchema` ("Header cardinality is unbounded"),
unchanged here; bounding it is `orderBy` + `limit` on the aggregate
verb, an engine-contract card of its own.
## Evidence
Final head `21dc9ad7`. `main` was merged twice: once before the review
(`6679644a`), and again after the sibling spec card #25755 landed
(`abebc4e1`). There were no conflicts, and the whole workspace was
rebuilt each time (`pnpm build`, 76/76).
- Tests on the final head:
- `@objectstack/spec`: full suite, 800 files, 23119 passed / 1 todo.
- `@objectstack/lint`: full suite, 163 files, 7331/7331.
- `@objectstack/rest`: `list-view-grouping-query-door.test.ts`, 53/53 (9
new in §11).
- Ablations, each through `scripts/ablation-replace.mjs`, with the file
restored to its HEAD blob and `git diff HEAD` empty afterwards:
- lint verdict forced silent (`|| true` on the compatibility check): 8
red;
- compiler order nodes dropped (`...orders.slice(0, 0)`): 5 red;
- the padded-name regex widened to `/[\s\S]*/`: 6 red.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands` on the final
diff derived 114 commands. All 114 ran on the final head, each exit code
recorded before any pipe: 114 × exit 0. `--ran` reconciles to "114
derived, 114 run, 0 NOT-MEASURED, 0 UNRUN".
- Two prerequisites were met first: a full `pnpm build`, and the
self-test fixture commit fetched into the shallow clone.
- Also run, all exit 0: the five roster gates whose roster sits under a
path of this diff (`check-changeset-fixed`, `check:meta-url-spelling`,
`check:authz-resolver`, `check:error-code-casing`,
`check:filter-alias-parity`), `check:error-code-provenance`, and
`typecheck` for spec, lint and rest.
- Dogfood battery: n/a. No `!` in the title, and `Clause-②` carries no
`(narrowing)` arm.
## Review
An independent full review of the whole diff (a fresh reviewer at the
contract-review tier) returned CHANGES-REQUIRED with five findings. All
five are fixed in `5d64c378`, `6a3bf131` and `d37faf8f`, and the delta
re-review's one nit in `5df57e7f`:
- a padded `orderBy` was accepted by the schema;
- the unknown and dotted refusals were attributed to the wrong door;
- the paged-header case was not stated;
- the tie direction was unspecified;
- the refused-type list was narrower than the table.
The delta re-review verdict is posted as a comment on this PR.
## Acceptance notes
- **objectui reads nothing yet.** `orderBy` is declared and compiled,
but the console grid ignores it until the objectui child lands: render
the label from `field`, order the headers by `listViewGroupOrderColumn`,
nulls last, ties by key in the same direction. That child card is filed
in objectui with `Blocked-by:` this PR. HotCRM's one-line view edit
(dropping its outer `period_start` level) comes after it, and is not
this PR.
- **A dotted `grouping.fields[].field` is not judged by lint** (pinned
silent in `validate-list-view-field-refs.test.ts`), while the data door
refuses it: `400 INVALID_FIELD`, "Unknown field 'period_start.year' … a
related record's column cannot be a grouping target", measured through
the §11 fixture. This predates the PR and is out of scope here; it is
listed in the report.
- **`field` keeps its `superRefine` and its
`dropped-refinements.baseline.json` row**, while `orderBy` publishes the
same non-padded rule as a JSON Schema `pattern`. Moving `field` to the
same `.regex()` would shrink the ledger, but that is a separate change.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01E1GNboWQRhh5vidwh2hxTY)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat(spec,lint): a list-view grouping level can order its groups by another field (`grouping.fields[].orderBy`)
7
+
8
+
Clause-②: yes
9
+
Metadata-protocol: added
10
+
11
+
-**Before.** A grouping level ordered its groups only by its own key. A period label grouped as text (`Nov 2026`, `Oct 2026`, `Q1 2027`, `Q4 2025`), so putting periods in time order took a second, outer grouping level on the period start, which showed two header rows for every group.
12
+
-**Now.**`GroupingFieldSchema` gains `orderBy`, the name of a field on the same object whose value orders this level's groups: `{ field: 'period_label', orderBy: 'period_start', order: 'desc' }`. When it is absent, or names the grouping field itself, the groups order by their own key as before. Nothing is defaulted in.
13
+
-**What the header query carries.** The consumer still sorts the header rows. `compileListViewGroupQuery` now adds the value to sort by: one `min` node over `orderBy` for `order: 'asc'`, or one `max` node for `'desc'`, under `min_<field>` / `max_<field>`. Each group sits where its first row would sit if the rows were sorted by `orderBy` in `order`. The grouping key stays the only `groupBy` column, so there is one header per label. A column summary that already declares the same `min` is the same node and the same column. In a multi-level grouping the outer level's value folds exactly from the leaf rows (the `min` of mins, the `max` of maxes).
14
+
-**`listViewGroupOrderColumn(grouping, level)`** (`@objectstack/spec/ui`) names the header-row column a level sorts by: the grouping field itself, or the order node's alias. A consumer reads this instead of rebuilding the alias. A group whose order value is `null` sorts last in both directions, and equal values fall back to the group key in the same direction.
15
+
- **Refused, at compile and at authoring.** An empty `orderBy` is refused by the schema (`too_small`), and a padded one, whitespace-only included, by name, as a padded `field` is, because the name is compiled verbatim. The compiler refuses a blank one in-process (`grouping_order_by_blank`, `INVALID_QUERY` / 400). An order alias that lands on a grouped field's own column is `alias_collision`. `os validate`, `os build` and the runtime save door judge `orderBy` the way the server doors do. An unknown name is `list-view-field-unknown`. A dotted path is `list-view-field-dotted`, because the header query compiles no join. A field whose type `min` / `max` cannot order on every backend is the new `list-view-group-order-unorderable` (`error`). That covers the string classes, whose order depends on the collation, the virtual `formula`, and every other type outside the numeric, temporal and boolean classes. Each of these is refused `400 INVALID_FIELD` on the server, so the grouped view would render no group: an unknown or dotted name by the data door's ingress (`findData`), and an unorderable type by `engine.aggregate`'s type door.
16
+
-**Unchanged.**`order` and `collapsed` keep their meaning and defaults. The row page query (`compileListViewGroupRowsQuery`) does not change. `EngineAggregateOptions` gains no `orderBy`, because the group sort stays the consumer's. That sort is right only over the whole header set, which is what the compiler asks for: a `limit` on the header query is sliced by `findData` before any sort, so a paged header set honours neither `order` nor `orderBy`, the limit already recorded on `GroupingConfigSchema`. The new key is purely additive, so a grouping declared today compiles to exactly the same query.
Copy file name to clipboardExpand all lines: content/docs/references/ui/component.mdx
+2-2Lines changed: 2 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -887,7 +887,7 @@ Sort field and direction pair
887
887
888
888
| Property | Type | Required | Description |
889
889
| :--- | :--- | :--- | :--- |
890
-
|**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`|
890
+
|**fields**|`{ field: string; order?: Enum<'asc' \| 'desc'>; orderBy?: string; 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`|
|**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`|
1040
+
|**fields**|`{ field: string; order?: Enum<'asc' \| 'desc'>; orderBy?: string; 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`|
Copy file name to clipboardExpand all lines: content/docs/references/ui/view.mdx
+7-5Lines changed: 7 additions & 5 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -613,14 +613,15 @@ Record grouping configuration — SERVER-SIDE: the set of groups and every numbe
613
613
614
614
| Property | Type | Required | Description |
615
615
| :--- | :--- | :--- | :--- |
616
-
|**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`|
616
+
|**fields**|`{ field: string; order?: Enum<'asc' \| 'desc'>; orderBy?: string; 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`|
617
617
618
618
### Nested Shape: `GroupingConfig.fields[number]`
619
619
620
620
| Property | Type | Required | Description |
621
621
| :--- | :--- | :--- | :--- |
622
622
|**field**|`string`| ✅ | Field name to group by — one `groupBy` column of the group header query; the header row carries its raw stored value (null for the empty group). NO leading or trailing whitespace: the name is compiled into the aggregate query verbatim, so a padded spelling buckets every row into one empty group instead of grouping them. |
623
-
|**order**|`Enum<'asc' \| 'desc'>`| optional (default: `"asc"`) | Group sort order — applied by the consumer over the header rows (the aggregate query carries no orderBy) |
623
+
|**order**|`Enum<'asc' \| 'desc'>`| optional (default: `"asc"`) | Group sort order — applied by the consumer over the header rows (the aggregate query itself sorts nothing): by the group key, or by the `orderBy` value each header row carries when `orderBy` is set |
624
+
|**orderBy**|`string`| optional | Field whose value orders this level's groups, when that is not the grouping field — a field of the same object; default: the grouping field itself, so the groups order by their own key. The group header query then carries, per group, the smallest value of this field for `order: asc` and the largest for `desc` (each group sits where its first row would if the rows were sorted by this field), and the consumer sorts the header rows by it; a group with no value here sorts last in both directions, and equal values fall back to the group key in the same direction. NO leading or trailing whitespace (compiled verbatim, as `field` is). A numeric, temporal or boolean field: min/max over a string orders by collation, which differs between backends, so the server refuses it |
624
625
|**collapsed**|`boolean`| optional (default: `false`) | Collapse groups by default (presentation only) |
625
626
626
627
@@ -633,7 +634,8 @@ Record grouping configuration — SERVER-SIDE: the set of groups and every numbe
633
634
| Property | Type | Required | Description |
634
635
| :--- | :--- | :--- | :--- |
635
636
|**field**|`string`| ✅ | Field name to group by — one `groupBy` column of the group header query; the header row carries its raw stored value (null for the empty group). NO leading or trailing whitespace: the name is compiled into the aggregate query verbatim, so a padded spelling buckets every row into one empty group instead of grouping them. |
636
-
|**order**|`Enum<'asc' \| 'desc'>`| optional (default: `"asc"`) | Group sort order — applied by the consumer over the header rows (the aggregate query carries no orderBy) |
637
+
|**order**|`Enum<'asc' \| 'desc'>`| optional (default: `"asc"`) | Group sort order — applied by the consumer over the header rows (the aggregate query itself sorts nothing): by the group key, or by the `orderBy` value each header row carries when `orderBy` is set |
638
+
|**orderBy**|`string`| optional | Field whose value orders this level's groups, when that is not the grouping field — a field of the same object; default: the grouping field itself, so the groups order by their own key. The group header query then carries, per group, the smallest value of this field for `order: asc` and the largest for `desc` (each group sits where its first row would if the rows were sorted by this field), and the consumer sorts the header rows by it; a group with no value here sorts last in both directions, and equal values fall back to the group key in the same direction. NO leading or trailing whitespace (compiled verbatim, as `field` is). A numeric, temporal or boolean field: min/max over a string orders by collation, which differs between backends, so the server refuses it |
637
639
|**collapsed**|`boolean`| optional (default: `false`) | Collapse groups by default (presentation only) |
638
640
639
641
@@ -1018,7 +1020,7 @@ View filter rule
1018
1020
1019
1021
| Property | Type | Required | Description |
1020
1022
| :--- | :--- | :--- | :--- |
1021
-
|**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`|
1023
+
|**fields**|`{ field: string; order?: Enum<'asc' \| 'desc'>; orderBy?: string; 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`|
1022
1024
1023
1025
### Nested Shape: `ListView.rowColor`
1024
1026
@@ -1395,7 +1397,7 @@ View filter rule
1395
1397
1396
1398
| Property | Type | Required | Description |
1397
1399
| :--- | :--- | :--- | :--- |
1398
-
|**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`|
1400
+
|**fields**|`{ field: string; order?: Enum<'asc' \| 'desc'>; orderBy?: string; 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`|
0 commit comments