3232 * the field's declared `currency` (`Intl` symbol) + numeral `format`, never
3333 * the raw field name or a misleading "$".
3434 *
35+ * Ordering (framework#3916): a report's `order` — a list of `{ by, direction }`
36+ * keys, most significant first — is lowered onto the dataset selection, so the
37+ * SERVER orders the whole grid (after measure-scoped filters merge and derived
38+ * measures evaluate) and this renderer only places the rows it is handed. It is
39+ * never a client-side re-sort: sorting here would order the truncated page
40+ * rather than the query, and could not sort by a derived measure at all.
41+ *
42+ * For a matrix that also decides the ACROSS axis: `colHeaders` are collected in
43+ * row-arrival order, so ordering the rows by the across dimension is what makes
44+ * the columns read left-to-right in that order. Declaring nothing still reads
45+ * correctly — the server defaults a selected time dimension to ascending, which
46+ * is what makes month/quarter columns chronological out of the box.
47+ *
3548 * Drill-down (ADR-0021 D2): when the report's `drilldown` flag is not `false`
3649 * and the host supplies `onDrill`, every aggregated row / matrix cell is
3750 * clickable and emits `{ dataset, groupKey, runtimeFilter }`. The HOST owns
@@ -109,6 +122,13 @@ interface DatasetReportLike {
109122 values ?: string [ ] ;
110123 runtimeFilter ?: Record < string , unknown > ;
111124 filter ?: Record < string , unknown > ;
125+ /**
126+ * Result ordering, most significant key first (framework#3916). Each `by`
127+ * names a dimension the report groups by (`rows` / `columns`) or a measure it
128+ * displays (`values`). A `joined` report carries this per BLOCK, never on the
129+ * container.
130+ */
131+ order ?: Array < { by ?: unknown ; direction ?: unknown } > ;
112132 /** Click-through to underlying records (default true). */
113133 drilldown ?: boolean ;
114134 /** Embedded chart visualization (ADR-0021): type + xAxis/yAxis over the dataset. */
@@ -153,14 +173,86 @@ function readNames(value: unknown): string[] {
153173 return Array . isArray ( value ) ? ( value as unknown [ ] ) . filter ( ( v ) : v is string => typeof v === 'string' && ! ! v ) : [ ] ;
154174}
155175
156- /** Shared fetch for one dataset selection. */
176+ /** A lowered ordering, keyed in significance order (first key = primary sort). */
177+ type SelectionOrder = Record < string , 'asc' | 'desc' > ;
178+
179+ /**
180+ * Lower a report's authored `order` list into the `DatasetSelection.order` the
181+ * dataset query takes (framework#3916).
182+ *
183+ * The array's element order becomes the object's key insertion order, which is
184+ * how `DatasetSelection.order` expresses sort significance. Returns `undefined`
185+ * for an absent / empty / entirely-unusable list, so the caller OMITS the field
186+ * and the server's own defaults still apply — a selected time dimension comes
187+ * back chronological without the author declaring anything.
188+ *
189+ * Deliberately permissive about the input, like `readNames` above: this reads
190+ * stored report JSON, which crosses the repo boundary and may predate (or lag)
191+ * the schema. An entry with no usable `by` is dropped rather than throwing —
192+ * the authoring-time schema is where a malformed `order` is meant to be caught.
193+ *
194+ * Mirrors `reportSelectionOrder` in `@objectstack/spec/ui`; kept local because
195+ * the pinned spec (`^17.0.0-rc.0`) predates that export. Swap this for the
196+ * import once the dependency bump lands.
197+ */
198+ function readOrder ( value : unknown ) : SelectionOrder | undefined {
199+ if ( ! Array . isArray ( value ) ) return undefined ;
200+ const out : SelectionOrder = { } ;
201+ for ( const entry of value as Array < { by ?: unknown ; direction ?: unknown } > ) {
202+ const by = entry && typeof entry === 'object' ? entry . by : undefined ;
203+ if ( typeof by !== 'string' || ! by ) continue ;
204+ out [ by ] = entry . direction === 'desc' ? 'desc' : 'asc' ;
205+ }
206+ return Object . keys ( out ) . length > 0 ? out : undefined ;
207+ }
208+
209+ /**
210+ * Narrow an ordering to the keys a given selection actually projects.
211+ *
212+ * The server REJECTS an order key that names nothing the selection selected
213+ * (a deliberate 400 — a mistyped sort key that silently returns arbitrary rows
214+ * is worse than a loud failure). But one report's `order` is validated against
215+ * its WHOLE selection, while this renderer issues narrower sub-selections from
216+ * it: the embedded chart queries only `chart.xAxis` × `chart.yAxis`, and the
217+ * flat table path drops the matrix across-dimensions. Forwarding the full list
218+ * to those would turn a perfectly valid report into a failed query.
219+ *
220+ * So keys outside a sub-selection are dropped HERE rather than sent and
221+ * rejected. This cannot mask an authoring mistake: the report's own schema
222+ * already validated every key against `rows` ∪ `columns` ∪ `values`, so the
223+ * only keys that can be lost are ones the narrower query genuinely has no
224+ * column for.
225+ */
226+ function scopeOrder (
227+ order : SelectionOrder | undefined ,
228+ dimensions : string [ ] ,
229+ measures : string [ ] ,
230+ ) : SelectionOrder | undefined {
231+ if ( ! order ) return undefined ;
232+ const selectable = new Set < string > ( [ ...dimensions , ...measures ] ) ;
233+ const out : SelectionOrder = { } ;
234+ for ( const [ key , direction ] of Object . entries ( order ) ) {
235+ if ( selectable . has ( key ) ) out [ key ] = direction ;
236+ }
237+ return Object . keys ( out ) . length > 0 ? out : undefined ;
238+ }
239+
240+ /**
241+ * Shared fetch for one dataset selection.
242+ *
243+ * `order` (framework#3916) is scoped to what THIS selection projects before it
244+ * is sent — see {@link scopeOrder}. Every report path funnels through here, so
245+ * scoping once is what keeps the chart's narrow x/y sub-selection from posting
246+ * an order key it never selected.
247+ */
157248function useDatasetRows (
158249 dataset : string ,
159250 dimensions : string [ ] ,
160251 measures : string [ ] ,
161252 runtimeFilter : Record < string , unknown > | undefined ,
162253 dataSource : unknown ,
163254 totalsGroupings ?: string [ ] [ ] ,
255+ order ?: SelectionOrder ,
164256) {
165257 const [ state , setState ] = React . useState < {
166258 status : 'idle' | 'loading' | 'ok' | 'error' ;
@@ -177,9 +269,16 @@ function useDatasetRows(
177269 rows : [ ] ,
178270 } ) ;
179271
272+ const scopedOrder = scopeOrder ( order , dimensions , measures ) ;
180273 const rfKey = JSON . stringify ( runtimeFilter ?? null ) ;
181274 const totalsKey = JSON . stringify ( totalsGroupings ?? null ) ;
182- const signature = `${ dataset } |${ dimensions . join ( ',' ) } |${ measures . join ( ',' ) } |${ rfKey } |${ totalsKey } ` ;
275+ // The ordering is part of the signature: it changes the ROWS the server
276+ // returns (and, for a matrix, the arrival order the pivot builds its column
277+ // headers from), so a report edited from asc to desc must refetch, not
278+ // re-render the previous grid. `JSON.stringify` preserves key order, which is
279+ // exactly the sort significance that must invalidate the cache.
280+ const orderKey = JSON . stringify ( scopedOrder ?? null ) ;
281+ const signature = `${ dataset } |${ dimensions . join ( ',' ) } |${ measures . join ( ',' ) } |${ rfKey } |${ totalsKey } |${ orderKey } ` ;
183282 React . useEffect ( ( ) => {
184283 const src = dataSource as DatasetCapableSource | undefined ;
185284 if ( ! src || typeof src . queryDataset !== 'function' ) {
@@ -198,6 +297,7 @@ function useDatasetRows(
198297 measures,
199298 ...( runtimeFilter && Object . keys ( runtimeFilter ) . length > 0 ? { runtimeFilter } : { } ) ,
200299 ...( totalsGroupings ? { totals : { groupings : totalsGroupings } } : { } ) ,
300+ ...( scopedOrder ? { order : scopedOrder } : { } ) ,
201301 } )
202302 . then ( ( res ) => {
203303 if ( ! cancelled ) {
@@ -267,15 +367,17 @@ function DatasetReportTable({
267367 runtimeFilter,
268368 dataSource,
269369 onDrill,
370+ order,
270371} : {
271372 dataset : string ;
272373 rows : string [ ] ;
273374 values : string [ ] ;
274375 runtimeFilter ?: Record < string , unknown > ;
275376 dataSource ?: unknown ;
276377 onDrill ?: ( args : DatasetDrillArgs ) => void ;
378+ order ?: SelectionOrder ;
277379} ) {
278- const state = useDatasetRows ( dataset , rows , values , runtimeFilter , dataSource ) ;
380+ const state = useDatasetRows ( dataset , rows , values , runtimeFilter , dataSource , undefined , order ) ;
279381 const { fieldLabel } = useSafeFieldLabel ( ) ;
280382
281383 if ( values . length === 0 ) return < EmptyMeasures dataset = { dataset } /> ;
@@ -420,20 +522,29 @@ function DatasetReportChart({
420522 chart,
421523 runtimeFilter,
422524 dataSource,
525+ order,
423526} : {
424527 dataset : string ;
425528 chart : Record < string , unknown > ;
426529 runtimeFilter ?: Record < string , unknown > ;
427530 dataSource ?: unknown ;
531+ order ?: SelectionOrder ;
428532} ) {
429533 const xAxis = typeof chart . xAxis === 'string' ? chart . xAxis : '' ;
430534 const yAxis = typeof chart . yAxis === 'string' ? chart . yAxis : '' ;
535+ // The chart plots a NARROWER selection than the table beneath it (one
536+ // dimension × one measure), so `useDatasetRows` scopes the report's order to
537+ // those two columns — a "biggest first" on the plotted measure still sorts
538+ // the bars; a key naming some other row dimension is simply not applicable
539+ // here and is dropped rather than posted and rejected.
431540 const state = useDatasetRows (
432541 dataset ,
433542 xAxis ? [ xAxis ] : [ ] ,
434543 yAxis ? [ yAxis ] : [ ] ,
435544 runtimeFilter ,
436545 dataSource ,
546+ undefined ,
547+ order ,
437548 ) ;
438549 const ChartComponent = useRegistryComponent ( 'chart' ) ;
439550
@@ -499,6 +610,7 @@ function DatasetMatrixTable({
499610 runtimeFilter,
500611 dataSource,
501612 onDrill,
613+ order,
502614} : {
503615 dataset : string ;
504616 rows : string [ ] ;
@@ -507,13 +619,25 @@ function DatasetMatrixTable({
507619 runtimeFilter ?: Record < string , unknown > ;
508620 dataSource ?: unknown ;
509621 onDrill ?: ( args : DatasetDrillArgs ) => void ;
622+ order ?: SelectionOrder ;
510623} ) {
511624 // Row subtotals, column subtotals, and the grand total ([]), in that order.
512- const state = useDatasetRows ( dataset , [ ...rows , ...columnsAcross ] , values , runtimeFilter , dataSource , [
513- rows ,
514- columnsAcross ,
515- [ ] ,
516- ] ) ;
625+ //
626+ // #3916: the ordering rides on the PRIMARY query only — the server drops it
627+ // for the totals sub-queries by design (a total covers the whole selection,
628+ // and an order key may name a dimension the totals grouping doesn't have).
629+ // The across-axis header sequence follows from it: `pivot` below collects
630+ // `colHeaders` in row-ARRIVAL order, so ordering the rows by the across
631+ // dimension is what makes the columns read left-to-right in that order.
632+ const state = useDatasetRows (
633+ dataset ,
634+ [ ...rows , ...columnsAcross ] ,
635+ values ,
636+ runtimeFilter ,
637+ dataSource ,
638+ [ rows , columnsAcross , [ ] ] ,
639+ order ,
640+ ) ;
517641 const tt = useSafeTranslate ( ) ;
518642 const { fieldLabel } = useSafeFieldLabel ( ) ;
519643
@@ -708,6 +832,11 @@ export const DatasetReportRenderer: React.FC<DatasetReportRendererProps> = ({
708832 { report . blocks . map ( ( block , index ) => {
709833 const blockFilter = mergeFilters ( outerFilter , ( block . runtimeFilter ?? block . filter ) as Record < string , unknown > | undefined ) ;
710834 const blockAcross = readNames ( block . columns ) ;
835+ // #3916 — each block orders ITSELF. A joined container selects nothing
836+ // of its own (the schema rejects `order` on it), and every block is an
837+ // independent query over its own dataset, so there is no report-level
838+ // ordering to inherit here.
839+ const blockOrder = readOrder ( block . order ) ;
711840 const blockTable = block . type === 'matrix' && blockAcross . length > 0 ? (
712841 < DatasetMatrixTable
713842 dataset = { String ( block . dataset ?? '' ) }
@@ -717,6 +846,7 @@ export const DatasetReportRenderer: React.FC<DatasetReportRendererProps> = ({
717846 runtimeFilter = { blockFilter }
718847 dataSource = { dataSource }
719848 onDrill = { drillSink }
849+ order = { blockOrder }
720850 />
721851 ) : (
722852 < DatasetReportTable
@@ -726,6 +856,7 @@ export const DatasetReportRenderer: React.FC<DatasetReportRendererProps> = ({
726856 runtimeFilter = { blockFilter }
727857 dataSource = { dataSource }
728858 onDrill = { drillSink }
859+ order = { blockOrder }
729860 />
730861 ) ;
731862 return (
@@ -750,6 +881,7 @@ export const DatasetReportRenderer: React.FC<DatasetReportRendererProps> = ({
750881 }
751882
752883 const across = readNames ( report . columns ) ;
884+ const reportOrder = readOrder ( report . order ) ;
753885 // Matrix with an across dimension → true cross-tab; without one it
754886 // degrades to the flat grouped table (pre-`columns` stored JSON).
755887 if ( report . type === 'matrix' && across . length > 0 ) {
@@ -763,6 +895,7 @@ export const DatasetReportRenderer: React.FC<DatasetReportRendererProps> = ({
763895 runtimeFilter = { outerFilter }
764896 dataSource = { dataSource }
765897 onDrill = { drillSink }
898+ order = { reportOrder }
766899 />
767900 </ div >
768901 ) ;
@@ -788,6 +921,7 @@ export const DatasetReportRenderer: React.FC<DatasetReportRendererProps> = ({
788921 chart = { chartCfg }
789922 runtimeFilter = { outerFilter }
790923 dataSource = { dataSource }
924+ order = { reportOrder }
791925 />
792926 ) : null }
793927 < DatasetReportTable
@@ -797,6 +931,7 @@ export const DatasetReportRenderer: React.FC<DatasetReportRendererProps> = ({
797931 runtimeFilter = { outerFilter }
798932 dataSource = { dataSource }
799933 onDrill = { drillSink }
934+ order = { reportOrder }
800935 />
801936 </ div >
802937 ) ;
0 commit comments