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
16 changes: 16 additions & 0 deletions .changeset/25786-grouping-order-by.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/spec': minor
'@objectstack/lint': minor
---

feat(spec,lint): a list-view grouping level can order its groups by another field (`grouping.fields[].orderBy`)

Clause-②: yes
Metadata-protocol: added

- **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.
- **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.
- **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).
- **`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.
- **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.
- **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.
4 changes: 2 additions & 2 deletions content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -887,7 +887,7 @@ Sort field and direction pair

| 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` |
| **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` |

### Nested Shape: `ObjectGridProps.aggregations[number]`

Expand Down Expand Up @@ -1037,7 +1037,7 @@ View filter rule

| 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` |
| **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` |

### Nested Shape: `ObjectKanbanProps.conditionalFormatting[number]`

Expand Down
12 changes: 7 additions & 5 deletions content/docs/references/ui/view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -613,14 +613,15 @@ Record grouping configuration — SERVER-SIDE: the set of groups and every numbe

| 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` |
| **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` |

### Nested Shape: `GroupingConfig.fields[number]`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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. |
| **order** | `Enum<'asc' \| 'desc'>` | optional (default: `"asc"`) | Group sort order — applied by the consumer over the header rows (the aggregate query carries no orderBy) |
| **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 |
| **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 |
| **collapsed** | `boolean` | optional (default: `false`) | Collapse groups by default (presentation only) |


Expand All @@ -633,7 +634,8 @@ Record grouping configuration — SERVER-SIDE: the set of groups and every numbe
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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. |
| **order** | `Enum<'asc' \| 'desc'>` | optional (default: `"asc"`) | Group sort order — applied by the consumer over the header rows (the aggregate query carries no orderBy) |
| **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 |
| **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 |
| **collapsed** | `boolean` | optional (default: `false`) | Collapse groups by default (presentation only) |


Expand Down Expand Up @@ -1018,7 +1020,7 @@ View filter rule

| 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` |
| **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` |

### Nested Shape: `ListView.rowColor`

Expand Down Expand Up @@ -1395,7 +1397,7 @@ View filter rule

| 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` |
| **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` |

### Nested Shape: `ObjectListView.rowColor`

Expand Down
3 changes: 3 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,10 +521,13 @@ export type {
// refused the same way; `wholeCollection` is the statement.
// Its FIFTH: a list view still carrying a key the spec retired, reported by
// name with the tombstone's prescription (`os lint` never parses).
// [#25786] Its SIXTH: a grouping level's `orderBy` naming a field the group
// header query cannot order by (`min` / `max` refused for its type).
export {
validateListViewFieldRefs,
LIST_VIEW_FIELD_UNKNOWN,
LIST_VIEW_FIELD_DOTTED,
LIST_VIEW_GROUP_ORDER_UNORDERABLE,
LIST_VIEW_OBJECT_OUTSIDE_CLOSURE,
LIST_VIEW_OBJECT_UNKNOWN,
LIST_VIEW_KEY_RETIRED,
Expand Down
31 changes: 29 additions & 2 deletions packages/lint/src/rule-explanations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3304,14 +3304,15 @@ const LIST_VIEW_FIELD_UNKNOWN_EXPLANATION: RuleExplanation = {
// The positions are held to `listViewWalkedPositions()` by the rule's test.
'Every field a list view names must be a field of its bound object: its `columns` (and a ' +
'column\'s `summary` and `prefix`), `hiddenFields`, `fieldOrder`, ' +
'`grouping`, `rowColor` and `userFilters`, the keys of its `filter` and of each ' +
'`grouping` (and a grouping level\'s `orderBy`), `rowColor` and `userFilters`, the keys of its `filter` and of each ' +
'`userFilters.tabs[].filter`, and every binding inside a `kanban`, `calendar`, `gantt`, ' +
'`timeline`, `gallery`, `map` or `tree` block. Nothing resolves the name at render time: the ' +
'view renders successfully with a blank, empty or wrong result, and nothing else reports the ' +
'miss. The verdict\'s "Did you mean" names the nearest field the object declares.',
'An error where the miss changes what the view returns or collapses what it lays out — for ' +
'example, a filter key, user filter or gantt quick filter matches no record; a column is sent as the ' +
'projection, where the REST ingress answers `400 INVALID_FIELD`; a `kanban.groupByField` ' +
'projection, where the REST ingress answers `400 INVALID_FIELD`; a grouping level\'s `orderBy` ' +
'is an operand of the group header query, which the data door refuses the same way; a `kanban.groupByField` ' +
'collapses every card into one lane; a required date field of a `gantt`, `calendar` or ' +
'`timeline` leaves nothing to place; a map with no coordinate field plots no marker; a ' +
'`tree.parentField` flattens the tree; a `gantt.lockField` unfreezes every row and a ' +
Expand Down Expand Up @@ -3349,6 +3350,10 @@ const LIST_VIEW_FIELD_DOTTED_EXPLANATION: RuleExplanation = {
'(`assertFilterIsMaterializable` and the REST ingress, `400 INVALID_FIELD`) rather than ' +
'answered with an empty list. A structured or JSON head, an array (`multiple: true`) head, a ' +
'file head and an unreadable head are served by the door, so they are not reported.',
'Aggregate: a grouping level\'s `orderBy` is the operand of the `min` / `max` node the group ' +
'header query orders its groups by. The header query compiles no join, so the data door\'s ' +
'ingress refuses every dotted operand whatever its head (`assertAggregationFieldsExist`, ' +
'`400 INVALID_FIELD`), and the grouped view renders no group.',
'The ways out are the doors\' own. At a projection position, name the whole column and read ' +
'into its value in the client, or read the related record with `expand`; at a filter position, ' +
'filter on a column of the bound object itself. At either, denormalise the value onto the bound ' +
Expand All @@ -3360,6 +3365,27 @@ const LIST_VIEW_FIELD_DOTTED_EXPLANATION: RuleExplanation = {
],
};

const LIST_VIEW_GROUP_ORDER_UNORDERABLE_EXPLANATION: RuleExplanation = {
rule: 'list-view-group-order-unorderable',
covers: 'which fields a grouping level can order its groups by',
paragraphs: [
'A grouping level\'s `orderBy` orders its groups by another field\'s value — a period label in time ' +
'order by its period start. The group header query carries that value per group: the smallest ' +
'(`min`) for `order: asc`, the largest (`max`) for `desc`. So the field must be one `min` and `max` ' +
'order the same way on every backend: the aggregate × field-type table (`AGGREGATE_FIELD_TYPE_COMPATIBILITY` ' +
'in `@objectstack/spec/data`) accepts the numeric, temporal and boolean classes.',
'It refuses every other type, among them the string classes — text, option values, references and ' +
'`autonumber` — because two backends order strings by two collations, the virtual `formula`, which ' +
'stores no column, and the file, structured and vector types. The engine\'s aggregate door (`engine.aggregate`) asks ' +
'the same table, so such an `orderBy` gets the grouped view\'s header query refused ' +
'(`400 INVALID_FIELD`) and no group renders. To order text labels, store each label\'s rank in a ' +
'number field and order by that.',
'Not judged: a level whose `orderBy` names its own grouping field (it compiles no node), a field with no ' +
'declared type, and a declared type outside `FieldType` — the aggregate door judges neither. A name ' +
'that resolves to nothing is `list-view-field-unknown`; a dotted one is `list-view-field-dotted`.',
],
};

/** Which keys a list view's binding is read from, and what stays unjudged (both binding ids write it out). */
const LIST_VIEW_BINDING_SCOPE =
'One finding per list view, at the key the binding was read from: the view\'s `object`, or a list ' +
Expand Down Expand Up @@ -5037,6 +5063,7 @@ export const RULE_EXPLANATIONS: Readonly<Record<string, RuleExplanation>> = Obje
[FILTER_EMPTY_NODE_EXPLANATION.rule]: FILTER_EMPTY_NODE_EXPLANATION,
[LIST_VIEW_FIELD_UNKNOWN_EXPLANATION.rule]: LIST_VIEW_FIELD_UNKNOWN_EXPLANATION,
[LIST_VIEW_FIELD_DOTTED_EXPLANATION.rule]: LIST_VIEW_FIELD_DOTTED_EXPLANATION,
[LIST_VIEW_GROUP_ORDER_UNORDERABLE_EXPLANATION.rule]: LIST_VIEW_GROUP_ORDER_UNORDERABLE_EXPLANATION,
[LIST_VIEW_OBJECT_OUTSIDE_CLOSURE_EXPLANATION.rule]: LIST_VIEW_OBJECT_OUTSIDE_CLOSURE_EXPLANATION,
[LIST_VIEW_OBJECT_UNKNOWN_EXPLANATION.rule]: LIST_VIEW_OBJECT_UNKNOWN_EXPLANATION,
[TRANSLATION_TARGET_UNKNOWN_EXPLANATION.rule]: TRANSLATION_TARGET_UNKNOWN_EXPLANATION,
Expand Down
Loading
Loading