-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathDatasetReportRenderer.tsx
More file actions
1098 lines (1049 loc) · 45.1 KB
/
Copy pathDatasetReportRenderer.tsx
File metadata and controls
1098 lines (1049 loc) · 45.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
/**
* DatasetReportRenderer
*
* Renders a spec `Report` that binds to a semantic-layer `dataset` (ADR-0021
* single-form) instead of an inline `objectName` + `columns` query. The report
* selects dimensions (`rows`, and for matrix also `columns`) and measures
* (`values`) BY NAME and runs them through `dataSource.queryDataset` — the
* same governed path dataset-bound dashboard widgets and the dataset preview
* use, so the numbers (and the server-resolved dimension display labels)
* match everywhere.
*
* - `tabular` → the selection as a simple list (`rows` + `values`), no totals.
* - `summary` → the same table plus a SERVER-computed grand-total footer
* (`totals: { groupings: [[]] }`) — the declared type picks the branch
* (#2941), the data shape never does.
* - `matrix` → a true cross-tab (ADR-0021 D2): `rows` down × `columns`
* across, measures in the cells. One dataset query over all dimensions,
* pivoted client-side. Totals (per-row subtotals, per-column subtotals,
* grand total) are SERVER-supplied: the selection asks for
* `totals: { groupings: [rows, columns, []] }` and the renderer only
* places the returned pre-aggregated rows. It never re-aggregates bucketed
* values client-side — measures like `avg` cannot be recombined without
* drifting from the semantic layer (the ADR-0021 governance red line) —
* so an older server that returns no `totals` renders the plain cross-tab
* with no totals row/column. A matrix without `columns` degrades to the
* flat grouped table.
* - `joined` → a vertical stack of blocks, each its own dataset-bound table,
* with the report-level `runtimeFilter` merged into every block.
*
* Headers and measure cells mirror the dashboard `DatasetWidget` (PR #1825):
* column headers use the dataset's server-supplied display `label` (run
* through the i18n field-label convention), and measure values format with
* the field's declared `currency` (`Intl` symbol) + numeral `format`, never
* the raw field name or a misleading "$".
*
* Ordering (framework#3916): a report's `order` — a list of `{ by, direction }`
* keys, most significant first — is lowered onto the dataset selection, so the
* SERVER orders the whole grid (after measure-scoped filters merge and derived
* measures evaluate) and this renderer only places the rows it is handed. It is
* never a client-side re-sort: sorting here would order the truncated page
* rather than the query, and could not sort by a derived measure at all.
*
* For a matrix that also decides the ACROSS axis: `colHeaders` are collected in
* row-arrival order, so ordering the rows by the across dimension is what makes
* the columns read left-to-right in that order. Declaring nothing still reads
* correctly — the server defaults a selected time dimension to ascending, which
* is what makes month/quarter columns chronological out of the box.
*
* Drill-down (ADR-0021 D2): when the report's `drilldown` flag is not `false`
* and the host supplies `onDrill`, every aggregated row / matrix cell is
* clickable and emits `{ dataset, groupKey, runtimeFilter }`. The HOST owns
* navigation — it resolves the dataset's `object` and dimension→field mapping
* (this renderer only knows dimension NAMES) and routes to the underlying
* records.
*/
import * as React from 'react';
import { Loader2, AlertTriangle, Table2 } from 'lucide-react';
import {
ComponentRegistry,
formatMeasure,
formatDimensionValue,
buildDatasetFieldHelpers,
buildDatasetDrillFilter,
type DatasetResultField,
type DatasetDrillRange,
} from '@object-ui/core';
import { useSafeFieldLabel, useSafeTranslate } from '@object-ui/i18n';
import { mergeFilters } from './mergeFilters';
type Row = Record<string, unknown>;
/** One server-computed totals grouping: `dimensions: []` is the grand total. */
interface DatasetTotals {
dimensions: string[];
rows: Row[];
}
interface DatasetCapableSource {
queryDataset?: (
dataset: string,
selection: unknown,
) => Promise<{
rows: Row[];
fields?: DatasetResultField[];
object?: string;
dimensionFields?: Record<string, string>;
drillRawRows?: Row[];
drillRanges?: Array<Record<string, DatasetDrillRange>>;
totals?: DatasetTotals[];
}>;
}
/** What a drill click means — the host resolves names to a navigation target. */
export interface DatasetDrillArgs {
/** Dataset the clicked aggregate was computed over. */
dataset: string;
/** Dimension NAME → clicked bucket value (row dims, plus across dims for a matrix cell). */
groupKey: Record<string, unknown>;
/** The effective render-time scope filter, if any. */
runtimeFilter?: Record<string, unknown>;
/** The dataset's base object (records to drill into), when the server supplied it. */
object?: string;
/**
* Exact record-list filter (object FIELD name → RAW stored value) for the
* clicked bucket, ANDed with `runtimeFilter`. Present only when the server
* returned the dimension→field mapping + raw grouped values, so the host can
* filter precisely — including select/lookup dims a display-label `groupKey`
* would mis-filter — without re-fetching the dataset definition.
*/
objectFilter?: Record<string, unknown>;
}
/** A report (or joined block) bound to a dataset. Field access is permissive — */
/** the bundled spec types lag the runtime schema across the repo boundary. */
interface DatasetReportLike {
name?: string;
type?: string;
dataset?: string;
rows?: string[];
/** Dimension names across — matrix pivots `rows` × `columns` (ADR-0021 D2). */
columns?: string[];
values?: string[];
runtimeFilter?: Record<string, unknown>;
filter?: Record<string, unknown>;
/**
* Result ordering, most significant key first (framework#3916). Each `by`
* names a dimension the report groups by (`rows` / `columns`) or a measure it
* displays (`values`). A `joined` report carries this per BLOCK, never on the
* container.
*/
order?: Array<{ by?: unknown; direction?: unknown }>;
/** Click-through to underlying records (default true). */
drilldown?: boolean;
/** Embedded chart visualization (ADR-0021): type + xAxis/yAxis over the dataset. */
chart?: Record<string, unknown>;
label?: unknown;
description?: unknown;
blocks?: DatasetReportLike[];
}
export interface DatasetReportRendererProps {
report: DatasetReportLike;
dataSource?: unknown;
/** Filter merged into the report (and every joined block) as `runtimeFilter`. */
runtimeFilter?: Record<string, unknown>;
/**
* Drill-down sink. Rows/cells become clickable when provided (and the
* report doesn't set `drilldown: false`).
*/
onDrill?: (args: DatasetDrillArgs) => void;
className?: string;
}
/** `true` when this report should render through the dataset path. */
export function isDatasetReport(value: unknown): value is DatasetReportLike {
if (!value || typeof value !== 'object') return false;
const v = value as DatasetReportLike;
if (typeof v.dataset === 'string' && v.dataset.length > 0) return true;
// A joined report whose blocks are dataset-bound.
return v.type === 'joined' && Array.isArray(v.blocks) && v.blocks.some((b) => typeof b?.dataset === 'string');
}
function resolveText(label: unknown, fallback: string): string {
if (!label) return fallback;
if (typeof label === 'string') return label;
if (typeof label === 'object' && typeof (label as { default?: unknown }).default === 'string') {
return (label as { default: string }).default;
}
return fallback;
}
function readNames(value: unknown): string[] {
return Array.isArray(value) ? (value as unknown[]).filter((v): v is string => typeof v === 'string' && !!v) : [];
}
/** A lowered ordering, keyed in significance order (first key = primary sort). */
type SelectionOrder = Record<string, 'asc' | 'desc'>;
/**
* Lower a report's authored `order` list into the `DatasetSelection.order` the
* dataset query takes (framework#3916).
*
* The array's element order becomes the object's key insertion order, which is
* how `DatasetSelection.order` expresses sort significance. Returns `undefined`
* for an absent / empty / entirely-unusable list, so the caller OMITS the field
* and the server's own defaults still apply — a selected time dimension comes
* back chronological without the author declaring anything.
*
* Deliberately permissive about the input, like `readNames` above: this reads
* stored report JSON, which crosses the repo boundary and may predate (or lag)
* the schema. An entry with no usable `by` is dropped rather than throwing —
* the authoring-time schema is where a malformed `order` is meant to be caught.
*
* Mirrors `reportSelectionOrder` in `@objectstack/spec/ui`; kept local because
* the pinned spec (`^17.0.0-rc.0`) predates that export. Swap this for the
* import once the dependency bump lands.
*/
function readOrder(value: unknown): SelectionOrder | undefined {
if (!Array.isArray(value)) return undefined;
const out: SelectionOrder = {};
for (const entry of value as Array<{ by?: unknown; direction?: unknown }>) {
const by = entry && typeof entry === 'object' ? entry.by : undefined;
if (typeof by !== 'string' || !by) continue;
out[by] = entry.direction === 'desc' ? 'desc' : 'asc';
}
return Object.keys(out).length > 0 ? out : undefined;
}
/**
* Narrow an ordering to the keys a given selection actually projects.
*
* The server REJECTS an order key that names nothing the selection selected
* (a deliberate 400 — a mistyped sort key that silently returns arbitrary rows
* is worse than a loud failure). But one report's `order` is validated against
* its WHOLE selection, while this renderer issues narrower sub-selections from
* it: the embedded chart queries only `chart.xAxis` × `chart.yAxis`, and the
* flat table path drops the matrix across-dimensions. Forwarding the full list
* to those would turn a perfectly valid report into a failed query.
*
* So keys outside a sub-selection are dropped HERE rather than sent and
* rejected. This cannot mask an authoring mistake: the report's own schema
* already validated every key against `rows` ∪ `columns` ∪ `values`, so the
* only keys that can be lost are ones the narrower query genuinely has no
* column for.
*/
function scopeOrder(
order: SelectionOrder | undefined,
dimensions: string[],
measures: string[],
): SelectionOrder | undefined {
if (!order) return undefined;
const selectable = new Set<string>([...dimensions, ...measures]);
const out: SelectionOrder = {};
for (const [key, direction] of Object.entries(order)) {
if (selectable.has(key)) out[key] = direction;
}
return Object.keys(out).length > 0 ? out : undefined;
}
/**
* Shared fetch for one dataset selection.
*
* `order` (framework#3916) is scoped to what THIS selection projects before it
* is sent — see {@link scopeOrder}. Every report path funnels through here, so
* scoping once is what keeps the chart's narrow x/y sub-selection from posting
* an order key it never selected.
*/
function useDatasetRows(
dataset: string,
dimensions: string[],
measures: string[],
runtimeFilter: Record<string, unknown> | undefined,
dataSource: unknown,
totalsGroupings?: string[][],
order?: SelectionOrder,
) {
const [state, setState] = React.useState<{
status: 'idle' | 'loading' | 'ok' | 'error';
rows: Row[];
fields?: DatasetResultField[];
object?: string;
dimensionFields?: Record<string, string>;
drillRawRows?: Row[];
drillRanges?: Array<Record<string, DatasetDrillRange>>;
totals?: DatasetTotals[];
error?: string;
}>({
status: 'idle',
rows: [],
});
const scopedOrder = scopeOrder(order, dimensions, measures);
const rfKey = JSON.stringify(runtimeFilter ?? null);
const totalsKey = JSON.stringify(totalsGroupings ?? null);
// The ordering is part of the signature: it changes the ROWS the server
// returns (and, for a matrix, the arrival order the pivot builds its column
// headers from), so a report edited from asc to desc must refetch, not
// re-render the previous grid. `JSON.stringify` preserves key order, which is
// exactly the sort significance that must invalidate the cache.
const orderKey = JSON.stringify(scopedOrder ?? null);
const signature = `${dataset}|${dimensions.join(',')}|${measures.join(',')}|${rfKey}|${totalsKey}|${orderKey}`;
React.useEffect(() => {
const src = dataSource as DatasetCapableSource | undefined;
if (!src || typeof src.queryDataset !== 'function') {
setState({ status: 'error', rows: [], error: 'This data source does not support dataset queries.' });
return;
}
if (!dataset || measures.length === 0) {
setState({ status: 'idle', rows: [] });
return;
}
let cancelled = false;
setState({ status: 'loading', rows: [] });
src
.queryDataset(dataset, {
dimensions,
measures,
...(runtimeFilter && Object.keys(runtimeFilter).length > 0 ? { runtimeFilter } : {}),
...(totalsGroupings ? { totals: { groupings: totalsGroupings } } : {}),
...(scopedOrder ? { order: scopedOrder } : {}),
})
.then((res) => {
if (!cancelled) {
setState({
status: 'ok',
rows: Array.isArray(res?.rows) ? res.rows : [],
fields: Array.isArray(res?.fields) ? res.fields : [],
object: res?.object,
dimensionFields: res?.dimensionFields,
drillRawRows: Array.isArray(res?.drillRawRows) ? res.drillRawRows : undefined,
drillRanges: Array.isArray(res?.drillRanges) ? res.drillRanges : undefined,
totals: Array.isArray(res?.totals) ? res.totals : undefined,
});
}
})
.catch((e) => {
if (!cancelled) setState({ status: 'error', rows: [], error: String((e as Error)?.message ?? e) });
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [signature]);
return state;
}
function EmptyMeasures({ dataset }: { dataset: string }) {
return (
<div className="flex items-center justify-center rounded-md border border-dashed bg-muted/20 p-4 text-xs text-muted-foreground">
This report binds the “{dataset}” dataset — choose at least one measure (values) to render.
</div>
);
}
function FetchStates({ status, error }: { status: 'idle' | 'loading' | 'error'; error?: string }) {
if (status === 'error') {
return (
<div role="alert" className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span className="break-words">{error}</span>
</div>
);
}
return (
<div className="flex items-center gap-2 p-4 text-xs text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Running report…
</div>
);
}
function NoRows() {
return (
<div className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed bg-muted/20 p-6 text-xs text-muted-foreground">
<Table2 className="h-6 w-6" /> The dataset returned no rows for this report’s scope.
</div>
);
}
const DRILL_CLASS = 'cursor-pointer hover:bg-accent/40';
/**
* One dataset-bound table: fetch via `queryDataset`, render `rows` + `values`.
*
* `withTotals` is what tells a `summary` report ("grouped by row") apart from
* a `tabular` one ("simple list") at the same selection (#2941): a summary
* additionally asks the SAME query for its grand total
* (`totals: { groupings: [[]] }`) and renders it as a footer row. The total is
* server-aggregated — never recombined from the bucket rows here, which the
* ADR-0021 governance red line forbids (an `avg` cannot be recombined) — so an
* older server that returns no `totals` renders the plain table, exactly like
* the matrix path.
*/
function DatasetReportTable({
dataset,
rows,
values,
runtimeFilter,
dataSource,
onDrill,
order,
withTotals,
}: {
dataset: string;
rows: string[];
values: string[];
runtimeFilter?: Record<string, unknown>;
dataSource?: unknown;
onDrill?: (args: DatasetDrillArgs) => void;
order?: SelectionOrder;
withTotals?: boolean;
}) {
// With no grouping dimensions the single result row already IS the grand
// total — requesting totals would only duplicate it.
const state = useDatasetRows(
dataset,
rows,
values,
runtimeFilter,
dataSource,
withTotals && rows.length > 0 ? [[]] : undefined,
order,
);
const { fieldLabel } = useSafeFieldLabel();
const tt = useSafeTranslate();
if (values.length === 0) return <EmptyMeasures dataset={dataset} />;
if (state.status === 'loading' || state.status === 'idle') return <FetchStates status={state.status} />;
if (state.status === 'error') return <FetchStates status="error" error={state.error} />;
if (state.rows.length === 0) return <NoRows />;
// Drilling needs at least one dimension to scope by.
const canDrill = !!onDrill && rows.length > 0;
// Dims the server can map to object fields → raw-value (drill-correct) filter.
const drillDims = state.dimensionFields ? rows.filter((d) => d in state.dimensionFields!) : [];
const drill = (row: Row, index: number) => {
const groupKey: Record<string, unknown> = {};
for (const dim of rows) groupKey[dim] = row[dim];
// ADR-0021 D2: when the server returned the object + raw grouped values,
// emit an exact field→raw filter (correct for select/lookup dims, which a
// display-label groupKey would mis-filter); the host then filters with no
// extra metadata round-trip. Older server → groupKey-only fallback.
// #1752: a time-bucketed date dim contributes a RANGE (not an equality dim),
// so the fast path also fires when the server sent a range for this row —
// covering a date-ONLY report that has no equality drill dim at all.
const rowRanges = state.drillRanges?.[index];
const hasRange = !!rowRanges && Object.keys(rowRanges).length > 0;
const objectFilter =
state.object && (drillDims.length > 0 || hasRange)
? buildDatasetDrillFilter(state.drillRawRows?.[index], drillDims, state.dimensionFields ?? {}, runtimeFilter, rowRanges)
: undefined;
onDrill!({ dataset, groupKey, runtimeFilter, object: state.object, objectFilter });
};
const { measureField, headerLabel } = buildDatasetFieldHelpers(state.fields, state.object, fieldLabel);
const columns = [...rows, ...values];
// Server-supplied grand total (`dimensions: []`); absent → no totals row.
const grandTotal = withTotals
? state.totals?.find((t) => Array.isArray(t.dimensions) && t.dimensions.length === 0)?.rows?.[0]
: undefined;
const totalText = tt('report.total', 'Total');
return (
<div className="overflow-auto max-h-[70vh] rounded-md border">
<table className="w-full text-xs">
<thead className="bg-muted/40">
<tr>
{columns.map((c) => (
<th key={c} className="px-2 py-1.5 text-left font-medium whitespace-nowrap">
{headerLabel(c)}
</th>
))}
</tr>
</thead>
<tbody>
{state.rows.map((row, i) => (
<tr
key={i}
className={`border-t${canDrill ? ` ${DRILL_CLASS}` : ''}`}
data-testid={canDrill ? 'dataset-drill-row' : undefined}
onClick={canDrill ? () => drill(row, i) : undefined}
>
{columns.map((c) => (
<td key={c} className="px-2 py-1 tabular-nums whitespace-nowrap">
{values.includes(c)
? formatMeasure(row[c], measureField(c)?.format, measureField(c)?.currency)
: formatDimensionValue(row[c])}
</td>
))}
</tr>
))}
{grandTotal && (
<tr className="border-t bg-muted/30 font-medium" data-testid="dataset-report-total-row">
{rows.length > 0 && (
<td colSpan={rows.length} className="px-2 py-1 whitespace-nowrap">
{totalText}
</td>
)}
{values.map((measure) => (
<td key={measure} className="px-2 py-1 tabular-nums whitespace-nowrap">
{formatMeasure(grandTotal[measure], measureField(measure)?.format, measureField(measure)?.currency)}
</td>
))}
</tr>
)}
</tbody>
</table>
</div>
);
}
/**
* Resolve a report's declared `type` — the spec's `ReportType`
* (`ui/report.zod.ts`: `tabular | summary | matrix | joined`) — to the
* presentation it renders. The DECLARED type picks the branch; it is never
* inferred from the data shape. Before this, `tabular` and `summary` shared
* one branch and the visual difference was read off whether `rows` happened
* to be non-empty — a `tabular` report that declared `rows` silently rendered
* exactly like a `summary` (#2941). An absent/out-of-spec value falls back to
* `tabular`, the spec's own declared default.
*
* Exported for the spec-parity test, which fails the moment `ReportType` and
* this dispatch drift in either direction.
*/
export function resolveReportPresentation(type: unknown): 'tabular' | 'summary' | 'matrix' | 'joined' {
switch (type) {
case 'summary':
return 'summary';
case 'matrix':
return 'matrix';
case 'joined':
return 'joined';
case 'tabular':
default:
return 'tabular';
}
}
/** How a report renders its embedded chart for one spec `ChartTypeSchema` value. */
export type ReportChartPlan =
/** A categorical series the generic chart component draws distinctly. */
| { kind: 'series'; chartType: string }
/** Single-value family — one dimensionless dataset query, shown as a number. */
| { kind: 'single_value' }
/** Tabular family — the grouped table beneath IS the rendering; no duplicate chart. */
| { kind: 'tabular' }
/** Out-of-spec value (stored dialect / typo) — surfaced, never a silent bar. */
| { kind: 'unsupported'; type: string };
/**
* Classify a report chart type into its rendering plan — every value of the
* spec's `ChartTypeSchema` (`ui/chart.zod.ts`, 19 names), each on the branch
* its family demands. The old mapping collapsed 10 of the 19 to a BAR — a
* plausible wrong picture with no warning (#2941). Now:
*
* - the 12 series families reach the generic chart component verbatim
* (`column` is its documented vertical-bar alias; `horizontal-bar` stays
* horizontal instead of silently flipping vertical);
* - the single-value families (`gauge` / `solid-gauge` / `metric` / `kpi` /
* `bullet`) render the measure as a number — the spec's own comment calls
* them "honest single-value variants pending a real dial/target renderer";
* - `table` / `pivot` add no duplicate chart: the grouped table that always
* renders beneath the chart slot is exactly the tabular presentation;
* - anything else is out-of-spec and gets a visible notice.
*
* Exported for the spec-parity test, which fails the moment `ChartTypeSchema`
* and this classification drift in either direction.
*/
export function planReportChart(t: unknown): ReportChartPlan {
switch (t) {
case 'bar':
case 'column':
case 'horizontal-bar':
case 'line':
case 'area':
case 'pie':
case 'donut':
case 'funnel':
case 'scatter':
case 'treemap':
case 'sankey':
case 'radar':
return { kind: 'series', chartType: t };
case 'gauge':
case 'solid-gauge':
case 'metric':
case 'kpi':
case 'bullet':
return { kind: 'single_value' };
case 'table':
case 'pivot':
return { kind: 'tabular' };
default:
return { kind: 'unsupported', type: String(t) };
}
}
/**
* Resolve a registered component by type, triggering its LAZY loader and
* re-rendering once it registers. The generic `chart` component is registered
* via `registerLazy`, so a plain `ComponentRegistry.get` returns undefined
* until the plugin-charts chunk loads — this hook kicks off `loadLazy` and
* subscribes so the chart appears as soon as the chunk resolves. Kept decoupled
* (no static import of plugin-charts / @object-ui/react) so plugin-report stays
* dependency-light and its test module graph doesn't duplicate React.
*/
function useRegistryComponent(
type: string,
): React.ComponentType<{ schema: Record<string, unknown> }> | undefined {
const [, bump] = React.useReducer((x: number) => x + 1, 0);
React.useEffect(() => {
if (ComponentRegistry.get(type)) return;
const unsub =
typeof ComponentRegistry.subscribe === 'function'
? ComponentRegistry.subscribe(() => {
if (ComponentRegistry.get(type)) bump();
})
: undefined;
const pending = ComponentRegistry.loadLazy?.(type);
if (pending && typeof pending.then === 'function') {
pending.then(() => bump()).catch(() => {});
}
return unsub;
}, [type]);
return ComponentRegistry.get(type) as
| React.ComponentType<{ schema: Record<string, unknown> }>
| undefined;
}
/**
* Render a report's embedded `chart` (ADR-0021) by running its OWN dataset
* query — the `xAxis` dimension grouped, the `yAxis` measure aggregated — and
* feeding the rows to the registered generic chart component (plugin-charts).
*
* Decoupled via {@link ComponentRegistry} (same approach as the legacy
* renderer) so plugin-report keeps no hard dependency on plugin-charts: if the
* chart plugin isn't loaded, or the chart is incomplete, we render nothing and
* let the grouped table stand alone. Before this, a dataset-bound report's
* `chart` config was authorable in Studio but never rendered anywhere.
*/
function DatasetReportChart({
dataset,
chart,
runtimeFilter,
dataSource,
order,
}: {
dataset: string;
chart: Record<string, unknown>;
runtimeFilter?: Record<string, unknown>;
dataSource?: unknown;
order?: SelectionOrder;
}) {
const xAxis = typeof chart.xAxis === 'string' ? chart.xAxis : '';
const yAxis = typeof chart.yAxis === 'string' ? chart.yAxis : '';
const plan = planReportChart(chart.type);
// The chart plots a NARROWER selection than the table beneath it (one
// dimension × one measure), so `useDatasetRows` scopes the report's order to
// those two columns — a "biggest first" on the plotted measure still sorts
// the bars; a key naming some other row dimension is simply not applicable
// here and is dropped rather than posted and rejected.
//
// A single-value chart (metric / kpi / gauge…) instead queries the measure
// with NO dimension: the server hands back its one pre-aggregated grand
// total, so the number shown is governed by the semantic layer, never a
// client-side recombination (the ADR-0021 red line). Plans that render no
// chart at all select no measures, which parks the hook in `idle`.
const wantsQuery = plan.kind === 'series' || plan.kind === 'single_value';
const state = useDatasetRows(
dataset,
plan.kind === 'series' && xAxis ? [xAxis] : [],
wantsQuery && yAxis ? [yAxis] : [],
runtimeFilter,
dataSource,
undefined,
order,
);
const ChartComponent = useRegistryComponent('chart');
const { fieldLabel } = useSafeFieldLabel();
const title = typeof chart.title === 'string' ? chart.title : undefined;
// `table` / `pivot`: the grouped table rendered beneath this slot IS the
// tabular presentation — a duplicate chart would say nothing new.
if (plan.kind === 'tabular') return null;
if (plan.kind === 'unsupported') {
// Out-of-spec chart type in stored metadata: say so instead of drawing a
// silently wrong bar (#2941). The table beneath still carries the numbers.
return (
<div
className="rounded-md border border-dashed bg-muted/20 px-3 py-2 text-xs text-muted-foreground"
data-testid="dataset-report-chart-unsupported"
>
Chart type “{plan.type}” is not a spec chart type — the grouped table below carries this report’s numbers.
</div>
);
}
// A series chart needs both an x (dimension) and y (measure); a
// single-value chart needs only the measure. Without them, skip.
if (plan.kind === 'series' ? !xAxis || !yAxis : !yAxis) return null;
if (state.status === 'loading' || state.status === 'idle') {
return (
<div className="flex items-center gap-2 p-4 text-xs text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading chart…
</div>
);
}
// On error or empty, fall back silently to the table beneath.
if (state.status === 'error' || state.rows.length === 0) return null;
if (plan.kind === 'single_value') {
const { measureField, headerLabel } = buildDatasetFieldHelpers(state.fields, state.object, fieldLabel);
const mf = measureField(yAxis);
return (
<div className="rounded-md border bg-card p-3" data-testid="dataset-report-metric">
{title ? <h3 className="mb-2 text-sm font-semibold">{title}</h3> : null}
<div className="flex flex-col gap-0.5 py-2">
<span className="text-2xl font-semibold tabular-nums">
{formatMeasure(state.rows[0]?.[yAxis], mf?.format, mf?.currency)}
</span>
<span className="text-xs text-muted-foreground">{headerLabel(yAxis)}</span>
</div>
</div>
);
}
// The chart component is registered lazily; until it resolves render nothing
// (the grouped table beneath still shows the exact numbers).
if (!ChartComponent) return null;
return (
<div className="rounded-md border bg-card p-3" data-testid="dataset-report-chart">
{title ? <h3 className="mb-2 text-sm font-semibold">{title}</h3> : null}
{/* eslint-disable-next-line react-hooks/static-components -- ChartComponent is a stable registry lookup (useRegistryComponent), not a component created during render */}
<ChartComponent
schema={{
chartType: plan.chartType,
data: state.rows,
xAxisKey: xAxis,
series: [{ dataKey: yAxis }],
height: typeof chart.height === 'number' ? chart.height : 280,
// Render deterministically (no rAF entrance animation): reports are
// often viewed in a background tab or exported, where an animated
// chart freezes at frame 0 (pie/donut would show no ring).
isAnimationActive: false,
}}
/>
</div>
);
}
/** Stable bucket id for a dimension-value tuple. */
function bucketId(dims: string[], row: Row): string {
return dims.map((d) => String(row[d] ?? '∅')).join('');
}
function bucketLabel(dims: string[], row: Row): string {
return dims.map((d) => formatDimensionValue(row[d])).join(' / ');
}
/**
* True cross-tab for `type: 'matrix'` — one dataset query over
* `[...rows, ...columns]`, pivoted client-side. Cells show every measure
* (single measure → one column per across-bucket; multiple → one column per
* across-bucket × measure). Totals come from the SAME query via
* `totals: { groupings: [rows, columns, []] }` — pre-aggregated server-side,
* never recombined here; a response without `totals` renders no totals UI.
*/
function DatasetMatrixTable({
dataset,
rows,
columnsAcross,
values,
runtimeFilter,
dataSource,
onDrill,
order,
}: {
dataset: string;
rows: string[];
columnsAcross: string[];
values: string[];
runtimeFilter?: Record<string, unknown>;
dataSource?: unknown;
onDrill?: (args: DatasetDrillArgs) => void;
order?: SelectionOrder;
}) {
// Row subtotals, column subtotals, and the grand total ([]), in that order.
//
// #3916: the ordering rides on the PRIMARY query only — the server drops it
// for the totals sub-queries by design (a total covers the whole selection,
// and an order key may name a dimension the totals grouping doesn't have).
// The across-axis header sequence follows from it: `pivot` below collects
// `colHeaders` in row-ARRIVAL order, so ordering the rows by the across
// dimension is what makes the columns read left-to-right in that order.
const state = useDatasetRows(
dataset,
[...rows, ...columnsAcross],
values,
runtimeFilter,
dataSource,
[rows, columnsAcross, []],
order,
);
const tt = useSafeTranslate();
const { fieldLabel } = useSafeFieldLabel();
const pivot = React.useMemo(() => {
if (state.status !== 'ok') return null;
const rowHeaders: Array<{ id: string; label: string; key: Row }> = [];
const colHeaders: Array<{ id: string; label: string; key: Row }> = [];
const seenRow = new Set<string>();
const seenCol = new Set<string>();
const cells = new Map<string, { row: Row; index: number }>();
state.rows.forEach((r, index) => {
const rid = bucketId(rows, r);
const cid = bucketId(columnsAcross, r);
if (!seenRow.has(rid)) {
seenRow.add(rid);
const key: Row = {};
for (const d of rows) key[d] = r[d];
rowHeaders.push({ id: rid, label: bucketLabel(rows, r), key });
}
if (!seenCol.has(cid)) {
seenCol.add(cid);
const key: Row = {};
for (const d of columnsAcross) key[d] = r[d];
colHeaders.push({ id: cid, label: bucketLabel(columnsAcross, r), key });
}
cells.set(`${rid} ${cid}`, { row: r, index });
});
return { rowHeaders, colHeaders, cells };
}, [state, rows, columnsAcross]);
if (values.length === 0) return <EmptyMeasures dataset={dataset} />;
if (state.status === 'loading' || state.status === 'idle') return <FetchStates status={state.status} />;
if (state.status === 'error') return <FetchStates status="error" error={state.error} />;
if (!pivot || pivot.rowHeaders.length === 0) return <NoRows />;
const { measureField, headerLabel } = buildDatasetFieldHelpers(state.fields, state.object, fieldLabel);
const totalText = tt('report.total', 'Total');
const canDrill = !!onDrill;
// Down + across dims the server can map to object fields → raw-value filter.
const drillDims = state.dimensionFields
? [...rows, ...columnsAcross].filter((d) => d in state.dimensionFields!)
: [];
const drillCell = (rowKey: Row, colKey: Row, index: number) => {
// #1752: include the date-bucket range sidecar so an "X by time" cell scopes
// to the clicked time bucket, not every bucket in that row/column.
const cellRanges = state.drillRanges?.[index];
const hasRange = !!cellRanges && Object.keys(cellRanges).length > 0;
const objectFilter =
state.object && (drillDims.length > 0 || hasRange)
? buildDatasetDrillFilter(state.drillRawRows?.[index], drillDims, state.dimensionFields ?? {}, runtimeFilter, cellRanges)
: undefined;
onDrill!({ dataset, groupKey: { ...rowKey, ...colKey }, runtimeFilter, object: state.object, objectFilter });
};
// Single measure → one column per across-bucket; multiple → bucket × measure.
const cellCols = pivot.colHeaders.flatMap((col) =>
values.map((measure) => ({
col,
measure,
header: values.length === 1 ? col.label : `${col.label} · ${headerLabel(measure)}`,
})),
);
// Server-supplied totals: match each grouping by its `dimensions` array,
// then match its rows to the pivot headers via the same bucketId. Absent
// (older server) → every map stays empty and no totals UI renders.
const findTotals = (dims: string[]) =>
state.totals?.find((t) => Array.isArray(t.dimensions) && t.dimensions.join(',') === dims.join(','))?.rows;
const rowTotalById = new Map<string, Row>();
for (const r of findTotals(rows) ?? []) rowTotalById.set(bucketId(rows, r), r);
const colTotalById = new Map<string, Row>();
for (const r of findTotals(columnsAcross) ?? []) colTotalById.set(bucketId(columnsAcross, r), r);
const grandTotal = findTotals([])?.[0];
const showTotalCol = rowTotalById.size > 0;
const showTotalRow = colTotalById.size > 0;
return (
<div className="overflow-auto max-h-[70vh] rounded-md border" data-testid="dataset-matrix">
<table className="w-full text-xs">
<thead className="bg-muted/40">
<tr>
{rows.map((d) => (
<th key={d} className="px-2 py-1.5 text-left font-medium whitespace-nowrap">
{headerLabel(d)}
</th>
))}
{cellCols.map((cc) => (
<th key={`${cc.col.id}-${cc.measure}`} className="px-2 py-1.5 text-right font-medium whitespace-nowrap">
{cc.header}
</th>
))}
{showTotalCol &&
values.map((measure) => (
<th
key={`total-${measure}`}
className="px-2 py-1.5 text-right font-medium whitespace-nowrap"
data-testid="matrix-total-col-header"
>
{values.length === 1 ? totalText : `${totalText} · ${headerLabel(measure)}`}
</th>
))}
</tr>
</thead>
<tbody>
{pivot.rowHeaders.map((rh) => (
<tr key={rh.id} className="border-t">
{rows.map((d) => (
<td key={d} className="px-2 py-1 whitespace-nowrap font-medium">
{formatDimensionValue(rh.key[d])}
</td>
))}
{cellCols.map((cc) => {
const entry = pivot.cells.get(`${rh.id} ${cc.col.id}`);
const value = entry?.row[cc.measure];
const clickable = canDrill && entry != null;
return (
<td
key={`${cc.col.id}-${cc.measure}`}
className={`px-2 py-1 text-right tabular-nums whitespace-nowrap${clickable ? ` ${DRILL_CLASS}` : ''}`}
data-testid={clickable ? 'dataset-drill-cell' : undefined}
onClick={clickable ? () => drillCell(rh.key, cc.col.key, entry!.index) : undefined}
>
{formatMeasure(value, measureField(cc.measure)?.format, measureField(cc.measure)?.currency)}
</td>
);
})}
{showTotalCol &&
values.map((measure) => (
<td
key={`total-${measure}`}
className="px-2 py-1 text-right tabular-nums whitespace-nowrap font-medium"
data-testid="matrix-row-total"
>
{formatMeasure(rowTotalById.get(rh.id)?.[measure], measureField(measure)?.format, measureField(measure)?.currency)}
</td>
))}
</tr>
))}
{showTotalRow && (
<tr className="border-t bg-muted/30 font-medium" data-testid="matrix-total-row">
{rows.length > 0 && (
<td colSpan={rows.length} className="px-2 py-1 whitespace-nowrap">
{totalText}
</td>
)}
{cellCols.map((cc) => (
<td key={`${cc.col.id}-${cc.measure}`} className="px-2 py-1 text-right tabular-nums whitespace-nowrap">
{formatMeasure(colTotalById.get(cc.col.id)?.[cc.measure], measureField(cc.measure)?.format, measureField(cc.measure)?.currency)}
</td>
))}
{showTotalCol &&
values.map((measure) => (
<td
key={`grand-${measure}`}
className="px-2 py-1 text-right tabular-nums whitespace-nowrap"
data-testid="matrix-grand-total"
>
{formatMeasure(grandTotal?.[measure], measureField(measure)?.format, measureField(measure)?.currency)}
</td>
))}
</tr>
)}
</tbody>
</table>
</div>
);
}
export const DatasetReportRenderer: React.FC<DatasetReportRendererProps> = ({
report,
dataSource,
runtimeFilter,
onDrill,
className,
}) => {
const outerFilter = mergeFilters(
(report.runtimeFilter ?? report.filter) as Record<string, unknown> | undefined,
runtimeFilter,
);
// ADR-0021 D2: `drilldown` defaults on; the host must still supply a sink.
const drillSink = report.drilldown === false ? undefined : onDrill;
// The DECLARED type drives the branch (#2941) — never the data shape.
const presentation = resolveReportPresentation(report.type);
// Joined → a vertical stack of dataset-bound blocks.
if (presentation === 'joined' && Array.isArray(report.blocks)) {
return (
<div
className={className}
data-testid="dataset-joined-report"
data-report-name={report.name}
style={{ display: 'flex', flexDirection: 'column', gap: 24 }}
>
{report.blocks.map((block, index) => {
const blockFilter = mergeFilters(outerFilter, (block.runtimeFilter ?? block.filter) as Record<string, unknown> | undefined);
const blockAcross = readNames(block.columns);
// #3916 — each block orders ITSELF. A joined container selects nothing
// of its own (the schema rejects `order` on it), and every block is an
// independent query over its own dataset, so there is no report-level
// ordering to inherit here.
const blockOrder = readOrder(block.order);
// Same type-driven dispatch as the top level: a block's declared
// type picks its presentation (`JoinedReportBlockSchema.type`
// defaults to `tabular`). A grouped block (summary, or a matrix
// without across-dimensions) carries the totals footer.
const blockPresentation = resolveReportPresentation(block.type);
const blockTable = blockPresentation === 'matrix' && blockAcross.length > 0 ? (
<DatasetMatrixTable
dataset={String(block.dataset ?? '')}
rows={readNames(block.rows)}
columnsAcross={blockAcross}
values={readNames(block.values)}
runtimeFilter={blockFilter}