From 1002da83879ba1c6b2c61c626f75ceeb02fa45cb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 06:45:20 +0000 Subject: [PATCH 1/4] feat(plugin-grid): order a grouped grid's groups by the level's orderBy A level naming grouping.fields[].orderBy now orders its groups by that field: the server path forwards orderBy into the header query and reads the min/max column listViewGroupOrderColumn names; the client path folds min (asc) / max (desc) from each group's rows. Nulls sort last in both directions; ties fall back to the key comparison in the same direction. Claude-Session: https://claude.ai/code/session_01Baqv5mDLFH63hvh9G4YGn5 Co-authored-by: Claude --- ui/packages/plugin-grid/src/ObjectGrid.tsx | 17 +- .../src/__tests__/groupOrderBy-25858.test.tsx | 369 ++++++++++++++++++ ui/packages/plugin-grid/src/useGroupedData.ts | 96 ++++- .../plugin-grid/src/useServerGrouping.ts | 43 +- 4 files changed, 504 insertions(+), 21 deletions(-) create mode 100644 ui/packages/plugin-grid/src/__tests__/groupOrderBy-25858.test.tsx diff --git a/ui/packages/plugin-grid/src/ObjectGrid.tsx b/ui/packages/plugin-grid/src/ObjectGrid.tsx index 5f7ea5a8b0c..189e73d15b7 100644 --- a/ui/packages/plugin-grid/src/ObjectGrid.tsx +++ b/ui/packages/plugin-grid/src/ObjectGrid.tsx @@ -3430,18 +3430,29 @@ export const ObjectGrid: React.FC = ({ // [objectui#7189] The entries the SERVER groups by: the render grouping less // any key this principal may not read (see `groupingKeyReadable`). Keyed on // the names, so a host rebuilding `grouping` every render re-asks nothing. + // [objectstack#25786] A level's `orderBy` rides along — the header query + // compiles its `min` / `max` column from it — under the same read gate: an + // `orderBy` this principal may not read is dropped, and the level orders by + // its own key, since the header row would carry that field's value and the + // group order would disclose it. const serverGroupingKey = serverGroupedFetch ? JSON.stringify( usableGroupingFields(groupingForRender?.fields) .filter((gf) => groupingKeyReadable(gf.field)) - .map((gf) => [gf.field, gf.order ?? null, gf.collapsed ?? null]), + .map((gf) => [ + gf.field, + gf.order ?? null, + gf.collapsed ?? null, + gf.orderBy && groupingKeyReadable(gf.orderBy) ? gf.orderBy : null, + ]), ) : '[]'; const serverGroupingFields = React.useMemo( - () => (JSON.parse(serverGroupingKey) as Array<[string, 'asc' | 'desc' | null, boolean | null]>) - .map(([field, order, collapsed]) => ({ + () => (JSON.parse(serverGroupingKey) as Array<[string, 'asc' | 'desc' | null, boolean | null, string | null]>) + .map(([field, order, collapsed, orderBy]) => ({ field, ...(order ? { order } : {}), + ...(orderBy !== null ? { orderBy } : {}), ...(collapsed !== null ? { collapsed } : {}), })), [serverGroupingKey], diff --git a/ui/packages/plugin-grid/src/__tests__/groupOrderBy-25858.test.tsx b/ui/packages/plugin-grid/src/__tests__/groupOrderBy-25858.test.tsx new file mode 100644 index 00000000000..8c9daeafd4a --- /dev/null +++ b/ui/packages/plugin-grid/src/__tests__/groupOrderBy-25858.test.tsx @@ -0,0 +1,369 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#25858 — a grouped grid orders a level's groups by the level's + * `orderBy` (spec `grouping.fields[].orderBy`, objectstack#25786), while the + * group label still comes from `field`. + * + * HotCRM's forecast list groups by `period_label`, which sorts as text + * (`Nov 2026` before `Oct 2026` before `Q1 2027`). With `orderBy: + * 'period_start'` the spec's header query carries one `min` (asc) / `max` + * (desc) column per ordered level, `listViewGroupOrderColumn` names it, and + * the consumer owes the sort: by that value in `order`, a group with no value + * last in both directions, equal values falling back to the key comparison in + * the same direction. + * + * Pinned on both of the grid's group sources, since each sorts on its own: + * - SERVER-grouped: header rows shaped as `compileListViewGroupQuery` + * compiles them, at the hook and through `ObjectGrid` (which must forward + * `orderBy` into the header query for the column to exist at all); + * - CLIENT-grouped: the group's order value is folded from its rows. + * + * CONTROLS: a level with no `orderBy` keeps today's label / option order, and + * its header query carries no order column. + */ +import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; +import { render, cleanup, renderHook } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { compileListViewGroupQuery, type ListViewGroupHeaderRow } from '@objectstack/spec/ui'; + +import { ObjectGrid } from '../ObjectGrid'; +import { useGroupedData, useGroupedDataInOptionOrder } from '../useGroupedData'; +import { buildCategoryOrder, buildCategoryRank } from '@object-ui/core'; +import { ActionProvider } from '@object-ui/react'; +import { registerAllFields } from '@object-ui/fields'; +import type { DataSource, GroupingConfig, ObjectGridSchema } from '@object-ui/types'; + +beforeAll(() => { + registerAllFields(); +}); + +const OBJECT = 'forecast'; + +const OBJECT_FIELDS = { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, + period_label: { type: 'text', label: 'Period' }, + period_start: { type: 'date', label: 'Period start' }, +}; + +/** + * Forecast rows whose labels sort differently as text, two rows for some + * periods, and one period with no start date. `Oct 2026` and `Q4 2026` share a + * start date, so the key comparison breaks their tie. + */ +const ROWS: Array> = [ + { id: 'f1', name: 'A', period_label: 'Q4 2025', period_start: '2025-10-01' }, + { id: 'f2', name: 'B', period_label: 'Sep 2026', period_start: '2026-09-01' }, + { id: 'f3', name: 'C', period_label: 'Oct 2026', period_start: '2026-10-01' }, + { id: 'f4', name: 'D', period_label: 'Q4 2026', period_start: '2026-10-01' }, + { id: 'f5', name: 'E', period_label: 'Nov 2026', period_start: '2026-11-01' }, + { id: 'f6', name: 'F', period_label: 'Q1 2027', period_start: '2027-01-01' }, + { id: 'f7', name: 'G', period_label: 'Undated', period_start: null }, + { id: 'f8', name: 'H', period_label: 'Nov 2026', period_start: '2026-11-01' }, + { id: 'f9', name: 'I', period_label: 'Q4 2025', period_start: '2025-10-01' }, +]; + +/** Descending `period_start`; the tie in key order, descending; undated last. */ +const DESC_BY_START = ['Q1 2027', 'Nov 2026', 'Q4 2026', 'Oct 2026', 'Sep 2026', 'Q4 2025', 'Undated']; +/** Ascending `period_start`; the tie in key order, ascending; undated still last. */ +const ASC_BY_START = ['Q4 2025', 'Sep 2026', 'Oct 2026', 'Q4 2026', 'Nov 2026', 'Q1 2027', 'Undated']; + +type AggregationNode = { function: string; field?: string; alias: string }; +type HeaderQuery = { where?: unknown; groupBy?: string[]; aggregations?: AggregationNode[] }; + +/** The `FilterCondition` subset the compiled group queries use. */ +function matches(row: Record, cond: unknown): boolean { + if (cond === undefined || cond === null) return true; + return Object.entries(cond as Record).every(([key, value]) => { + if (key === '$and') return (value as unknown[]).every((c) => matches(row, c)); + if (value && typeof value === 'object' && !Array.isArray(value)) { + const op = value as Record; + if ('$eq' in op) return row[key] === op.$eq; + if ('$null' in op) return op.$null ? row[key] == null : row[key] != null; + throw new Error(`double: unsupported operator ${JSON.stringify(op)}`); + } + return row[key] === value; + }); +} + +/** + * Answer a compiled header query over `rows` the way an engine's `GROUP BY` + * does: one row per bucket in the stored keys' order, each grouped field under + * its own name, then every aggregation node under its alias — `count`, and + * `min` / `max` over the non-null values (`null` when there are none). + */ +function answerHeaderQuery(rows: Array>, query: HeaderQuery): ListViewGroupHeaderRow[] { + const groupBy = query.groupBy ?? []; + const buckets = new Map>>(); + for (const row of rows.filter((r) => matches(r, query.where))) { + const key = JSON.stringify(groupBy.map((f) => row[f] ?? null)); + buckets.set(key, [...(buckets.get(key) ?? []), row]); + } + return [...buckets.entries()] + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([key, members]) => { + const values = JSON.parse(key) as unknown[]; + const out: Record = Object.fromEntries(groupBy.map((f, i) => [f, values[i]])); + for (const node of query.aggregations ?? []) { + if (node.function === 'count') { + out[node.alias] = members.length; + continue; + } + const present = members.map((m) => m[node.field!]).filter((v) => v !== null && v !== undefined) as string[]; + if (node.function !== 'min' && node.function !== 'max') throw new Error(`double: unsupported ${node.function}`); + out[node.alias] = present.length === 0 + ? null + : present.reduce((acc, v) => ((node.function === 'min' ? v < acc : v > acc) ? v : acc)); + } + return out as ListViewGroupHeaderRow; + }); +} + +/** Header rows for every depth of `grouping`, compiled by the spec. */ +function compiledHeaders(grouping: GroupingConfig, rows = ROWS): ListViewGroupHeaderRow[][] { + return grouping.fields.map((_, depthIndex) => + answerHeaderQuery(rows, compileListViewGroupQuery({ grouping } as never, { depth: depthIndex + 1 }) as HeaderQuery), + ); +} + +afterEach(() => cleanup()); + +describe('the header query an ordered level compiles (objectstack#25786, the contract this card reads)', () => { + it('carries a `max` column for `desc` and a `min` column for `asc`, beside the one groupBy column', () => { + const desc = compileListViewGroupQuery({ + grouping: { fields: [{ field: 'period_label', orderBy: 'period_start', order: 'desc' }] }, + } as never); + expect(desc.groupBy).toEqual(['period_label']); + expect(desc.aggregations).toContainEqual({ function: 'max', field: 'period_start', alias: 'max_period_start' }); + const asc = compileListViewGroupQuery({ + grouping: { fields: [{ field: 'period_label', orderBy: 'period_start' }] }, + } as never); + expect(asc.aggregations).toContainEqual({ function: 'min', field: 'period_start', alias: 'min_period_start' }); + }); +}); + +describe('server-grouped: the hook sorts header rows by the level\'s order column (objectstack#25858)', () => { + const groupLabelsOf = (grouping: GroupingConfig, headers = compiledHeaders(grouping)) => { + const { result } = renderHook(() => useGroupedData(grouping, [], undefined, undefined, { headers })); + return result.current.groups.map((g) => g.label); + }; + + it('`orderBy: period_start, order: desc` — one header per label, descending start, undated last', () => { + expect(groupLabelsOf({ fields: [{ field: 'period_label', orderBy: 'period_start', order: 'desc' }] })) + .toEqual(DESC_BY_START); + }); + + it('`order: asc` — ascending start; the undated group is STILL last', () => { + expect(groupLabelsOf({ fields: [{ field: 'period_label', orderBy: 'period_start', order: 'asc' }] })) + .toEqual(ASC_BY_START); + }); + + it('the group count is still the header\'s own', () => { + const grouping: GroupingConfig = { fields: [{ field: 'period_label', orderBy: 'period_start', order: 'desc' }] }; + const headers = compiledHeaders(grouping); + const { result } = renderHook(() => useGroupedData(grouping, [], undefined, undefined, { headers })); + expect(result.current.groups.map((g) => [g.label, g.count])).toEqual( + DESC_BY_START.map((label) => [label, ROWS.filter((r) => r.period_label === label).length]), + ); + }); + + it('an OUTER level orders by its own order column; the inner level keeps label order', () => { + const grouping: GroupingConfig = { + fields: [ + { field: 'period_label', orderBy: 'period_start', order: 'desc' }, + { field: 'name' }, + ], + }; + const headers = compiledHeaders(grouping); + const { result } = renderHook(() => useGroupedData(grouping, [], undefined, undefined, { headers })); + expect(result.current.groups.map((g) => g.label)).toEqual(DESC_BY_START); + const nov = result.current.groups.find((g) => g.label === 'Nov 2026')!; + expect(nov.subgroups.map((g) => g.label)).toEqual(['E', 'H']); + }); + + it('CONTROL — no `orderBy`: label order, exactly as before', () => { + expect(groupLabelsOf({ fields: [{ field: 'period_label', order: 'desc' }] })).toEqual( + ['Undated', 'Sep 2026', 'Q4 2026', 'Q4 2025', 'Q1 2027', 'Oct 2026', 'Nov 2026'], + ); + }); + + it('CONTROL — `orderBy` naming the grouping field itself orders by the key', () => { + expect(groupLabelsOf({ fields: [{ field: 'period_label', orderBy: 'period_label' }] })).toEqual( + ['Nov 2026', 'Oct 2026', 'Q1 2027', 'Q4 2025', 'Q4 2026', 'Sep 2026', 'Undated'], + ); + }); +}); + +describe('client-grouped: each group\'s order value is folded from its rows (objectstack#25858)', () => { + const groupLabelsOf = (grouping: GroupingConfig, rows: Array> = ROWS) => { + const { result } = renderHook(() => useGroupedData(grouping, rows)); + return result.current.groups.map((g) => g.label); + }; + + it('`orderBy: period_start, order: desc` — descending start, undated last', () => { + expect(groupLabelsOf({ fields: [{ field: 'period_label', orderBy: 'period_start', order: 'desc' }] })) + .toEqual(DESC_BY_START); + }); + + it('`order: asc` — ascending start, undated last', () => { + expect(groupLabelsOf({ fields: [{ field: 'period_label', orderBy: 'period_start' }] })).toEqual(ASC_BY_START); + }); + + it('a group sits where its FIRST row would: its `min` for asc, its `max` for desc', () => { + // `wide` spans 1..10; `narrow` is 5 throughout. + const rows = [ + { id: 1, bucket: 'wide', rank: 10 }, + { id: 2, bucket: 'narrow', rank: 5 }, + { id: 3, bucket: 'wide', rank: 1 }, + { id: 4, bucket: 'narrow', rank: 5 }, + ]; + expect(groupLabelsOf({ fields: [{ field: 'bucket', orderBy: 'rank', order: 'asc' }] }, rows)) + .toEqual(['wide', 'narrow']); + expect(groupLabelsOf({ fields: [{ field: 'bucket', orderBy: 'rank', order: 'desc' }] }, rows)) + .toEqual(['wide', 'narrow']); + // Sorted by `rank`, the rows read 1(wide) 5 5 10 ascending and 10(wide) + // 5 5 1 descending: `wide` leads both. Lift its smallest value past 5 and + // it no longer leads ascending. + const narrowFirstAsc = [{ id: 1, bucket: 'wide', rank: 7 }, { id: 2, bucket: 'narrow', rank: 5 }]; + expect(groupLabelsOf({ fields: [{ field: 'bucket', orderBy: 'rank', order: 'asc' }] }, narrowFirstAsc)) + .toEqual(['narrow', 'wide']); + }); + + it('a numeric order value arriving as a numeric string orders as the number', () => { + const rows = [ + { id: 1, bucket: 'a', amount: '3.10' }, + { id: 2, bucket: 'b', amount: '3.9' }, + { id: 3, bucket: 'c', amount: '12' }, + ]; + expect(groupLabelsOf({ fields: [{ field: 'bucket', orderBy: 'amount' }] }, rows)).toEqual(['a', 'b', 'c']); + }); + + it('CONTROL — no `orderBy`: label order, exactly as before', () => { + expect(groupLabelsOf({ fields: [{ field: 'period_label' }] })).toEqual( + ['Nov 2026', 'Oct 2026', 'Q1 2027', 'Q4 2025', 'Q4 2026', 'Sep 2026', 'Undated'], + ); + }); +}); + +describe('precedence: `orderBy` ahead of a declared option order (objectstack#25858)', () => { + const STAGES = [ + { value: 'won', label: 'Won' }, + { value: 'open', label: 'Open' }, + { value: 'lost', label: 'Lost' }, + ]; + const ranks = new Map([['stage', buildCategoryRank(buildCategoryOrder(STAGES))!]]); + const rows = [ + { id: 1, stage: 'won', closed_on: '2026-03-01' }, + { id: 2, stage: 'open', closed_on: '2026-01-01' }, + { id: 3, stage: 'lost', closed_on: '2026-02-01' }, + ]; + + it('a level with `orderBy` orders by it, not by the options', () => { + const { result } = renderHook(() => + useGroupedDataInOptionOrder( + { fields: [{ field: 'stage', orderBy: 'closed_on' }] }, rows, undefined, undefined, undefined, ranks, + ), + ); + expect(result.current.groups.map((g) => g.keyValues.stage)).toEqual(['open', 'lost', 'won']); + }); + + it('equal order values fall back to the option order, in the same direction', () => { + const tied = rows.map((r) => ({ ...r, closed_on: '2026-01-01' })); + const asc = renderHook(() => + useGroupedDataInOptionOrder( + { fields: [{ field: 'stage', orderBy: 'closed_on' }] }, tied, undefined, undefined, undefined, ranks, + ), + ); + expect(asc.result.current.groups.map((g) => g.keyValues.stage)).toEqual(['won', 'open', 'lost']); + const desc = renderHook(() => + useGroupedDataInOptionOrder( + { fields: [{ field: 'stage', orderBy: 'closed_on', order: 'desc' }] }, tied, undefined, undefined, undefined, ranks, + ), + ); + expect(desc.result.current.groups.map((g) => g.keyValues.stage)).toEqual(['lost', 'open', 'won']); + }); + + it('CONTROL — without `orderBy` the declared option order is unchanged', () => { + const { result } = renderHook(() => + useGroupedDataInOptionOrder({ fields: [{ field: 'stage' }] }, rows, undefined, undefined, undefined, ranks), + ); + expect(result.current.groups.map((g) => g.keyValues.stage)).toEqual(['won', 'open', 'lost']); + }); +}); + +describe('through ObjectGrid: `orderBy` reaches the header query and orders the painted headers (objectstack#25858)', () => { + const makeServerSource = () => ({ + queryGroupHeaders: vi.fn(async (_object: string, query: HeaderQuery) => answerHeaderQuery(ROWS, query)), + find: vi.fn(async (_object: string, params: Record) => { + const matching = ROWS.filter((r) => matches(r, params.$filter)); + const skip = (params.$skip as number | undefined) ?? 0; + const top = (params.$top as number | undefined) ?? matching.length; + return { data: matching.slice(skip, skip + top), total: matching.length }; + }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ name: OBJECT, fields: OBJECT_FIELDS })), + }); + + const renderGrid = (grouping: GroupingConfig, data?: unknown) => { + const ds = makeServerSource(); + render( + + + , + ); + return ds; + }; + + const groupLabels = () => + Array.from(document.querySelectorAll('.group-label')).map((el) => el.textContent); + + it('server-grouped: the header query carries `max_period_start`, and the groups read descending start', async () => { + const ds = renderGrid({ fields: [{ field: 'period_label', orderBy: 'period_start', order: 'desc' }] }); + await vi.waitFor(() => expect(groupLabels()).toEqual(DESC_BY_START)); + const queries = ds.queryGroupHeaders.mock.calls.map(([, query]) => query as HeaderQuery); + expect(queries.length).toBeGreaterThan(0); + for (const query of queries) { + expect(query.groupBy).toEqual(['period_label']); + expect(query.aggregations).toContainEqual({ function: 'max', field: 'period_start', alias: 'max_period_start' }); + } + }); + + it('client-grouped (rows handed in): the groups read descending start', async () => { + const ds = renderGrid( + { fields: [{ field: 'period_label', orderBy: 'period_start', order: 'desc' }] }, + { provider: 'value', items: ROWS }, + ); + await vi.waitFor(() => expect(groupLabels()).toEqual(DESC_BY_START)); + expect(ds.queryGroupHeaders).not.toHaveBeenCalled(); + }); + + it('CONTROL — no `orderBy`: the header query carries no order column, and label order holds', async () => { + const ds = renderGrid({ fields: [{ field: 'period_label' }] }); + await vi.waitFor(() => + expect(groupLabels()).toEqual(['Nov 2026', 'Oct 2026', 'Q1 2027', 'Q4 2025', 'Q4 2026', 'Sep 2026', 'Undated']), + ); + for (const [, query] of ds.queryGroupHeaders.mock.calls) { + expect((query as HeaderQuery).aggregations).toEqual([{ function: 'count', alias: 'count' }]); + } + }); +}); diff --git a/ui/packages/plugin-grid/src/useGroupedData.ts b/ui/packages/plugin-grid/src/useGroupedData.ts index 5884ecd54d4..a8ee9d1ab3f 100644 --- a/ui/packages/plugin-grid/src/useGroupedData.ts +++ b/ui/packages/plugin-grid/src/useGroupedData.ts @@ -8,7 +8,8 @@ import { useState, useMemo, useCallback } from 'react'; import type { GroupingConfig } from '@object-ui/types'; -import { deriveColumnSummary, type ListViewGroupHeaderRow } from '@objectstack/spec/ui'; +import { compareSortValues, getSortValue, type SortValue } from '@object-ui/core'; +import { deriveColumnSummary, listViewGroupOrderColumn, type ListViewGroupHeaderRow } from '@objectstack/spec/ui'; /** Supported aggregation function types. */ export type AggregationType = 'sum' | 'count' | 'avg' | 'min' | 'max' | 'count_distinct'; @@ -258,6 +259,55 @@ interface SortableGroup { raw: unknown; /** The header label, for the label-order arms. */ label: string; + /** + * The value the group is placed by when its level names an `orderBy` + * (objectstack#25786) — `null` when the group has none. Absent when the + * level orders by its own key. + */ + orderValue?: SortValue; +} + +/** + * The field a level's groups are ordered by when that is not the grouping + * field itself (spec `GroupingFieldSchema.orderBy`, objectstack#25786), or + * `undefined` when the level orders by its own key — `orderBy` absent, or + * naming the grouping field, the same two cases the spec's + * `listViewGroupOrderColumn` answers with the grouping field. + */ +export function groupOrderByOf(f: UsableGroupingField): string | undefined { + return f.orderBy && f.orderBy !== f.field ? f.orderBy : undefined; +} + +/** + * One `orderBy` value reduced to what two groups compare by. `orderBy` names + * a numeric, temporal or boolean field (the spec refuses the string classes), + * and a numeric column can arrive as a numeric STRING — a driver's decimal — + * which a string compare would order digit by digit, so it is read as the + * number it spells. + */ +function orderValueOf(value: unknown): SortValue { + const reduced = getSortValue(value); + if (typeof reduced === 'string' && reduced.trim() !== '' && Number.isFinite(Number(reduced))) { + return Number(reduced); + } + return reduced; +} + +/** + * The client-side twin of the header query's order node: over a group's rows, + * the smallest `orderBy` value for `asc` and the largest for `desc`, so the + * group sits where its first row would if the rows were sorted by `orderBy`. + * Rows with no value there do not count; a group with none answers `null`. + */ +function groupOrderValueOfRows(rows: any[], orderBy: string, order: 'asc' | 'desc'): SortValue { + let extreme: SortValue = null; + for (const row of rows) { + const value = orderValueOf(row?.[orderBy]); + if (value === null) continue; + const cmp = extreme === null ? 0 : compareSortValues(value, extreme); + if (extreme === null || (order === 'desc' ? cmp > 0 : cmp < 0)) extreme = value; + } + return extreme; } /** @@ -282,6 +332,11 @@ function optionRankOf(raw: unknown, ranks: ReadonlyMap): number[ * the options in that order (reversed for `desc`), then any value the options * do not declare, by label, then the empty group — empty and unknown values * sit last in both directions. Without one: label order, exactly as before. + * + * A level that names an `orderBy` (objectstack#25786) orders by each group's + * `orderValue` first, ahead of both: in `order`, a group with no value last + * in both directions, and equal values fall back to the key comparison above + * in the same direction — what the spec states the consumer owes. */ function compareGroupEntries( a: SortableGroup, @@ -289,6 +344,16 @@ function compareGroupEntries( order: 'asc' | 'desc', ranks: ReadonlyMap | undefined, ): number { + if (a.orderValue !== undefined && b.orderValue !== undefined) { + const aValue = a.orderValue; + const bValue = b.orderValue; + if (aValue === null || bValue === null) { + if (aValue !== bValue) return aValue === null ? 1 : -1; + } else { + const cmp = compareSortValues(aValue, bValue); + if (cmp !== 0) return order === 'desc' ? -cmp : cmp; + } + } if (!ranks) return compareGroups(a.label, b.label, order); const aEmpty = a.segment === ''; const bEmpty = b.segment === ''; @@ -308,7 +373,7 @@ function compareGroupEntries( /** * One entry of the spec's `grouping.fields[]` as AUTHORED — `field` plus the - * optional `order` / `collapsed`. + * optional `order` / `orderBy` / `collapsed`. * * ⚠️ NOT the same type as `@object-ui/components`' `GroupingFieldEntry`, which * is the grouping EDITOR's fully-populated value shape and requires `order` @@ -526,6 +591,13 @@ export function useGroupedDataInOptionOrder( const rowsAtDepth = (server.headers[depth] ?? []).filter((row) => outer.every((pf) => sameGroupKey(row[pf.field], prefix[pf.field])), ); + // [objectstack#25786] A level ordered by another field reads each + // group's order value off the column the spec names for it — never a + // re-derived alias. Asked only over an answered header set: one was + // compiled from this same grouping, so the spec accepted its `orderBy`. + const orderColumn = groupOrderByOf(f) !== undefined && rowsAtDepth.length > 0 + ? listViewGroupOrderColumn({ fields } as Parameters[0], depth) + : undefined; const entries = rowsAtDepth.map((row) => { const raw = row[f.field] ?? null; const resolved = raw === null ? undefined : server.keyLabels?.[f.field]?.[String(raw)]; @@ -534,6 +606,7 @@ export function useGroupedDataInOptionOrder( raw, segment: extractValueKey(raw), label: resolved ?? buildSegmentLabel(raw, f.field, formatValue), + ...(orderColumn !== undefined ? { orderValue: orderValueOf(row[orderColumn]) } : {}), }; }); const order = f.order ?? 'asc'; @@ -597,11 +670,20 @@ export function useGroupedDataInOptionOrder( const order = f.order ?? 'asc'; const ranks = optionRanks?.get(f.field); - const sortableOf = (segment: string): SortableGroup => { - const entry = map.get(segment); - return { segment, raw: entry?.rows[0]?.[f.field], label: entry?.label ?? segment }; - }; - keyOrder.sort((a, b) => compareGroupEntries(sortableOf(a), sortableOf(b), order, ranks)); + // [objectstack#25786] Each group's order value, folded from its rows + // once, before the sort. + const orderBy = groupOrderByOf(f); + const sortables = new Map(); + for (const segment of keyOrder) { + const entry = map.get(segment)!; + sortables.set(segment, { + segment, + raw: entry.rows[0]?.[f.field], + label: entry.label, + ...(orderBy !== undefined ? { orderValue: groupOrderValueOfRows(entry.rows, orderBy, order) } : {}), + }); + } + keyOrder.sort((a, b) => compareGroupEntries(sortables.get(a)!, sortables.get(b)!, order, ranks)); return keyOrder.map((segment) => { const entry = map.get(segment)!; diff --git a/ui/packages/plugin-grid/src/useServerGrouping.ts b/ui/packages/plugin-grid/src/useServerGrouping.ts index 76abd549175..3e774b508eb 100644 --- a/ui/packages/plugin-grid/src/useServerGrouping.ts +++ b/ui/packages/plugin-grid/src/useServerGrouping.ts @@ -56,7 +56,10 @@ * `find` on the referenced object per grouped field (the same resolution * `ObjectChart` applies to a grouped series); a failed resolution keeps the id * rather than failing the grid. Ordering the groups is the consumer's too: - * `useGroupedData` applies `GroupingField.order` over the header set. + * `useGroupedData` applies `GroupingField.order` over the header set — by the + * group key, or, for a level naming an `orderBy` (objectstack#25786), by the + * `min` / `max` column the header query compiles for it, read under the name + * `listViewGroupOrderColumn` gives it. * * ## An answer is held against the question it answers (objectui#11574) * @@ -87,7 +90,7 @@ import { type ListViewGroupQuerySource, } from '@objectstack/spec/ui'; import { isExpandableFieldType } from '@object-ui/core'; -import { serverSummaryOf, type GroupAggregationConfig, type UsableGroupingField } from './useGroupedData'; +import { groupOrderByOf, serverSummaryOf, type GroupAggregationConfig, type UsableGroupingField } from './useGroupedData'; /** * `aggregations` as the summary columns the header query compiles from. An @@ -104,9 +107,25 @@ function summaryColumnsOf(aggregations: GroupAggregationConfig[] | undefined): N return out as NonNullable; } -/** The grouping block the two compilers read, keyed on field names alone. */ -function groupingOf(fields: readonly UsableGroupingField[]): ListViewGroupQuerySource['grouping'] { - return { fields: fields.map((f) => ({ field: f.field })) } as ListViewGroupQuerySource['grouping']; +/** + * One grouping level as the HEADER query compiles it (objectstack#25786): the + * field, plus — when the level orders by another field — that `orderBy` and + * the `order` that picks its `min` (asc) or `max` (desc) node. A level ordered + * by its own key carries the name alone, so flipping its `order` re-asks + * nothing: the header query sorts nothing, the grid does. + */ +type HeaderLevel = { field: string; orderBy?: string; order?: 'asc' | 'desc' }; + +function headerLevelOf(f: UsableGroupingField): HeaderLevel { + const orderBy = groupOrderByOf(f); + return orderBy === undefined + ? { field: f.field } + : { field: f.field, orderBy, order: f.order === 'desc' ? 'desc' : 'asc' }; +} + +/** The grouping block the two compilers read. */ +function groupingOf(levels: readonly HeaderLevel[]): ListViewGroupQuerySource['grouping'] { + return { fields: levels.map((level) => ({ ...level })) } as ListViewGroupQuerySource['grouping']; } export interface ServerGroupHeadersInput { @@ -191,8 +210,9 @@ export function useServerGroupHeaders(input: ServerGroupHeadersInput): ServerGro const [state, setState] = useState(IDLE_HELD); // Keyed on CONTENT, never on the identity of an object a host may rebuild - // every render (AGENTS.md #10). - const fieldsKey = JSON.stringify(fields.map((f) => f.field)); + // every render (AGENTS.md #10). The levels as the header query compiles + // them: a level's `orderBy` adds a column to its answer (objectstack#25786). + const levelsKey = JSON.stringify(fields.map(headerLevelOf)); const whereKey = JSON.stringify(where ?? null); const searchKey = JSON.stringify( search @@ -213,7 +233,7 @@ export function useServerGroupHeaders(input: ServerGroupHeadersInput): ServerGro // they compile with. `reloadKey` is not in it — a refresh asks the same // question again — and neither is `referenceKey`, which labels the keys of // the same answer. - const question = JSON.stringify([objectName ?? null, fieldsKey, whereKey, searchKey, aggregationsKey]); + const question = JSON.stringify([objectName ?? null, levelsKey, whereKey, searchKey, aggregationsKey]); useEffect(() => { if (!canAsk || !dataSource || !objectName) { @@ -228,9 +248,10 @@ export function useServerGroupHeaders(input: ServerGroupHeadersInput): ServerGro setState((prev) => (prev.question === question ? { ...prev, loading: true, error: null } : { ...PENDING, question })); - const fieldNames: string[] = JSON.parse(fieldsKey); + const levels: HeaderLevel[] = JSON.parse(levelsKey); + const fieldNames = levels.map((level) => level.field); const references: Array<[string, string | null] | null> = JSON.parse(referenceKey); - const grouping = groupingOf(fieldNames.map((field) => ({ field }))); + const grouping = groupingOf(levels); const columns = JSON.parse(aggregationsKey) as NonNullable; const composedWhere = JSON.parse(whereKey) as FilterCondition | null; const searched = JSON.parse(searchKey) as { search: string; searchFields?: string[] } | null; @@ -289,7 +310,7 @@ export function useServerGroupHeaders(input: ServerGroupHeadersInput): ServerGro })(); return () => { cancelled = true; }; - }, [canAsk, dataSource, objectName, question, fieldsKey, whereKey, searchKey, aggregationsKey, referenceKey, reloadKey]); + }, [canAsk, dataSource, objectName, question, levelsKey, whereKey, searchKey, aggregationsKey, referenceKey, reloadKey]); // Read in the render the question changes, not one render late: the // state still holds the previous question's answer until the effect From 02167adbf7bd3068d3bb5d05e0a0c1c2ad170af5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 06:46:48 +0000 Subject: [PATCH 2/4] chore(changeset): plugin-grid groups ordered by orderBy Claude-Session: https://claude.ai/code/session_01Baqv5mDLFH63hvh9G4YGn5 Co-authored-by: Claude --- .changeset/25858-grid-group-order-by.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/25858-grid-group-order-by.md diff --git a/.changeset/25858-grid-group-order-by.md b/.changeset/25858-grid-group-order-by.md new file mode 100644 index 00000000000..728ec2ab07e --- /dev/null +++ b/.changeset/25858-grid-group-order-by.md @@ -0,0 +1,14 @@ +--- +'@object-ui/plugin-grid': patch +--- + +fix(plugin-grid): a grouped grid orders a level's groups by its `grouping.fields[].orderBy`, the label still from `field` + +Clause-②: no +Metadata-protocol: none + +A view grouped by `period_label` with `orderBy: 'period_start', order: 'desc'` now lists one header per label in descending `period_start` order, where it used to list the labels as text (`Nov 2026` before `Oct 2026` before `Q1 2027`). + +- **Server-grouped grid.** The grid forwards the level's `orderBy` into the group header query, so the query carries the `min` (asc) / `max` (desc) column the spec compiles for it, and sorts the header rows by the column `listViewGroupOrderColumn` names. An `orderBy` field the user may not read is not forwarded: that level orders by its own key. +- **Rows handed in whole.** Each group's order value is the `min` (asc) or `max` (desc) of `orderBy` over its rows, so a group sits where its first row would if the rows were sorted by `orderBy`. +- **Both paths.** A group with no `orderBy` value sorts last in both directions; equal values fall back to the existing key comparison (declared option order, then label) in the same direction. A level without `orderBy`, or with `orderBy` naming the grouping field itself, orders exactly as before. From b3b58b6979e2611fd4c0267f9cc1d8f5c7db61a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 06:55:54 +0000 Subject: [PATCH 3/4] test(plugin-grid): numeric-string order values discriminate number from text order Claude-Session: https://claude.ai/code/session_01Baqv5mDLFH63hvh9G4YGn5 Co-authored-by: Claude --- .../plugin-grid/src/__tests__/groupOrderBy-25858.test.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ui/packages/plugin-grid/src/__tests__/groupOrderBy-25858.test.tsx b/ui/packages/plugin-grid/src/__tests__/groupOrderBy-25858.test.tsx index 8c9daeafd4a..a510a956534 100644 --- a/ui/packages/plugin-grid/src/__tests__/groupOrderBy-25858.test.tsx +++ b/ui/packages/plugin-grid/src/__tests__/groupOrderBy-25858.test.tsx @@ -238,12 +238,13 @@ describe('client-grouped: each group\'s order value is folded from its rows (obj }); it('a numeric order value arriving as a numeric string orders as the number', () => { + // As text the values read '12' < '3.10' < '3.9', and the labels read a, b, c. const rows = [ - { id: 1, bucket: 'a', amount: '3.10' }, + { id: 1, bucket: 'c', amount: '3.10' }, { id: 2, bucket: 'b', amount: '3.9' }, - { id: 3, bucket: 'c', amount: '12' }, + { id: 3, bucket: 'a', amount: '12' }, ]; - expect(groupLabelsOf({ fields: [{ field: 'bucket', orderBy: 'amount' }] }, rows)).toEqual(['a', 'b', 'c']); + expect(groupLabelsOf({ fields: [{ field: 'bucket', orderBy: 'amount' }] }, rows)).toEqual(['c', 'b', 'a']); }); it('CONTROL — no `orderBy`: label order, exactly as before', () => { From 19f3f573d2036c5c9c0353317a46095a1f5ab25f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 07:27:05 +0000 Subject: [PATCH 4/4] chore(sdui): re-record the registry source hash after the plugin-grid edit sdui.manifest.json is byte-identical; only the recorded source hash moves. Claude-Session: https://claude.ai/code/session_01Baqv5mDLFH63hvh9G4YGn5 Co-authored-by: Claude --- scripts/sdui-manifest.record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 1555759c7e3..5d728022f56 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "ad9e0875b83f339e03c528fc3be774592615dfdc5ee5fe9becc4376ddb5f0d52" + "registrySourcesSha256": "7734fe6bc3763fe8318db624b9e0dd4c48b2dc7b15bf2a3fb1f78e46bb8d959e" } }