Skip to content

Commit 8529444

Browse files
authored
feat(report): carry a report's order into the dataset selection (framework#3916) (#2964)
Spec 17 gave reports an ordering declaration — `ReportSchema.order` and `blocks[].order`, a list of `{ by, direction }` keys, most significant first — and the framework executor applies it. This renderer built the selection it posts and never carried the declaration into it, so an authored `order` reached no query and did nothing. `useDatasetRows` — the single fetch choke point behind every report path — now takes the lowered ordering, and all four call sites supply it: the grouped table, the embedded chart, the matrix cross-tab, and each joined block. `readOrder()` lowers the authored list, the array's element order becoming the object's key insertion order — how sort significance is expressed on the wire. It is permissive about its input like the neighbouring `readNames()`, and an absent or unusable list yields undefined so the field is OMITTED and the server's defaults still apply. `scopeOrder()` narrows the ordering to what each sub-selection projects: the chart queries one dimension × one measure and the flat-table path drops the across-dimensions, and the server rejects an order key naming nothing the selection projects, so forwarding the full list would turn a valid report into a failed chart. The ordering also joins the refetch signature — it changes the rows the server returns, not just their presentation. For a matrix it decides the across axis too, since colHeaders are collected in row-arrival order.
1 parent 38ca8be commit 8529444

3 files changed

Lines changed: 372 additions & 8 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@object-ui/plugin-report": minor
3+
---
4+
5+
feat(report): carry a report's `order` into the dataset selection (framework#3916)
6+
7+
`@objectstack/spec` 17 gave reports an ordering declaration — `ReportSchema.order`
8+
(and `blocks[].order` for a joined report): a list of `{ by, direction }` keys,
9+
most significant first. The framework executor applies it. `DatasetReportRenderer`
10+
built the selection it posts and never carried the declaration into it, so an
11+
authored `order` reached no query and did nothing.
12+
13+
`useDatasetRows` — the single fetch choke point behind every report path — now
14+
takes the lowered ordering, and all four call sites supply it: the grouped table,
15+
the embedded chart, the matrix cross-tab, and each joined block.
16+
17+
- **Lowering.** `readOrder()` turns the authored list into
18+
`DatasetSelection.order`, the array's element order becoming the object's key
19+
insertion order (which is how sort significance is expressed on the wire). It
20+
is permissive about its input, like the neighbouring `readNames()` — stored
21+
report JSON crosses the repo boundary and may lag the schema, so an entry with
22+
no usable `by` is dropped rather than thrown. An absent or entirely-unusable
23+
list yields `undefined`, so the field is OMITTED and the server's own defaults
24+
still apply: a selected time dimension comes back chronological with nothing
25+
declared.
26+
27+
Kept local rather than importing spec's `reportSelectionOrder` — the pinned
28+
`^17.0.0-rc.0` predates that export. Swap it for the import on the next bump.
29+
30+
- **Scoped per sub-selection.** A report's `order` is validated against its
31+
WHOLE selection, but this renderer issues narrower queries from it: the chart
32+
plots one dimension × one measure, and the flat-table path drops the matrix
33+
across-dimensions. The server rejects an order key naming nothing the
34+
selection projects (a deliberate 400), so forwarding the full list would turn
35+
a valid report into a failed chart. Keys outside a sub-selection are dropped
36+
at the choke point instead. Nothing is masked: the schema already validated
37+
every key against `rows``columns``values`, so the only keys that can be
38+
lost are ones the narrower query genuinely has no column for.
39+
40+
- **Part of the refetch key.** The ordering changes the ROWS the server returns,
41+
not just their presentation, so it joins the `useDatasetRows` signature — an
42+
ordering edited from asc to desc refetches instead of re-rendering the stale
43+
grid.
44+
45+
- **Matrix across-axis.** `colHeaders` are collected in row-arrival order, so
46+
ordering the rows by the across dimension is what makes the columns read
47+
left-to-right in that order. Ordering rides on the primary query only; the
48+
server drops it for the totals sub-queries by design.
49+
50+
Ordering stays server-side throughout — never a client-side re-sort, which would
51+
order the page rather than the query and could not sort by a derived measure at
52+
all.

packages/plugin-report/src/DatasetReportRenderer.tsx

Lines changed: 143 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,19 @@
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+
*/
157248
function 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

Comments
 (0)