Skip to content

Commit 5b038ac

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(plugin-report): server-supplied totals in dataset matrix reports (#1669)
Framework PR objectstack-ai/objectstack#1779 (issue #1753) added server-side totals to queryDataset. The matrix renderer now requests totals: { groupings: [rows, columns, []] } and renders the returned pre-aggregated rows: a trailing Total column per measure (row subtotals), a trailing Total row (column subtotals), and the grand total at their intersection — matched to pivot headers via the same bucketId logic. No totals in the response (older server) renders exactly as before; the client never re-aggregates (ADR-0021). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent e95cc25 commit 5b038ac

2 files changed

Lines changed: 176 additions & 12 deletions

File tree

packages/plugin-report/src/DatasetReportRenderer.tsx

Lines changed: 99 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,15 @@
1414
* - `summary` / `tabular` → one grouped table (`rows` + `values`).
1515
* - `matrix` → a true cross-tab (ADR-0021 D2): `rows` down × `columns`
1616
* across, measures in the cells. One dataset query over all dimensions,
17-
* pivoted client-side. (No totals row/column: measures like `avg` cannot
18-
* be re-aggregated from bucketed values without drifting from the semantic
19-
* layer — the governance red line. A matrix without `columns` degrades to
20-
* the flat grouped table.)
17+
* pivoted client-side. Totals (per-row subtotals, per-column subtotals,
18+
* grand total) are SERVER-supplied: the selection asks for
19+
* `totals: { groupings: [rows, columns, []] }` and the renderer only
20+
* places the returned pre-aggregated rows. It never re-aggregates bucketed
21+
* values client-side — measures like `avg` cannot be recombined without
22+
* drifting from the semantic layer (the ADR-0021 governance red line) —
23+
* so an older server that returns no `totals` renders the plain cross-tab
24+
* with no totals row/column. A matrix without `columns` degrades to the
25+
* flat grouped table.
2126
* - `joined` → a vertical stack of blocks, each its own dataset-bound table,
2227
* with the report-level `runtimeFilter` merged into every block.
2328
*
@@ -35,8 +40,14 @@ import { mergeFilters } from './mergeFilters';
3540

3641
type Row = Record<string, unknown>;
3742

43+
/** One server-computed totals grouping: `dimensions: []` is the grand total. */
44+
interface DatasetTotals {
45+
dimensions: string[];
46+
rows: Row[];
47+
}
48+
3849
interface DatasetCapableSource {
39-
queryDataset?: (dataset: string, selection: unknown) => Promise<{ rows: Row[] }>;
50+
queryDataset?: (dataset: string, selection: unknown) => Promise<{ rows: Row[]; totals?: DatasetTotals[] }>;
4051
}
4152

4253
/** A report (or joined block) bound to a dataset. Field access is permissive — */
@@ -118,14 +129,21 @@ function useDatasetRows(
118129
measures: string[],
119130
runtimeFilter: Record<string, unknown> | undefined,
120131
dataSource: unknown,
132+
totalsGroupings?: string[][],
121133
) {
122-
const [state, setState] = React.useState<{ status: 'idle' | 'loading' | 'ok' | 'error'; rows: Row[]; error?: string }>({
134+
const [state, setState] = React.useState<{
135+
status: 'idle' | 'loading' | 'ok' | 'error';
136+
rows: Row[];
137+
totals?: DatasetTotals[];
138+
error?: string;
139+
}>({
123140
status: 'idle',
124141
rows: [],
125142
});
126143

127144
const rfKey = JSON.stringify(runtimeFilter ?? null);
128-
const signature = `${dataset}|${dimensions.join(',')}|${measures.join(',')}|${rfKey}`;
145+
const totalsKey = JSON.stringify(totalsGroupings ?? null);
146+
const signature = `${dataset}|${dimensions.join(',')}|${measures.join(',')}|${rfKey}|${totalsKey}`;
129147
React.useEffect(() => {
130148
const src = dataSource as DatasetCapableSource | undefined;
131149
if (!src || typeof src.queryDataset !== 'function') {
@@ -143,9 +161,16 @@ function useDatasetRows(
143161
dimensions,
144162
measures,
145163
...(runtimeFilter && Object.keys(runtimeFilter).length > 0 ? { runtimeFilter } : {}),
164+
...(totalsGroupings ? { totals: { groupings: totalsGroupings } } : {}),
146165
})
147166
.then((res) => {
148-
if (!cancelled) setState({ status: 'ok', rows: Array.isArray(res?.rows) ? res.rows : [] });
167+
if (!cancelled) {
168+
setState({
169+
status: 'ok',
170+
rows: Array.isArray(res?.rows) ? res.rows : [],
171+
totals: Array.isArray(res?.totals) ? res.totals : undefined,
172+
});
173+
}
149174
})
150175
.catch((e) => {
151176
if (!cancelled) setState({ status: 'error', rows: [], error: String((e as Error)?.message ?? e) });
@@ -271,7 +296,9 @@ function bucketLabel(dims: string[], row: Row): string {
271296
* True cross-tab for `type: 'matrix'` — one dataset query over
272297
* `[...rows, ...columns]`, pivoted client-side. Cells show every measure
273298
* (single measure → one column per across-bucket; multiple → one column per
274-
* across-bucket × measure).
299+
* across-bucket × measure). Totals come from the SAME query via
300+
* `totals: { groupings: [rows, columns, []] }` — pre-aggregated server-side,
301+
* never recombined here; a response without `totals` renders no totals UI.
275302
*/
276303
function DatasetMatrixTable({
277304
dataset,
@@ -290,7 +317,12 @@ function DatasetMatrixTable({
290317
dataSource?: unknown;
291318
onDrill?: (args: DatasetDrillArgs) => void;
292319
}) {
293-
const state = useDatasetRows(dataset, [...rows, ...columnsAcross], values, runtimeFilter, dataSource);
320+
// Row subtotals, column subtotals, and the grand total ([]), in that order.
321+
const state = useDatasetRows(dataset, [...rows, ...columnsAcross], values, runtimeFilter, dataSource, [
322+
rows,
323+
columnsAcross,
324+
[],
325+
]);
294326

295327
const pivot = React.useMemo(() => {
296328
if (state.status !== 'ok') return null;
@@ -338,6 +370,19 @@ function DatasetMatrixTable({
338370
})),
339371
);
340372

373+
// Server-supplied totals: match each grouping by its `dimensions` array,
374+
// then match its rows to the pivot headers via the same bucketId. Absent
375+
// (older server) → every map stays empty and no totals UI renders.
376+
const findTotals = (dims: string[]) =>
377+
state.totals?.find((t) => Array.isArray(t.dimensions) && t.dimensions.join(',') === dims.join(','))?.rows;
378+
const rowTotalById = new Map<string, Row>();
379+
for (const r of findTotals(rows) ?? []) rowTotalById.set(bucketId(rows, r), r);
380+
const colTotalById = new Map<string, Row>();
381+
for (const r of findTotals(columnsAcross) ?? []) colTotalById.set(bucketId(columnsAcross, r), r);
382+
const grandTotal = findTotals([])?.[0];
383+
const showTotalCol = rowTotalById.size > 0;
384+
const showTotalRow = colTotalById.size > 0;
385+
341386
return (
342387
<div className="overflow-auto max-h-[70vh] rounded-md border" data-testid="dataset-matrix">
343388
<table className="w-full text-xs">
@@ -353,6 +398,16 @@ function DatasetMatrixTable({
353398
{cc.header}
354399
</th>
355400
))}
401+
{showTotalCol &&
402+
values.map((measure) => (
403+
<th
404+
key={`total-${measure}`}
405+
className="px-2 py-1.5 text-right font-medium whitespace-nowrap"
406+
data-testid="matrix-total-col-header"
407+
>
408+
{values.length === 1 ? 'Total' : `Total · ${measure}`}
409+
</th>
410+
))}
356411
</tr>
357412
</thead>
358413
<tbody>
@@ -378,8 +433,42 @@ function DatasetMatrixTable({
378433
</td>
379434
);
380435
})}
436+
{showTotalCol &&
437+
values.map((measure) => (
438+
<td
439+
key={`total-${measure}`}
440+
className="px-2 py-1 text-right tabular-nums whitespace-nowrap font-medium"
441+
data-testid="matrix-row-total"
442+
>
443+
{formatCell(rowTotalById.get(rh.id)?.[measure])}
444+
</td>
445+
))}
381446
</tr>
382447
))}
448+
{showTotalRow && (
449+
<tr className="border-t bg-muted/30 font-medium" data-testid="matrix-total-row">
450+
{rows.length > 0 && (
451+
<td colSpan={rows.length} className="px-2 py-1 whitespace-nowrap">
452+
Total
453+
</td>
454+
)}
455+
{cellCols.map((cc) => (
456+
<td key={`${cc.col.id}-${cc.measure}`} className="px-2 py-1 text-right tabular-nums whitespace-nowrap">
457+
{formatCell(colTotalById.get(cc.col.id)?.[cc.measure])}
458+
</td>
459+
))}
460+
{showTotalCol &&
461+
values.map((measure) => (
462+
<td
463+
key={`grand-${measure}`}
464+
className="px-2 py-1 text-right tabular-nums whitespace-nowrap"
465+
data-testid="matrix-grand-total"
466+
>
467+
{formatCell(grandTotal?.[measure])}
468+
</td>
469+
))}
470+
</tr>
471+
)}
383472
</tbody>
384473
</table>
385474
</div>

packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsx

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,28 @@
88
* - report-level runtimeFilter merged into the dataset query
99
* - missing queryDataset → a clear error instead of a blank
1010
* - matrix → true rows × columns cross-tab (ADR-0021 D2)
11+
* - matrix totals: requests `totals.groupings` [rows, columns, []] and
12+
* renders the SERVER-supplied subtotals/grand total; no totals in the
13+
* response (older server) → no totals UI (never re-aggregated client-side)
1114
* - drill-down: clickable rows/cells emit {dataset, groupKey, runtimeFilter}
1215
*/
1316
import { describe, it, expect, vi, beforeEach } from 'vitest';
1417
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
1518
import '@testing-library/jest-dom';
1619
import { DatasetReportRenderer, isDatasetReport } from '../DatasetReportRenderer';
1720

18-
function makeSource(rowsByDataset: Record<string, Array<Record<string, unknown>>>) {
21+
type MockRows = Array<Record<string, unknown>>;
22+
type MockResult = { rows: MockRows; totals?: Array<{ dimensions: string[]; rows: MockRows }> };
23+
24+
function makeSource(byDataset: Record<string, MockRows | MockResult>) {
1925
const calls: Array<{ dataset: string; selection: any }> = [];
2026
return {
2127
calls,
2228
queryDataset: vi.fn(async (dataset: string, selection: unknown) => {
2329
calls.push({ dataset, selection: selection as any });
24-
return { rows: rowsByDataset[dataset] ?? [] };
30+
const entry = byDataset[dataset];
31+
if (Array.isArray(entry)) return { rows: entry };
32+
return { rows: entry?.rows ?? [], ...(entry?.totals ? { totals: entry.totals } : {}) };
2533
}),
2634
};
2735
}
@@ -125,6 +133,73 @@ describe('DatasetReportRenderer', () => {
125133
expect(screen.getByText('—')).toBeInTheDocument(); // Done × Low has no bucket
126134
});
127135

136+
it('matrix requests server-side totals groupings: [rows, columns, []]', async () => {
137+
const src = makeSource({ task_metrics: [{ status: 'Backlog', priority: 'High', est_hours: 10 }] });
138+
render(
139+
<DatasetReportRenderer
140+
report={{ name: 'm', type: 'matrix', dataset: 'task_metrics', rows: ['status'], columns: ['priority'], values: ['est_hours'] }}
141+
dataSource={src}
142+
/>,
143+
);
144+
await waitFor(() => expect(src.queryDataset).toHaveBeenCalled());
145+
expect(src.calls[0].selection.totals).toEqual({ groupings: [['status'], ['priority'], []] });
146+
});
147+
148+
it('matrix renders server-supplied totals: row subtotal column, totals row, grand total', async () => {
149+
const src = makeSource({
150+
task_metrics: {
151+
rows: [
152+
{ status: 'Backlog', priority: 'High', est_hours: 10 },
153+
{ status: 'Backlog', priority: 'Low', est_hours: 20 },
154+
{ status: 'Done', priority: 'High', est_hours: 14 },
155+
],
156+
totals: [
157+
{ dimensions: ['status'], rows: [{ status: 'Backlog', est_hours: 30 }, { status: 'Done', est_hours: 14 }] },
158+
{ dimensions: ['priority'], rows: [{ priority: 'High', est_hours: 24 }, { priority: 'Low', est_hours: 20 }] },
159+
{ dimensions: [], rows: [{ est_hours: 44 }] },
160+
],
161+
},
162+
});
163+
render(
164+
<DatasetReportRenderer
165+
report={{ name: 'm', type: 'matrix', dataset: 'task_metrics', rows: ['status'], columns: ['priority'], values: ['est_hours'] }}
166+
dataSource={src}
167+
/>,
168+
);
169+
await waitFor(() => expect(screen.getByTestId('dataset-matrix')).toBeInTheDocument());
170+
// Trailing "Total" column header (single measure → plain label).
171+
expect(screen.getByTestId('matrix-total-col-header')).toHaveTextContent('Total');
172+
// Per-row subtotals, matched to row headers by bucketId.
173+
expect(screen.getAllByTestId('matrix-row-total').map((el) => el.textContent)).toEqual(['30', '14']);
174+
// Totals row: per-column subtotals in cellCols order (High, Low).
175+
const totalRow = screen.getByTestId('matrix-total-row');
176+
expect(totalRow).toHaveTextContent('Total');
177+
expect(totalRow).toHaveTextContent('24');
178+
expect(totalRow).toHaveTextContent('20');
179+
// Grand total ([] grouping) sits at the totals row × Total column corner.
180+
expect(screen.getByTestId('matrix-grand-total')).toHaveTextContent('44');
181+
});
182+
183+
it('matrix degrades gracefully when the server returns no totals (older server)', async () => {
184+
const src = makeSource({
185+
task_metrics: [
186+
{ status: 'Backlog', priority: 'High', est_hours: 10 },
187+
{ status: 'Done', priority: 'High', est_hours: 14 },
188+
],
189+
});
190+
render(
191+
<DatasetReportRenderer
192+
report={{ name: 'm', type: 'matrix', dataset: 'task_metrics', rows: ['status'], columns: ['priority'], values: ['est_hours'] }}
193+
dataSource={src}
194+
/>,
195+
);
196+
await waitFor(() => expect(screen.getByTestId('dataset-matrix')).toBeInTheDocument());
197+
expect(screen.queryByTestId('matrix-total-col-header')).not.toBeInTheDocument();
198+
expect(screen.queryByTestId('matrix-row-total')).not.toBeInTheDocument();
199+
expect(screen.queryByTestId('matrix-total-row')).not.toBeInTheDocument();
200+
expect(screen.queryByTestId('matrix-grand-total')).not.toBeInTheDocument();
201+
});
202+
128203
it('matrix without `columns` degrades to the flat grouped table', async () => {
129204
const src = makeSource({ task_metrics: [{ status: 'Backlog', priority: 'High', est_hours: 10 }] });
130205
render(

0 commit comments

Comments
 (0)