diff --git a/.changeset/25786-grouping-order-by.md b/.changeset/25786-grouping-order-by.md new file mode 100644 index 00000000000..a7487a04520 --- /dev/null +++ b/.changeset/25786-grouping-order-by.md @@ -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_` / `max_`. 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. diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 1f3720a53d4..965cec08d24 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -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]` @@ -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]` diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 0f9e38ce50a..3f28ff734f5 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -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) | @@ -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) | @@ -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` @@ -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` diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 81a359e0902..a93889e3a0c 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -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, diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index c3597dfad10..62ede30470a 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -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 ' + @@ -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 ' + @@ -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 ' + @@ -5037,6 +5063,7 @@ export const RULE_EXPLANATIONS: Readonly> = 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, diff --git a/packages/lint/src/validate-list-view-field-refs.test.ts b/packages/lint/src/validate-list-view-field-refs.test.ts index 38bc0e959a0..b995252f081 100644 --- a/packages/lint/src/validate-list-view-field-refs.test.ts +++ b/packages/lint/src/validate-list-view-field-refs.test.ts @@ -16,6 +16,7 @@ import { validateListViewFieldRefs as validateListViewFieldRefsUnrecorded, LIST_VIEW_FIELD_UNKNOWN, LIST_VIEW_FIELD_DOTTED, + LIST_VIEW_GROUP_ORDER_UNORDERABLE, LIST_VIEW_OBJECT_OUTSIDE_CLOSURE, LIST_VIEW_OBJECT_UNKNOWN, listViewWalkedPositions, @@ -92,7 +93,7 @@ const FULL_LIST_VIEW: AnyRec = { filter: [{ field: 'visible_from', operator: 'equals', value: '2026-01-01' }], hiddenFields: ['business_unit'], fieldOrder: ['title', 'status'], - grouping: { fields: [{ field: 'business_unit' }] }, + grouping: { fields: [{ field: 'business_unit', orderBy: 'due_at' }] }, rowColor: { field: 'status' }, userFilters: { fields: [{ field: 'status' }], @@ -298,6 +299,13 @@ describe('#14107 — every other walked position', () => { ], [{ hiddenFields: [BAD] }, 'views[0].list.hiddenFields[0]', 'warning'], [{ fieldOrder: [BAD] }, 'views[0].list.fieldOrder[0]', 'warning'], + // [#25786] An operand of the group header query: the aggregate door + // refuses an unknown column, so no group renders. + [ + { grouping: { fields: [{ field: 'business_unit', orderBy: BAD }] } }, + 'views[0].list.grouping.fields[0].orderBy', + 'error', + ], [{ rowColor: { field: BAD } }, 'views[0].list.rowColor.field', 'warning'], [{ userFilters: { fields: [{ field: BAD }] } }, 'views[0].list.userFilters.fields[0].field', 'error'], [ @@ -1132,6 +1140,109 @@ describe('[#22161] one-line verdicts — list-view-field-unknown, list-view-fiel }); }); +/** + * [#25786] `grouping.fields[].orderBy` reaches a door — the group header + * query's `min` / `max` node, executed by `engine.aggregate` — so all three of + * that door's verdicts are this position's: the name resolves, it is not a + * dotted path, and `min` / `max` over its declared type is a pair the + * aggregate × field-type table accepts. + */ +describe('[#25786] a grouping level ordered by another field', () => { + const ordered = (orderBy: string, extra: AnyRec = {}): AnyRec => ({ + grouping: { fields: [{ field: 'business_unit', orderBy, ...extra }] }, + }); + const findingsFor = (patch: AnyRec) => validateListViewFieldRefs(stackWith(mutate(patch))); + + it.each([ + ['a date field', 'due_at'], + ['a number field', 'estimate'], + ['a boolean field', 'is_locked'], + ['a registry-injected datetime column', 'created_at'], + ])('orders by %s — silent', (_label, orderBy) => { + expect(findingsFor(ordered(orderBy))).toEqual([]); + expect(findingsFor(ordered(orderBy, { order: 'desc' }))).toEqual([]); + }); + + it.each([ + ['text', 'title', 'min'], + ['select', 'status', 'min'], + ['lookup', 'owner', 'max'], + ])('a `%s` field is unorderable — `error`, naming the refused pair, at the level\'s `orderBy`', (type, orderBy, fn) => { + const findings = findingsFor(ordered(orderBy, fn === 'max' ? { order: 'desc' } : {})); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(LIST_VIEW_GROUP_ORDER_UNORDERABLE); + expect(findings[0].severity).toBe('error'); + expect(findings[0].path).toBe('views[0].list.grouping.fields[0].orderBy'); + expect(findings[0].message).toContain(`is a \`${type}\` field, and \`${fn}\` over it is refused`); + }); + + it('a virtual `formula` field carries the spec\'s store-it remedy, not the generic one', () => { + const [finding, ...rest] = findingsFor(ordered('score')); + expect(rest).toEqual([]); + expect(finding.rule).toBe(LIST_VIEW_GROUP_ORDER_UNORDERABLE); + expect(finding.hint).toMatch(/^The field is computed on read and stores no value/); + }); + + it('a dotted `orderBy` is refused at the data door\'s ingress, whatever its head', () => { + const findings = findingsFor(ordered('owner.name')); + expect(findings.map((f) => [f.rule, f.path, f.severity])).toEqual([ + [LIST_VIEW_FIELD_DOTTED, 'views[0].list.grouping.fields[0].orderBy', 'error'], + ]); + expect(findings[0].message).toContain('the group header query\'s aggregation refuses it'); + // A head that resolves to nothing is the unknown class, as everywhere. + expect(findingsFor(ordered('ownr.name')).map((f) => f.rule)).toEqual([LIST_VIEW_FIELD_UNKNOWN]); + }); + + it('ordering by its own grouping field compiles no node — nothing is judged at `orderBy`', () => { + // A text grouping field ordering by itself is the default, not a `min` over text. + expect(findingsFor(ordered('business_unit'))).toEqual([]); + // And a dangling grouping field is reported ONCE, at `field`. + const dangling = findingsFor({ grouping: { fields: [{ field: 'nope_field', orderBy: 'nope_field' }] } }); + expect(idsOf(dangling)).toEqual(['views[0].list.grouping.fields[0].field']); + }); + + it('a field with no declared type, or a type outside `FieldType`, is not judged — the door judges neither', () => { + // `id` is driver-provisioned: the graph knows it and has no type for it. + expect(findingsFor(ordered('id'))).toEqual([]); + const objects = [{ ...OBJECTS[0], fields: [...OBJECTS[0].fields, { name: 'rank_raw', type: 'integer', label: 'Rank' }] }, OBJECTS[1]]; + expect(validateListViewFieldRefs(stackWith(mutate(ordered('rank_raw')), objects))).toEqual([]); + }); + + it('every level is judged, each at its own index', () => { + const findings = findingsFor({ + grouping: { fields: [{ field: 'status', orderBy: 'estimate' }, { field: 'business_unit', orderBy: 'title' }] }, + }); + expect(findings.map((f) => [f.rule, f.path])).toEqual([ + [LIST_VIEW_GROUP_ORDER_UNORDERABLE, 'views[0].list.grouping.fields[1].orderBy'], + ]); + }); + + for (const command of ['validate', 'build'] as const) { + it(`an unorderable \`orderBy\` fails \`${command}\``, () => { + const { errors } = splitBySeverity(runAuthoringRules(command, { normalized: stackWith(mutate(ordered('title'))) })); + expect(errors.map((f) => f.rule)).toContain(LIST_VIEW_GROUP_ORDER_UNORDERABLE); + }); + } + + it('reaches the runtime save door through the reference-integrity suite', () => { + const findings = validateReferenceIntegrity(stackWith(mutate(ordered('title'))), { runtimeWriteType: 'view' }); + expect(findings.map((f) => f.rule)).toContain(LIST_VIEW_GROUP_ORDER_UNORDERABLE); + }); + + it('`os explain` carries the reasoning, and the verdict is one line', () => { + const entry = explainRule(LIST_VIEW_GROUP_ORDER_UNORDERABLE); + expect(entry, 'no `os explain` entry').toBeDefined(); + const text = entry!.paragraphs.join('\n'); + for (const fact of ['`min`', '`max`', 'collation', '`formula`', 'rank in a number field']) { + expect(text, fact).toContain(fact); + } + for (const f of fired.filter((x) => x.rule === LIST_VIEW_GROUP_ORDER_UNORDERABLE)) { + expect(f.hint).not.toContain('\n'); + expect(f.hint.length, f.hint).toBeLessThanOrEqual(200); + } + }); +}); + // [#22161] Slice 16: the `fix:` lines. The unknown id prints its remedy alone // (the verdict's "Did you mean" names the nearest field, so the object's field // list is no longer appended); the dotted id prints one remedy line per door, diff --git a/packages/lint/src/validate-list-view-field-refs.ts b/packages/lint/src/validate-list-view-field-refs.ts index 18368539a7c..5058cffef5c 100644 --- a/packages/lint/src/validate-list-view-field-refs.ts +++ b/packages/lint/src/validate-list-view-field-refs.ts @@ -307,6 +307,31 @@ * A closure wins over the statement when both are given: it is the narrower * set, and its remedy (declare the dependency) is the one that applies. * + * ## [#25786] `grouping.fields[].orderBy` — named, served, and orderable + * + * A grouping level's `orderBy` names the field whose value orders its groups. + * Unlike the renderer bindings, it REACHES A DOOR: the group header query + * (`compileListViewGroupQuery`) carries one `min` / `max` node over it, which + * `engine.aggregate` executes. Three verdicts, each the door's own — two + * doors, both `400 INVALID_FIELD`: + * + * - **Existence** — {@link LIST_VIEW_FIELD_UNKNOWN}, `error`: the data + * door's ingress (`findData`'s `assertAggregationFieldsExist`) refuses an + * unknown operand, so the grouped view renders no group at all. + * - **A dotted path** — {@link LIST_VIEW_FIELD_DOTTED} at the `aggregate` + * axis: the header query compiles no join, and the same ingress gate + * refuses every dotted aggregation operand, whatever its head. + * - **Orderability** — {@link LIST_VIEW_GROUP_ORDER_UNORDERABLE}, `error`: + * the pair `min` / `max` × the field's declared type, asked of the spec's + * table (`isAggregateCompatibleWithFieldType`) exactly as the engine's + * aggregate door asks it, with the door's two non-verdicts kept — no + * declared type (the driver-provisioned `id`) and a type outside + * `FieldType` are not judged. + * + * A level whose `orderBy` names its own grouping field compiles no node, so + * nothing is judged at `orderBy` there: the `field` position already judges + * the name. + * * ## Skips — the same three every field-existence rule in this package takes * * Resolution is {@link resolveFieldPath}'s and its `unknowable` verdicts are @@ -327,7 +352,12 @@ * definition table describes it — and the door serves it, so this rule does too. */ -import { classifyDottedFilterHead } from '@objectstack/spec/data'; +import { + FieldType, + classifyDottedFilterHead, + isAggregateCompatibleWithFieldType, + virtualFieldAggregateRemedy, +} from '@objectstack/spec/data'; import { ListViewSchema } from '@objectstack/spec/ui'; import { walkFilterFieldKeys } from './filter-walk.js'; @@ -364,6 +394,16 @@ export const LIST_VIEW_OBJECT_UNKNOWN = 'list-view-object-unknown'; */ export const LIST_VIEW_FIELD_DOTTED = 'list-view-field-dotted'; +/** + * [#25786] A grouping level whose `orderBy` names a field the group header + * query cannot order by: `min` / `max` over its declared type is a pair the + * aggregate × field-type table refuses — the string classes, whose order is + * collation-dependent, and the virtual `formula` — so the engine's aggregate + * door answers the grouped view's header query `400 INVALID_FIELD` and no + * group renders. See the section of that name on this module. + */ +export const LIST_VIEW_GROUP_ORDER_UNORDERABLE = 'list-view-group-order-unorderable'; + /** * A list view that still carries a key `@objectstack/spec` retired — reported * BY NAME, with the tombstone's own prescription, never as a field reference. @@ -438,7 +478,7 @@ export function retiredKeyVerdictHead(prescription: string): string { * measured" — the position is judged for head existence only, exactly as * before. */ -type DottedAxis = 'projection' | 'filter' | undefined; +type DottedAxis = 'projection' | 'filter' | 'aggregate' | undefined; export type ListViewFieldRefSeverity = 'error' | 'warning'; @@ -673,6 +713,19 @@ function dottedAxisAt(block: string, key: string): DottedAxis { return DOTTED_AXIS[block]?.[key]; } +/** + * [#25786] The second field-naming key on a `grouping.fields[]` entry record, + * beside its `field` — see the section of that name on this module. Its own + * table rather than a `COLUMN_ENTRY_POSITIONS` row: those keys live one record + * further down (`columns[].summary.field`), this one on the entry itself. + */ +const GROUPING_ENTRY_POSITIONS: Array<{ key: string; severity: Sev; axis: DottedAxis }> = [ + { key: 'orderBy', severity: 'error', axis: 'aggregate' }, +]; + +/** `FieldType`'s members — the vocabulary the aggregate door judges, and nothing outside it. */ +const FIELD_TYPES: ReadonlySet = new Set(FieldType.options); + /** The nested field positions inside one `columns[]` entry record. */ const COLUMN_ENTRY_POSITIONS: Array<{ block: string; key: string; severity: Sev }> = [ // `summary` aggregates a column; a stale name aggregates nothing and the @@ -737,6 +790,7 @@ export function listViewWalkedPositions(): string[] { ].map((key) => (block === '' ? key : `${block}.${key}`)), ), ...COLUMN_ENTRY_POSITIONS.map(({ block, key }) => `columns.${block}.${key}`), + ...GROUPING_ENTRY_POSITIONS.map(({ key }) => `grouping.fields.${key}`), ]; } @@ -768,6 +822,17 @@ function describeDottedRefusal( // written when the source changes) is `os explain list-view-field-dotted`. const denormalise = `denormalise the value onto "${object}" and name that`; + if (axis === 'aggregate') { + // No classification: the header query compiles no join, so the aggregate + // door refuses every dotted operand by name (an unknown column). + return { + message: + `${subject} "${written}" is a dotted path, so the group header query's aggregation refuses it ` + + `and the grouped view's header query answers 400 INVALID_FIELD`, + hint: `Order by a field of "${object}" itself, or ${denormalise}`, + }; + } + if (axis === 'projection') { // No classification: `assertProjectionHasNoDottedPaths` filters on // `f.includes('.')` and refuses every dotted entry, whatever the head is. @@ -953,6 +1018,45 @@ export function validateListViewFieldRefs( }); }; + /** + * [#25786] One `grouping.fields[]` entry's `orderBy` — see the section of + * that name on this module: the shared existence and dotted verdicts at the + * `aggregate` axis, then the orderability of the field's declared type. + */ + const checkGroupOrder = ( + entry: AnyRec, + position: (typeof GROUPING_ENTRY_POSITIONS)[number], + object: string, + where: string, + path: string, + subject: string, + ): void => { + const written = strName(entry[position.key]); + // Ordering by its own grouping field compiles no node: nothing to judge here. + if (!written || written === strName(entry.field)) return; + check(written, object, position.severity, where, path, subject, position.axis); + if (written.includes('.')) return; + const verdict = resolveFieldPath(graph, object, written); + if (!verdict || verdict.kind !== 'ok') return; + const type = verdict.meta?.type; + if (!type || !FIELD_TYPES.has(type)) return; + const fn = entry.order === 'desc' ? 'max' : 'min'; + if (isAggregateCompatibleWithFieldType(fn, type)) return; + findings.push({ + severity: 'error', + rule: LIST_VIEW_GROUP_ORDER_UNORDERABLE, + where, + path, + message: + `${subject} "${written}" is a \`${type}\` field, and \`${fn}\` over it is refused by the aggregate door ` + + `(400 INVALID_FIELD), so the grouped view's header query fails and no group renders`, + hint: + virtualFieldAggregateRemedy(fn, type) ?? + `Order by a numeric, date/time or boolean field of "${object}" (for a text label, a number field holding ` + + `its rank), or remove \`${position.key}\``, + }); + }; + /** * Every position on ONE list view record. `bindingPath` is the key the * binding `object` was read from, where an unresolvable binding is reported. @@ -1042,6 +1146,13 @@ export function validateListViewFieldRefs( } if (!isRec(entry)) return; check(entry.field, bound, severity, hostWhere, `${entryPath}.field`, `${key}[${i}].field`, axis); + // [#25786] The keys a `grouping.fields[]` entry carries beside `field`. + if (block === 'grouping' && key === 'fields') { + for (const nested of GROUPING_ENTRY_POSITIONS) { + checkGroupOrder(entry, nested, bound, hostWhere, `${entryPath}.${nested.key}`, `${key}[${i}].${nested.key}`); + } + return; + } // The nested positions a `columns[]` entry carries. if (block !== '' || key !== 'columns') return; for (const nested of COLUMN_ENTRY_POSITIONS) { diff --git a/packages/rest/src/list-view-grouping-query-door.test.ts b/packages/rest/src/list-view-grouping-query-door.test.ts index 15ec77b11c1..6a98de4a007 100644 --- a/packages/rest/src/list-view-grouping-query-door.test.ts +++ b/packages/rest/src/list-view-grouping-query-door.test.ts @@ -99,6 +99,7 @@ import { compileListViewGroupQuery, compileListViewGroupRowsQuery, deriveColumnSummary, + listViewGroupOrderColumn, } from '@objectstack/spec/ui'; import type { ListViewGroupHeaderRow, ListViewGroupQuerySource } from '@objectstack/spec/ui'; import { RestServer } from './rest-server'; @@ -215,6 +216,60 @@ const PLAIN_VIEW: ListViewGroupQuerySource = { grouping: { fields: [{ field: 'bu const OBJECT_CONTIGUOUS = 'work_item'; const OBJECT_INTERLEAVED = 'work_item_interleaved'; const OBJECT_WITH_EMPTY = 'work_item_unassigned'; +/** [#25786] The forecast shape §11 orders by another field. */ +const OBJECT_FORECAST = 'forecast_period'; + +/** + * [#25786] Periods grouped by their LABEL, which sorts as text, ordered by + * their START. Two labels share a start (the tie), one has none (the null + * group), and the rows are stored interleaved so no group order can fall out + * of the stored order. + */ +const FORECAST_PERIODS: ReadonlyArray = [ + ['Q4 2025', '2025-10-01', '2025-12-30T09:00:00.000Z', 2], + ['Sep 2026', '2026-09-01', '2026-09-29T17:30:00.000Z', 1], + ['Oct 2026', '2026-10-01', '2026-10-30T08:15:00.000Z', 3], + ['Q4 2026', '2026-10-01', '2026-12-31T23:59:00.000Z', 1], + ['Nov 2026', '2026-11-01', '2026-11-27T12:00:00.000Z', 2], + ['Q1 2027', '2027-01-01', '2027-03-31T10:45:00.000Z', 2], + ['Unscheduled', null, null, 1], +]; + +const FORECAST_ROWS: Array> = (() => { + const queues = FORECAST_PERIODS.map(([label, start, closedAt, rows]) => + Array.from({ length: rows }, (_, i) => ({ + id: `${label}-${i}`, + period_label: label, + period_start: start, + // Each row of a period closes an hour after the one before it, so `min` + // and `max` over this column are different instants per group. + closed_at: closedAt === null ? null : new Date(Date.parse(closedAt) + i * 3_600_000).toISOString(), + amount: i + 1, + }))); + const out: Array> = []; + while (queues.some((q) => q.length > 0)) { + for (const q of [...queues].reverse()) { + const next = q.shift(); + if (next) out.push(next); + } + } + return out; +})(); + +function forecastSchema() { + return { + name: OBJECT_FORECAST, + label: OBJECT_FORECAST, + systemFields: false, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + period_label: { name: 'period_label', type: 'text' as const, label: 'Period' }, + period_start: { name: 'period_start', type: 'date' as const, label: 'Period start' }, + closed_at: { name: 'closed_at', type: 'datetime' as const, label: 'Closed at' }, + amount: { name: 'amount', type: 'number' as const, label: 'Amount' }, + }, + }; +} function objectSchema(name: string) { return { @@ -318,7 +373,7 @@ const TIERS: readonly Tier[] = ['driver-sql', 'in-memory']; const calls = { nativeAggregate: 0, driverFind: 0, dataFind: 0, findWithAggregations: 0 }; /** The row stores this file seeds — a `find` on one of these is a DATA read. */ -const DATA_OBJECTS: readonly string[] = [OBJECT_CONTIGUOUS, OBJECT_INTERLEAVED, OBJECT_WITH_EMPTY]; +const DATA_OBJECTS: readonly string[] = [OBJECT_CONTIGUOUS, OBJECT_INTERLEAVED, OBJECT_WITH_EMPTY, OBJECT_FORECAST]; /** * Every `driver.find` the shim saw, in order — object plus the AST's top-level @@ -381,10 +436,12 @@ beforeAll(async () => { for (const name of [OBJECT_CONTIGUOUS, OBJECT_INTERLEAVED, OBJECT_WITH_EMPTY]) { engine.registry.registerObject(objectSchema(name) as never); } + engine.registry.registerObject(forecastSchema() as never); await engine.syncSchemas(); await engine.insert(OBJECT_CONTIGUOUS, inStoredOrder(CONTIGUOUS)); await engine.insert(OBJECT_INTERLEAVED, inStoredOrder(INTERLEAVED)); await engine.insert(OBJECT_WITH_EMPTY, inStoredOrder([...CONTIGUOUS, ...UNASSIGNED])); + await engine.insert(OBJECT_FORECAST, FORECAST_ROWS); const protocol = new ObjectStackProtocolImplementation(engine as never); const rest = new RestServer( @@ -949,3 +1006,117 @@ describe('§10 a grouped query under `search` groups the SEARCHED rows', () => { expect(res._body?.code).toBe('INVALID_FIELD'); }); }); + +// ─── §11 A level ordered by another field (#25786) ─────────────────────────── + +/** + * `GroupingFieldSchema.orderBy`: the consumer sorts the header rows, and the + * header query carries the value to sort by — one `min` (asc) / `max` (desc) + * node over `orderBy`. What was unmeasured is whether the two faces answer + * that node with one value of one TYPE per group over a `date` and a + * `datetime` column, and whether the door refuses an operand the lint refuses. + * + * The consumer's sort is written out here as `GroupingFieldSchema.orderBy` + * states it — test-local, because the sort is the consumer's: by the column + * {@link listViewGroupOrderColumn} names, `null` last in both directions, + * equal values by the group key in the same direction. + */ +describe('§11 a level ordered by another field — the header rows carry the order value', () => { + const byStart = (order: 'asc' | 'desc' = 'asc') => ({ + grouping: { fields: [{ field: 'period_label', orderBy: 'period_start', order }] }, + }); + + const sortHeaders = (headers: Array>, column: string, order: 'asc' | 'desc'): string[] => { + const sign = order === 'desc' ? -1 : 1; + const cmp = (a: unknown, b: unknown) => (a === b ? 0 : (a as string) < (b as string) ? -1 : 1); + return [...headers] + .sort((a, b) => { + const av = a[column]; + const bv = b[column]; + if (av == null || bv == null) return av == null && bv == null ? 0 : av == null ? 1 : -1; + return sign * (cmp(av, bv) || cmp(a.period_label, b.period_label)); + }) + .map((h) => String(h.period_label)); + }; + + it.each(TIERS)('%s: one header per LABEL, each carrying its period start under min_period_start', async (tier) => { + const view = byStart(); + const query = compileListViewGroupQuery(view); + expect(query.groupBy).toEqual(['period_label']); + const column = listViewGroupOrderColumn(view.grouping, 0); + expect(column).toBe('min_period_start'); + + const headers = await onTier(tier, () => records(OBJECT_FORECAST, query)); + expect(headers).toHaveLength(FORECAST_PERIODS.length); + for (const [label, start, , rows] of FORECAST_PERIODS) { + const header = headers.find((h) => h.period_label === label)!; + expect(header, label).toBeDefined(); + expect(header[LIST_VIEW_GROUP_COUNT_ALIAS], label).toBe(rows); + // The stored calendar-day form, on both faces — a string the consumer + // orders as it is. + expect(header[column], label).toBe(start); + } + expect(sortHeaders(headers, column, 'asc')).toEqual([ + 'Q4 2025', 'Sep 2026', 'Oct 2026', 'Q4 2026', 'Nov 2026', 'Q1 2027', 'Unscheduled', + ]); + // CONTROL — the key alone sorts as text, which is what the forecast list showed. + expect(sortHeaders(headers, 'period_label', 'asc')).toEqual([ + 'Nov 2026', 'Oct 2026', 'Q1 2027', 'Q4 2025', 'Q4 2026', 'Sep 2026', 'Unscheduled', + ]); + }); + + it.each(TIERS)('%s: `desc` carries max_period_start, and the null group is still last', async (tier) => { + const view = byStart('desc'); + const column = listViewGroupOrderColumn(view.grouping, 0); + expect(column).toBe('max_period_start'); + const headers = await onTier(tier, () => records(OBJECT_FORECAST, compileListViewGroupQuery(view))); + expect(sortHeaders(headers, column, 'desc')).toEqual([ + 'Q1 2027', 'Nov 2026', 'Q4 2026', 'Oct 2026', 'Sep 2026', 'Q4 2025', 'Unscheduled', + ]); + }); + + it('driver-sql and the in-memory tier answer the same order values, types included — date and datetime', async () => { + for (const grouping of [ + byStart().grouping, + byStart('desc').grouping, + { fields: [{ field: 'period_label', orderBy: 'closed_at' }] }, + { fields: [{ field: 'period_label', orderBy: 'closed_at', order: 'desc' as const }] }, + ]) { + const query = compileListViewGroupQuery({ grouping }); + const pushedDown = await onTier('driver-sql', () => records(OBJECT_FORECAST, query)); + const inMemory = await onTier('in-memory', () => records(OBJECT_FORECAST, query)); + const label = JSON.stringify(grouping.fields[0]); + expect(canonical(inMemory), label).toEqual(canonical(pushedDown)); + expect(pushedDown, label).toHaveLength(FORECAST_PERIODS.length); + } + }); + + it.each(TIERS)('%s: min and max over a datetime are different instants per group, each the right row\'s', async (tier) => { + const asc = { fields: [{ field: 'period_label', orderBy: 'closed_at' }] }; + const desc = { fields: [{ field: 'period_label', orderBy: 'closed_at', order: 'desc' as const }] }; + const first = await onTier(tier, () => records(OBJECT_FORECAST, compileListViewGroupQuery({ grouping: asc }))); + const last = await onTier(tier, () => records(OBJECT_FORECAST, compileListViewGroupQuery({ grouping: desc }))); + const own = (label: string) => FORECAST_ROWS.filter((r) => r.period_label === label).map((r) => r.closed_at as string).sort(); + const instant = (v: unknown) => (v == null ? null : new Date(v as string).getTime()); + const oct = (rows: Array>, column: string) => + instant(rows.find((h) => h.period_label === 'Oct 2026')![column]); + expect(oct(first, listViewGroupOrderColumn(asc, 0))).toBe(instant(own('Oct 2026')[0])); + expect(oct(last, listViewGroupOrderColumn(desc, 0))).toBe(instant(own('Oct 2026')[2])); + expect(oct(first, 'min_closed_at')).not.toBe(oct(last, 'max_closed_at')); + }); + + it('an `orderBy` the lint refuses is refused by the door: `min` over a text field is 400 / INVALID_FIELD', async () => { + const query = compileListViewGroupQuery({ grouping: { fields: [{ field: 'period_start', orderBy: 'period_label' }] } }); + expect(query.aggregations).toContainEqual({ function: 'min', field: 'period_label', alias: 'min_period_label' }); + const res = await postQuery(OBJECT_FORECAST, query); + expect(res._status).toBe(400); + expect(res._body?.code).toBe('INVALID_FIELD'); + }); + + it('a dotted `orderBy` is refused by the door: 400 / INVALID_FIELD, not a null order value', async () => { + const query = compileListViewGroupQuery({ grouping: { fields: [{ field: 'period_label', orderBy: 'period_start.year' }] } }); + const res = await postQuery(OBJECT_FORECAST, query); + expect(res._status).toBe(400); + expect(res._body?.code).toBe('INVALID_FIELD'); + }); +}); diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 3dccc6b976c..66ce48ecaf5 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -548,6 +548,7 @@ "isRecordContextBlockType (function)", "isViewContainerShaped (function)", "listViewGroupKeyPredicate (function)", + "listViewGroupOrderColumn (function)", "misplacedDefaultPageRefusal (function)", "navItemVisibleDataNeedsDataSource (function)", "normalizeFilterOperator (function)", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 985a29bc6b4..c9b141ccc82 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -664,6 +664,7 @@ "ui/GroupingField:collapsed", "ui/GroupingField:field", "ui/GroupingField:order", + "ui/GroupingField:orderBy", "ui/HttpRequest:body", "ui/HttpRequest:headers", "ui/HttpRequest:method", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index f8853aa66fb..a5c3a276055 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -533,6 +533,7 @@ "isRecordContextBlockType": "src/ui/react-blocks.ts#isRecordContextBlockType (function)", "isViewContainerShaped": "src/ui/assembled-views.zod.ts#isViewContainerShaped (function)", "listViewGroupKeyPredicate": "src/ui/view-grouping-query.ts#listViewGroupKeyPredicate (function)", + "listViewGroupOrderColumn": "src/ui/view-grouping-query.ts#listViewGroupOrderColumn (function)", "misplacedDefaultPageRefusal": "src/ui/default-record-page.ts#misplacedDefaultPageRefusal (function)", "navItemVisibleDataNeedsDataSource": "src/ui/app.zod.ts#navItemVisibleDataNeedsDataSource (function)", "normalizeFilterOperator": "src/ui/view.zod.ts#normalizeFilterOperator (function)", diff --git a/packages/spec/src/ui/view-grouping-query.test.ts b/packages/spec/src/ui/view-grouping-query.test.ts index efcb03d3a3d..058d59586c4 100644 --- a/packages/spec/src/ui/view-grouping-query.test.ts +++ b/packages/spec/src/ui/view-grouping-query.test.ts @@ -28,6 +28,7 @@ import { compileListViewGroupRowsQuery, deriveColumnSummary, listViewGroupKeyPredicate, + listViewGroupOrderColumn, } from './view-grouping-query'; import type { ListViewGroupHeaderRow } from './view-grouping-query'; @@ -137,14 +138,22 @@ function reduceHeaderQuery(rows: Row[], query: EngineAggregateOptions): ListView case 'count_distinct': out[agg.alias] = new Set(values.filter((v) => v != null)).size; break; case 'sum': out[agg.alias] = nums.reduce((a, b) => a + b, 0); break; case 'avg': out[agg.alias] = nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : null; break; - case 'min': out[agg.alias] = nums.length ? Math.min(...nums) : null; break; - case 'max': out[agg.alias] = nums.length ? Math.max(...nums) : null; break; + // Raw comparison, as the in-memory face does: a `YYYY-MM-DD` string + // orders chronologically, and a number orders as itself. + case 'min': out[agg.alias] = orderStatistic(values, (a, b) => a < b); break; + case 'max': out[agg.alias] = orderStatistic(values, (a, b) => a > b); break; } } return out as ListViewGroupHeaderRow; }); } +/** `min` / `max` over the non-null values, `null` over none. */ +function orderStatistic(values: unknown[], better: (a: never, b: never) => boolean): unknown { + const defined = values.filter((v) => v != null); + return defined.length === 0 ? null : defined.reduce((a, b) => (better(a as never, b as never) ? a : b)); +} + /** Header rows → `{ unit: count }`, the shape a reader compares. */ function countsByUnit(headers: ListViewGroupHeaderRow[]): Record { return Object.fromEntries(headers.map((h) => [String(h.business_unit), h[LIST_VIEW_GROUP_COUNT_ALIAS]])); @@ -519,6 +528,229 @@ describe('compileListViewGroupQuery — refuses what the contract cannot mean', }); }); +// ─── [#25786] Ordering a level by another field ────────────────────────────── +// +// The HotCRM forecast shape: periods grouped by their LABEL, which sorts as +// text ("Nov 2026" < "Oct 2026" < "Q1 2027" < "Q4 2025" …), ordered by their +// period START. Two labels share a start on purpose (the tie arm) and one +// period has no start at all (the null arm). + +interface Forecast { + id: string; + period_label: string; + period_start: string | null; + amount: number; +} + +const FORECAST_PERIODS: ReadonlyArray = [ + ['Q4 2025', '2025-10-01', 2], + ['Sep 2026', '2026-09-01', 1], + ['Oct 2026', '2026-10-01', 3], + ['Q4 2026', '2026-10-01', 1], + ['Nov 2026', '2026-11-01', 2], + ['Q1 2027', '2027-01-01', 2], + ['Unscheduled', null, 1], +]; + +/** Interleaved on purpose, so no group order can fall out of the row order. */ +const FORECASTS: Forecast[] = (() => { + const queues = FORECAST_PERIODS.map(([label, start, rows]) => + Array.from({ length: rows }, (_, i) => ({ id: `${label}-${i}`, period_label: label, period_start: start, amount: i + 1 }))); + const out: Forecast[] = []; + while (queues.some((q) => q.length > 0)) { + for (const q of [...queues].reverse()) { + const next = q.shift(); + if (next) out.push(next); + } + } + return out; +})(); + +/** + * The CONSUMER's sort, written out as `GroupingFieldSchema.orderBy` states it + * — test-local, because the sort is the consumer's and not this module's: by + * the header-row column {@link listViewGroupOrderColumn} names, a `null` value + * last in both directions, equal values by the group key in the same direction. + */ +function sortHeaders( + headers: ListViewGroupHeaderRow[], + column: string, + key: string, + order: 'asc' | 'desc', +): string[] { + const sign = order === 'desc' ? -1 : 1; + const cmp = (a: unknown, b: unknown) => (a === b ? 0 : (a as string) < (b as string) ? -1 : 1); + return [...headers] + .sort((a, b) => { + const av = a[column]; + const bv = b[column]; + if (av == null || bv == null) return av == null && bv == null ? 0 : av == null ? 1 : -1; + return sign * (cmp(av, bv) || cmp(a[key], b[key])); + }) + .map((h) => String(h[key])); +} + +describe('[#25786] a level ordered by another field — the header query carries the value to sort by', () => { + const byStart = (order?: 'asc' | 'desc') => ({ + grouping: { fields: [{ field: 'period_label', orderBy: 'period_start', ...(order ? { order } : {}) }] }, + }); + + it('compiles ONE `min` node for `asc` (the default) and ONE `max` node for `desc`, named by listViewGroupOrderColumn', () => { + expect(compileListViewGroupQuery(byStart()).aggregations).toEqual([ + { function: 'count', alias: LIST_VIEW_GROUP_COUNT_ALIAS }, + { function: 'min', field: 'period_start', alias: 'min_period_start' }, + ]); + expect(compileListViewGroupQuery(byStart('desc')).aggregations).toEqual([ + { function: 'count', alias: LIST_VIEW_GROUP_COUNT_ALIAS }, + { function: 'max', field: 'period_start', alias: 'max_period_start' }, + ]); + // The grouping key never leaves `groupBy`: one header per LABEL. + expect(compileListViewGroupQuery(byStart()).groupBy).toEqual(['period_label']); + expect(listViewGroupOrderColumn(byStart().grouping, 0)).toBe('min_period_start'); + expect(listViewGroupOrderColumn(byStart('desc').grouping, 0)).toBe('max_period_start'); + }); + + it('sorting the header rows by that column puts the periods in TIME order, where the key alone gives text order', () => { + const asc = byStart(); + const headers = reduceHeaderQuery(FORECASTS as unknown as Row[], compileListViewGroupQuery(asc)); + expect(headers).toHaveLength(FORECAST_PERIODS.length); + expect(sortHeaders(headers, listViewGroupOrderColumn(asc.grouping, 0), 'period_label', 'asc')).toEqual([ + 'Q4 2025', 'Sep 2026', 'Oct 2026', 'Q4 2026', 'Nov 2026', 'Q1 2027', 'Unscheduled', + ]); + // CONTROL — the same rows by the group key: what the forecast list showed. + expect(sortHeaders(headers, 'period_label', 'period_label', 'asc')).toEqual([ + 'Nov 2026', 'Oct 2026', 'Q1 2027', 'Q4 2025', 'Q4 2026', 'Sep 2026', 'Unscheduled', + ]); + + const desc = byStart('desc'); + const descHeaders = reduceHeaderQuery(FORECASTS as unknown as Row[], compileListViewGroupQuery(desc)); + // The null-start period is LAST in this direction too, and the tie keeps + // the key order in the same direction. + expect(sortHeaders(descHeaders, listViewGroupOrderColumn(desc.grouping, 0), 'period_label', 'desc')).toEqual([ + 'Q1 2027', 'Nov 2026', 'Q4 2026', 'Oct 2026', 'Sep 2026', 'Q4 2025', 'Unscheduled', + ]); + // Every count still rides the same row. + expect(Object.fromEntries(headers.map((h) => [h.period_label, h.count]))) + .toEqual(Object.fromEntries(FORECAST_PERIODS.map(([label, , rows]) => [label, rows]))); + }); + + it('`min` ascending and `max` descending are one rule: each group sits where its FIRST row would, rows sorted by `orderBy`', () => { + // An order field that is NOT a function of the group key: owners ordered + // by the dates of their deals. + const deals = [ + { id: 'a1', owner: 'avery', closed_on: '2026-01-05' }, + { id: 'a2', owner: 'avery', closed_on: '2026-06-01' }, + { id: 'b1', owner: 'blake', closed_on: '2026-03-01' }, + { id: 'c1', owner: 'casey', closed_on: '2026-02-01' }, + { id: 'c2', owner: 'casey', closed_on: '2026-07-01' }, + ]; + for (const order of ['asc', 'desc'] as const) { + const grouping = { fields: [{ field: 'owner', orderBy: 'closed_on', order }] }; + const headers = reduceHeaderQuery(deals as unknown as Row[], compileListViewGroupQuery({ grouping })); + const sorted = [...deals].sort((a, b) => (order === 'asc' ? 1 : -1) * a.closed_on.localeCompare(b.closed_on)); + const firstAppearance = [...new Set(sorted.map((d) => d.owner))]; + expect(sortHeaders(headers, listViewGroupOrderColumn(grouping, 0), 'owner', order), order).toEqual(firstAppearance); + } + expect(['avery', 'casey', 'blake']).toEqual( + [...new Set([...deals].sort((a, b) => a.closed_on.localeCompare(b.closed_on)).map((d) => d.owner))], + ); + }); + + it('a level that orders by its own key compiles no node, and its sort column is the key itself', () => { + for (const fields of [[{ field: 'period_label' }], [{ field: 'period_label', orderBy: 'period_label', order: 'desc' as const }]]) { + const grouping = { fields }; + expect(compileListViewGroupQuery({ grouping }).aggregations).toEqual([ + { function: 'count', alias: LIST_VIEW_GROUP_COUNT_ALIAS }, + ]); + expect(listViewGroupOrderColumn(grouping, 0)).toBe('period_label'); + } + }); + + it('a column summary `min` over the same field IS the order node — one node, one column', () => { + const query = compileListViewGroupQuery({ + ...byStart(), + columns: [{ field: 'period_start', summary: 'min' }, { field: 'amount', summary: 'sum' }], + }); + expect(query.aggregations).toEqual([ + { function: 'count', alias: LIST_VIEW_GROUP_COUNT_ALIAS }, + { function: 'min', field: 'period_start', alias: 'min_period_start' }, + { function: 'sum', field: 'amount', alias: 'sum_amount' }, + ]); + }); + + it('per level, in nesting order; `depth` carries only the grouped levels\' nodes, and an outer level folds exactly', () => { + const rows = FORECASTS.map((f, i) => ({ ...f, region: i % 2 === 0 ? 'north' : 'south', region_rank: i % 2 === 0 ? 2 : 1 })); + const view = { + grouping: { + fields: [ + { field: 'region', orderBy: 'region_rank' }, + { field: 'period_label', orderBy: 'period_start', order: 'desc' as const }, + ], + }, + }; + const leaves = compileListViewGroupQuery(view); + expect(leaves.aggregations).toEqual([ + { function: 'count', alias: LIST_VIEW_GROUP_COUNT_ALIAS }, + { function: 'min', field: 'region_rank', alias: 'min_region_rank' }, + { function: 'max', field: 'period_start', alias: 'max_period_start' }, + ]); + const outer = compileListViewGroupQuery(view, { depth: 1 }); + expect(outer.aggregations).toEqual([ + { function: 'count', alias: LIST_VIEW_GROUP_COUNT_ALIAS }, + { function: 'min', field: 'region_rank', alias: 'min_region_rank' }, + ]); + expect(listViewGroupOrderColumn(view.grouping, 1)).toBe('max_period_start'); + + // The outer level's value, folded from the leaves (`min` of mins), equals + // the outer level's own query. + const leafRows = reduceHeaderQuery(rows as unknown as Row[], leaves); + const outerRows = reduceHeaderQuery(rows as unknown as Row[], outer); + for (const region of ['north', 'south']) { + const folded = orderStatistic(leafRows.filter((r) => r.region === region).map((r) => r.min_region_rank), (a, b) => a < b); + expect(folded, region).toBe(outerRows.find((r) => r.region === region)!.min_region_rank); + } + expect(sortHeaders(outerRows, listViewGroupOrderColumn(view.grouping, 0), 'region', 'asc')).toEqual(['south', 'north']); + }); + + it('refuses a blank `orderBy` — INVALID_QUERY / 400 at the level\'s `orderBy`', () => { + for (const orderBy of ['', ' ']) { + const grouping = { fields: [{ field: 'status' }, { field: 'period_label', orderBy }] }; + const err = refusal(() => compileListViewGroupQuery({ grouping })); + expect(err).toBeInstanceOf(ListViewGroupQueryError); + expect(err.reason).toBe('grouping_order_by_blank'); + expect(err.code).toBe('INVALID_QUERY'); + expect(err.status).toBe(400); + expect(err.path).toEqual(['grouping', 'fields', 1, 'orderBy']); + // The same refusal from the column helper, and past the compiled depth too. + expect(refusal(() => listViewGroupOrderColumn(grouping, 1)).reason).toBe('grouping_order_by_blank'); + expect(refusal(() => compileListViewGroupQuery({ grouping }, { depth: 1 })).reason).toBe('grouping_order_by_blank'); + } + }); + + it('refuses an order value landing on a grouped field\'s own column', () => { + const err = refusal(() => compileListViewGroupQuery({ + grouping: { fields: [{ field: 'min_period_start' }, { field: 'period_label', orderBy: 'period_start' }] }, + })); + expect(err.reason).toBe('alias_collision'); + expect(err.code).toBe('INVALID_QUERY'); + expect(err.status).toBe(400); + expect(err.path).toEqual(['grouping', 'fields', 1, 'orderBy']); + }); + + it.each([-1, 1, 0.5])('listViewGroupOrderColumn refuses level %s of a one-level grouping', (level) => { + const err = refusal(() => listViewGroupOrderColumn(byStart().grouping, level)); + expect(err.reason).toBe('depth_out_of_range'); + expect(err.code).toBe('INVALID_QUERY'); + expect(err.status).toBe(400); + }); + + it('compiles from a grouping the schema accepts', () => { + const parsed = ListViewSchema.parse({ columns: ['period_label'], grouping: byStart('desc').grouping }); + expect(compileListViewGroupQuery({ grouping: parsed.grouping! }).aggregations) + .toContainEqual({ function: 'max', field: 'period_start', alias: 'max_period_start' }); + }); +}); + // ─── The per-group row page ────────────────────────────────────────────────── describe('compileListViewGroupRowsQuery — rows inside a group are the EXISTING paged find', () => { diff --git a/packages/spec/src/ui/view-grouping-query.ts b/packages/spec/src/ui/view-grouping-query.ts index a3e7f5ba3f7..00137e57606 100644 --- a/packages/spec/src/ui/view-grouping-query.ts +++ b/packages/spec/src/ui/view-grouping-query.ts @@ -114,6 +114,49 @@ * with `depth` — the same query over the first `depth` grouping fields — * rather than folding. This is still one query shape. * + * ## Ordering a level by another field (#25786) + * + * A level's groups are ordered by their own key unless the level names an + * `orderBy` — a period label grouped in time order by its period start. The + * consumer still sorts the header rows; a header row did not carry the value + * to sort by, so the header query does now: per ordered level, ONE node over + * `orderBy` — `min` for `order: 'asc'`, `max` for `'desc'` — under + * {@link columnSummaryAlias} (`min_period_start`), named for the consumer by + * {@link listViewGroupOrderColumn}. `min` ascending and `max` descending is + * one rule: each group sits where its FIRST row would sit if the rows were + * sorted by `orderBy` in `order`. When `orderBy` is a function of the group + * key (one period start per label) the two nodes are the same number; when + * it is not, the rule still names one answer, and the grouping-field key never + * leaves `groupBy` — a second `groupBy` column would split a label into one + * header per distinct order value, which is the two-level workaround this + * key retires. + * + * * **Dedup and collision** are the summaries' rules: a column summary + * `min` over the same field is the same node and the same column; an + * alias landing on a grouped field's own column is refused + * (`alias_collision`). + * * **Multi-level** folds exactly — `min` of the leaves' mins, `max` of + * their maxes — so an outer level's order value is derived from the + * leaf rows like its `count`. + * * **The operand's type** is the aggregate × field-type table's: `min` / + * `max` accept the numeric, temporal and boolean classes and refuse every + * other (the string classes for their collation-dependent order, the + * virtual `formula`). Two doors refuse with `INVALID_FIELD` / 400: the + * data door's ingress (`findData`'s field-existence gate) an unknown or + * dotted name, and `engine.aggregate`'s type door a refused type. `os + * validate` refuses all three at authoring. This module has no field + * types and judges nothing about them. + * * **What the consumer owes** is stated on `GroupingFieldSchema.orderBy`: a + * group whose order value is `null` (every row empty there) sorts last + * in both directions, and equal values fall back to the group key in the + * same direction. + * * **The header set is sorted whole.** The header query is compiled + * unpaged, and the consumer's sort is right only over every group: a + * `limit` on it is sliced by `findData` in the engine's emission order, + * before any sort, so a paged header set honours neither `order` nor + * `orderBy` — the recorded "Header cardinality is unbounded" limit on + * `GroupingConfigSchema`, unchanged here. + * * ## The door — existing, not new * * Both compiled queries ride the data endpoint's EXISTING query door: @@ -141,7 +184,9 @@ * objectui's `filter-converter` on the client. * * **Ordering the groups.** `EngineAggregateOptions` carries no `orderBy`; * `GroupingField.order` is applied by the consumer over the header rows, - * a set the size of the group count. + * a set the size of the group count. What the header query DOES carry for + * it is the value to sort by when a level orders by another field — see + * "Ordering a level by another field" above. * * ## No business logic here (Prime Directive #2) * @@ -279,6 +324,7 @@ export type ListViewGroupQueryRefusal = | 'alias_collision' | 'grouping_empty' | 'grouping_field_blank' + | 'grouping_order_by_blank' | 'depth_out_of_range' | 'group_key_not_a_prefix' | 'group_key_not_scalar'; @@ -350,6 +396,93 @@ function groupingFieldNames(grouping: GroupingConfig | undefined): string[] { }); } +/** One grouping level, as `grouping.fields[]` declares it. */ +type GroupingLevel = GroupingConfig['fields'][number]; + +/** + * The order node of one grouping level (#25786), validated, or `undefined` + * when the level orders by its own key — `orderBy` absent, or naming the + * grouping field itself. `index` is the level's position, for the refusal's + * path. See "Ordering a level by another field" in the module note. + */ +function groupOrderNode(level: GroupingLevel, index: number): AggregationNode | undefined { + const orderBy: unknown = (level as { orderBy?: unknown }).orderBy; + if (orderBy === undefined) return undefined; + if (typeof orderBy !== 'string' || orderBy.trim() === '') { + throw new ListViewGroupQueryError( + 'grouping_order_by_blank', + ['grouping', 'fields', index, 'orderBy'], + `grouping.fields[${index}].orderBy is not a field name, so the header query has no value to order ` + + 'this level\'s groups by. Name the field whose value orders the groups, or remove `orderBy` to ' + + 'order them by the grouping field itself.', + ); + } + if (orderBy === level.field) return undefined; + const fn: AggregationFunction = level.order === 'desc' ? 'max' : 'min'; + return { function: fn, field: orderBy, alias: columnSummaryAlias(fn, orderBy) }; +} + +/** + * The header-row column a grouping level's groups are SORTED by (#25786) — + * the one name both ends read, so the consumer never re-derives the alias the + * compiler minted: + * + * * the level's own grouping field when it orders by its key (no `orderBy`, + * or `orderBy` naming the grouping field itself); + * * otherwise the column the header query carries the order value under — + * `min_` for `order: 'asc'`, `max_` for `'desc'`. + * + * At an outer level of a multi-level header set, the value folds exactly from + * the leaf rows sharing its prefix — the `min` of their mins, the `max` of + * their maxes. `level` is the level's index in `grouping.fields`. + * + * @throws {ListViewGroupQueryError} `grouping_empty` / `grouping_field_blank` + * as {@link compileListViewGroupQuery} refuses them, `grouping_order_by_blank` + * for a blank `orderBy`, and `depth_out_of_range` for a `level` that is not + * an index of `grouping.fields` (all `INVALID_QUERY` / 400) + */ +export function listViewGroupOrderColumn(grouping: GroupingConfig, level: number): string { + const names = groupingFieldNames(grouping); + if (!Number.isInteger(level) || level < 0 || level >= names.length) { + throw new ListViewGroupQueryError( + 'depth_out_of_range', + ['grouping', 'fields'], + `level ${String(level)} is outside 0..${names.length - 1}: it names one of the ${names.length} declared ` + + 'grouping level(s), from the outermost (0).', + ); + } + return groupOrderNode(grouping.fields[level], level)?.alias ?? names[level]; +} + +/** + * The order nodes of the first `depth` levels (#25786), in nesting order — + * every level's `orderBy` is validated, only the grouped levels contribute. + * Refuses an alias landing on a grouped field's column, as the summaries do. + */ +function groupOrderNodes( + grouping: GroupingConfig, + depth: number, + groupByNames: readonly string[], +): AggregationNode[] { + const grouped = new Set(groupByNames); + const nodes: AggregationNode[] = []; + grouping.fields.forEach((level, index) => { + const node = groupOrderNode(level, index); + if (!node || index >= depth) return; + if (grouped.has(node.alias)) { + throw new ListViewGroupQueryError( + 'alias_collision', + ['grouping', 'fields', index, 'orderBy'], + `grouping.fields[${index}] orders its groups by "${node.field}", whose order value lands under ` + + `"${node.alias}", which is also a grouped field's own column on the header row — one column cannot ` + + 'carry both the group key and the order value. Rename the field.', + ); + } + nodes.push(node); + }); + return nodes; +} + /** * The per-column summaries a view declares, as aggregation nodes — the header * query's `aggregations` after the `count` node. Refuses an unmapped member @@ -495,11 +628,14 @@ export function deriveColumnSummary( * * `aggregations` — the `count` node first * ({@link LIST_VIEW_GROUP_COUNT_ALIAS}), then one node per declared * column summary ({@link COLUMN_SUMMARY_AGGREGATION}, - * {@link columnSummaryAlias}), in column order, deduplicated. + * {@link columnSummaryAlias}), in column order, deduplicated, then one + * order node per grouped level that orders by another field (`min` / + * `max` over its `orderBy`, {@link listViewGroupOrderColumn}), in nesting + * order, unless a summary already carries it. * * @throws {ListViewGroupQueryError} on an unmapped summary (fork i — * `NOT_IMPLEMENTED` / 501), an alias colliding with a grouped field, an - * empty grouping, a blank grouping field, or a `depth` outside + * empty grouping, a blank grouping field or `orderBy`, or a `depth` outside * `1..fields.length` (all `INVALID_QUERY` / 400). */ export function compileListViewGroupQuery( @@ -527,9 +663,19 @@ export function compileListViewGroupQuery( + 'Rename the field.', ); } + const summaries = summaryAggregationNodes(view.columns, groupBy); + // [#25786] An order node a column summary already declares (`min` over the + // same field) is that summary's node and column — one number, one column. + const declared = new Set(summaries.map((node) => node.alias)); + const orders = groupOrderNodes(view.grouping, depth, groupBy).filter((node) => { + if (declared.has(node.alias)) return false; + declared.add(node.alias); + return true; + }); const aggregations: AggregationNode[] = [ { function: 'count', alias: LIST_VIEW_GROUP_COUNT_ALIAS }, - ...summaryAggregationNodes(view.columns, groupBy), + ...summaries, + ...orders, ]; const query: EngineAggregateOptions = { groupBy, aggregations }; if (options.where !== undefined) query.where = options.where; diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 9f7849ae084..1e10e9d9b29 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -2272,6 +2272,73 @@ describe('GroupingFieldSchema', () => { expect(() => GroupingFieldSchema.parse(field)).not.toThrow(); }); + // ========================================================================== + // [#25786] `orderBy` — order a level's groups by another field's value + // ========================================================================== + + it('accepts `orderBy` as an authorable key, with no `unrecognized_keys` and no default', () => { + const result = GroupingFieldSchema.safeParse({ field: 'period_label', orderBy: 'period_start', order: 'desc' }); + expect(result.success).toBe(true); + expect(result.data!.orderBy).toBe('period_start'); + // Absent means "order by the group key" — nothing is defaulted in. + expect(GroupingFieldSchema.parse({ field: 'period_label' })).not.toHaveProperty('orderBy'); + // …and the whole list view accepts it, one level down. + const view = ListViewSchema.safeParse({ + columns: ['name'], + grouping: { fields: [{ field: 'period_label', orderBy: 'period_start' }] }, + }); + expect(view.success, JSON.stringify(view.error?.issues)).toBe(true); + }); + + it.each([ + ['an empty name', '', 'too_small'], + ['a sort node list', [{ field: 'period_start', order: 'desc' }], 'invalid_type'], + ['a number', 1, 'invalid_type'], + ])('refuses %s at `orderBy`', (_label, value, code) => { + const result = GroupingFieldSchema.safeParse({ field: 'period_label', orderBy: value }); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => [i.path.join('.'), i.code])).toEqual([['orderBy', code]]); + }); + + // The name is compiled into the `min` / `max` node verbatim, as `field` is + // into `groupBy`, so a padded spelling is refused the same way (#17360's + // ruling), not trimmed: it would name a column no record has. + it.each([ + ['leading', ' period_start', 'Write "period_start".'], + ['trailing', 'period_start ', 'Write "period_start".'], + ['a tab', '\tperiod_start', 'Write "period_start".'], + ['whitespace only', ' ', 'nothing but whitespace'], + ])('refuses %s whitespace at `orderBy`, by name', (_label, spelling, remedy) => { + const result = GroupingFieldSchema.safeParse({ field: 'period_label', orderBy: spelling }); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => [i.path.join('.'), i.code])).toEqual([['orderBy', 'invalid_format']]); + const message = result.error!.issues[0]!.message; + expect(message).toContain('grouping.fields[].orderBy'); + expect(message).toContain(JSON.stringify(spelling)); + expect(message).toContain(remedy); + }); + + // A native `.regex()`, not a refinement: the published JSON Schema states the + // rule, so a validator reading packages/spec/json-schema refuses it too (#18670). + it('publishes the padded-name rule on `orderBy` as the JSON Schema `pattern`', () => { + const json = z.toJSONSchema(GroupingFieldSchema, { unrepresentable: 'any', io: 'input' }) as { + properties: Record; + }; + expect(json.properties.orderBy).toMatchObject({ minLength: 1, pattern: '^(?:\\S|\\S[\\s\\S]*\\S)?$' }); + const pattern = new RegExp(json.properties.orderBy!.pattern!); + expect([' period_start', 'period_start ', ' '].map((s) => pattern.test(s))).toEqual([false, false, false]); + expect(pattern.test('period_start')).toBe(true); + }); + + it('refuses a padded `orderBy` through ListViewSchema at `grouping.fields[N].orderBy`', () => { + const result = ListViewSchema.safeParse({ + columns: ['name'], + grouping: { fields: [{ field: 'status' }, { field: 'period_label', orderBy: 'period_start ' }] }, + }); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => i.path.join('.'))).toEqual(['grouping.fields.1.orderBy']); + }); + // ========================================================================== // [#17360] A padded `field` is REFUSED, not trimmed // (objectui#7347 ruling C, decision batch #110 item 5 — refuse at the producer) diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 76e28a374d3..7982f33f422 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1280,6 +1280,35 @@ function checkGroupingFieldName(raw: string): string | undefined { + `statement about the data. ${remedy}`; } +/** + * The refusal for a padded `grouping.fields[].orderBy` name (#25786): the same + * non-padded rule as {@link checkGroupingFieldName}, for the same reason — the + * name is compiled into the group header query verbatim, here as a `min` / + * `max` operand. Declared as the key's `.regex()` rather than a + * `.superRefine()`, so the published JSON Schema states the rule as its + * `pattern` instead of adding an `x-dropped-refinements` site (#18670). The + * empty string is the key's `min(1)` and the compiler's + * `grouping_order_by_blank`, not this. + * + * Why `field` does not do the same: its refusal has shipped as zod issue code + * `custom`, and a `.regex()` answers `invalid_format`. Moving it is a change + * a consumer can see, so it is a card of its own, not a tidy-up here. + */ +function groupingOrderByPaddedRefusal(input: unknown): string { + const raw = String(input); + const trimmed = raw.trim(); + const remedy = trimmed === '' + ? 'Name the field to order the groups by, or remove `orderBy` — this value is nothing but whitespace.' + : `Write ${JSON.stringify(trimmed)}.`; + + return '`grouping.fields[].orderBy` names the field exactly as it is stored, with no leading or ' + + `trailing whitespace — received ${JSON.stringify(raw)}. The group header query is compiled ` + + 'from this string verbatim (`compileListViewGroupQuery` sends it as the operand of the level\'s ' + + '`min` / `max` node), so a padded spelling names a column no record has: the data door refuses ' + + 'the header query `400 INVALID_FIELD` and the grouped view renders no group. ' + + remedy; +} + /* * --------------------------------------------------------------------------- * `groupByField` — the same non-padded name rule, three more schemas (#17499) @@ -1371,6 +1400,24 @@ const groupByFieldCheck = (view: GroupByFieldView) => (raw: string, ctx: z.Refin * empty group's key is `null`. `order` and `collapsed` are presentation: * `EngineAggregateOptions` carries no `orderBy`, so the consumer sorts the * header rows (a set the size of the group count) and folds/unfolds them. + * + * `orderBy` (#25786) names the field whose value the consumer sorts this + * level's groups by, when that is not the grouping field itself — a period + * label grouped in time order by its period start. The sort stays the + * consumer's; what the header query adds is the VALUE to sort by, which a + * header row did not carry before: one `min` (for `order: 'asc'`) or `max` + * (for `'desc'`) node over `orderBy` per ordered level, so each group is + * placed where its first row would be if the rows were sorted by `orderBy` in + * `order`. `listViewGroupOrderColumn` (`view-grouping-query.ts`) names the + * header-row column to sort by. The operand is judged by the aggregate × + * field-type table (`AGGREGATE_FIELD_TYPE_COMPATIBILITY`, the `min` / `max` + * row): a numeric, temporal or boolean field — the table refuses the string + * classes because two backends order strings by two collations — and + * `os validate` refuses any other (`list-view-group-order-unorderable`). The + * name is compiled verbatim, so a padded one is refused here as `field`'s is + * (#17360's ruling, {@link groupingOrderByPaddedRefusal}). The header set is + * sorted whole: a `limit` on the header query slices before any sort (the + * "Header cardinality is unbounded" limit on {@link GroupingConfigSchema}). */ export const GroupingFieldSchema = lazySchema(() => strictObject({ surface: 'this grouping field', @@ -1387,7 +1434,23 @@ export const GroupingFieldSchema = lazySchema(() => strictObject({ + '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: z.enum(['asc', 'desc']).default('asc').describe('Group sort order — applied by the consumer over the header rows (the aggregate query carries no orderBy)'), + order: z.enum(['asc', 'desc']).default('asc').describe( + '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: z.string().min(1) + .regex(NON_PADDED_FIELD_NAME_PATTERN, { error: (iss) => groupingOrderByPaddedRefusal(iss.input) }) + .optional() + .describe( + '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: z.boolean().default(false).describe('Collapse groups by default (presentation only)'), }));