From 1cdd361a6e805846d17b0b707b776f4791fad06c Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:05:36 +0500 Subject: [PATCH] feat(plugin-report): server-supplied totals in dataset matrix reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Framework PR objectstack-ai/framework#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: Claude Fable 5 --- .../src/DatasetReportRenderer.tsx | 109 ++++++++++++++++-- .../__tests__/DatasetReportRenderer.test.tsx | 79 ++++++++++++- 2 files changed, 176 insertions(+), 12 deletions(-) diff --git a/packages/plugin-report/src/DatasetReportRenderer.tsx b/packages/plugin-report/src/DatasetReportRenderer.tsx index ff4dcda6f..ae04c882b 100644 --- a/packages/plugin-report/src/DatasetReportRenderer.tsx +++ b/packages/plugin-report/src/DatasetReportRenderer.tsx @@ -14,10 +14,15 @@ * - `summary` / `tabular` → one grouped table (`rows` + `values`). * - `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. (No totals row/column: measures like `avg` cannot - * be re-aggregated from bucketed values without drifting from the semantic - * layer — the governance red line. A matrix without `columns` degrades to - * the flat grouped table.) + * 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. * @@ -35,8 +40,14 @@ import { mergeFilters } from './mergeFilters'; type Row = Record; +/** 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[] }>; + queryDataset?: (dataset: string, selection: unknown) => Promise<{ rows: Row[]; totals?: DatasetTotals[] }>; } /** A report (or joined block) bound to a dataset. Field access is permissive — */ @@ -118,14 +129,21 @@ function useDatasetRows( measures: string[], runtimeFilter: Record | undefined, dataSource: unknown, + totalsGroupings?: string[][], ) { - const [state, setState] = React.useState<{ status: 'idle' | 'loading' | 'ok' | 'error'; rows: Row[]; error?: string }>({ + const [state, setState] = React.useState<{ + status: 'idle' | 'loading' | 'ok' | 'error'; + rows: Row[]; + totals?: DatasetTotals[]; + error?: string; + }>({ status: 'idle', rows: [], }); const rfKey = JSON.stringify(runtimeFilter ?? null); - const signature = `${dataset}|${dimensions.join(',')}|${measures.join(',')}|${rfKey}`; + const totalsKey = JSON.stringify(totalsGroupings ?? null); + const signature = `${dataset}|${dimensions.join(',')}|${measures.join(',')}|${rfKey}|${totalsKey}`; React.useEffect(() => { const src = dataSource as DatasetCapableSource | undefined; if (!src || typeof src.queryDataset !== 'function') { @@ -143,9 +161,16 @@ function useDatasetRows( dimensions, measures, ...(runtimeFilter && Object.keys(runtimeFilter).length > 0 ? { runtimeFilter } : {}), + ...(totalsGroupings ? { totals: { groupings: totalsGroupings } } : {}), }) .then((res) => { - if (!cancelled) setState({ status: 'ok', rows: Array.isArray(res?.rows) ? res.rows : [] }); + if (!cancelled) { + setState({ + status: 'ok', + rows: Array.isArray(res?.rows) ? res.rows : [], + totals: Array.isArray(res?.totals) ? res.totals : undefined, + }); + } }) .catch((e) => { if (!cancelled) setState({ status: 'error', rows: [], error: String((e as Error)?.message ?? e) }); @@ -271,7 +296,9 @@ function bucketLabel(dims: string[], row: Row): string { * 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). + * 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, @@ -290,7 +317,12 @@ function DatasetMatrixTable({ dataSource?: unknown; onDrill?: (args: DatasetDrillArgs) => void; }) { - const state = useDatasetRows(dataset, [...rows, ...columnsAcross], values, runtimeFilter, dataSource); + // Row subtotals, column subtotals, and the grand total ([]), in that order. + const state = useDatasetRows(dataset, [...rows, ...columnsAcross], values, runtimeFilter, dataSource, [ + rows, + columnsAcross, + [], + ]); const pivot = React.useMemo(() => { if (state.status !== 'ok') return null; @@ -338,6 +370,19 @@ function DatasetMatrixTable({ })), ); + // 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(); + for (const r of findTotals(rows) ?? []) rowTotalById.set(bucketId(rows, r), r); + const colTotalById = new Map(); + 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 (
@@ -353,6 +398,16 @@ function DatasetMatrixTable({ {cc.header} ))} + {showTotalCol && + values.map((measure) => ( + + ))} @@ -378,8 +433,42 @@ function DatasetMatrixTable({ ); })} + {showTotalCol && + values.map((measure) => ( + + ))} ))} + {showTotalRow && ( + + {rows.length > 0 && ( + + )} + {cellCols.map((cc) => ( + + ))} + {showTotalCol && + values.map((measure) => ( + + ))} + + )}
+ {values.length === 1 ? 'Total' : `Total · ${measure}`} +
+ {formatCell(rowTotalById.get(rh.id)?.[measure])} +
+ Total + + {formatCell(colTotalById.get(cc.col.id)?.[cc.measure])} + + {formatCell(grandTotal?.[measure])} +
diff --git a/packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsx b/packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsx index c67770eeb..e7668b285 100644 --- a/packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsx +++ b/packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsx @@ -8,6 +8,9 @@ * - report-level runtimeFilter merged into the dataset query * - missing queryDataset → a clear error instead of a blank * - matrix → true rows × columns cross-tab (ADR-0021 D2) + * - matrix totals: requests `totals.groupings` [rows, columns, []] and + * renders the SERVER-supplied subtotals/grand total; no totals in the + * response (older server) → no totals UI (never re-aggregated client-side) * - drill-down: clickable rows/cells emit {dataset, groupKey, runtimeFilter} */ import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -15,13 +18,18 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import { DatasetReportRenderer, isDatasetReport } from '../DatasetReportRenderer'; -function makeSource(rowsByDataset: Record>>) { +type MockRows = Array>; +type MockResult = { rows: MockRows; totals?: Array<{ dimensions: string[]; rows: MockRows }> }; + +function makeSource(byDataset: Record) { const calls: Array<{ dataset: string; selection: any }> = []; return { calls, queryDataset: vi.fn(async (dataset: string, selection: unknown) => { calls.push({ dataset, selection: selection as any }); - return { rows: rowsByDataset[dataset] ?? [] }; + const entry = byDataset[dataset]; + if (Array.isArray(entry)) return { rows: entry }; + return { rows: entry?.rows ?? [], ...(entry?.totals ? { totals: entry.totals } : {}) }; }), }; } @@ -125,6 +133,73 @@ describe('DatasetReportRenderer', () => { expect(screen.getByText('—')).toBeInTheDocument(); // Done × Low has no bucket }); + it('matrix requests server-side totals groupings: [rows, columns, []]', async () => { + const src = makeSource({ task_metrics: [{ status: 'Backlog', priority: 'High', est_hours: 10 }] }); + render( + , + ); + await waitFor(() => expect(src.queryDataset).toHaveBeenCalled()); + expect(src.calls[0].selection.totals).toEqual({ groupings: [['status'], ['priority'], []] }); + }); + + it('matrix renders server-supplied totals: row subtotal column, totals row, grand total', async () => { + const src = makeSource({ + task_metrics: { + rows: [ + { status: 'Backlog', priority: 'High', est_hours: 10 }, + { status: 'Backlog', priority: 'Low', est_hours: 20 }, + { status: 'Done', priority: 'High', est_hours: 14 }, + ], + totals: [ + { dimensions: ['status'], rows: [{ status: 'Backlog', est_hours: 30 }, { status: 'Done', est_hours: 14 }] }, + { dimensions: ['priority'], rows: [{ priority: 'High', est_hours: 24 }, { priority: 'Low', est_hours: 20 }] }, + { dimensions: [], rows: [{ est_hours: 44 }] }, + ], + }, + }); + render( + , + ); + await waitFor(() => expect(screen.getByTestId('dataset-matrix')).toBeInTheDocument()); + // Trailing "Total" column header (single measure → plain label). + expect(screen.getByTestId('matrix-total-col-header')).toHaveTextContent('Total'); + // Per-row subtotals, matched to row headers by bucketId. + expect(screen.getAllByTestId('matrix-row-total').map((el) => el.textContent)).toEqual(['30', '14']); + // Totals row: per-column subtotals in cellCols order (High, Low). + const totalRow = screen.getByTestId('matrix-total-row'); + expect(totalRow).toHaveTextContent('Total'); + expect(totalRow).toHaveTextContent('24'); + expect(totalRow).toHaveTextContent('20'); + // Grand total ([] grouping) sits at the totals row × Total column corner. + expect(screen.getByTestId('matrix-grand-total')).toHaveTextContent('44'); + }); + + it('matrix degrades gracefully when the server returns no totals (older server)', async () => { + const src = makeSource({ + task_metrics: [ + { status: 'Backlog', priority: 'High', est_hours: 10 }, + { status: 'Done', priority: 'High', est_hours: 14 }, + ], + }); + render( + , + ); + await waitFor(() => expect(screen.getByTestId('dataset-matrix')).toBeInTheDocument()); + expect(screen.queryByTestId('matrix-total-col-header')).not.toBeInTheDocument(); + expect(screen.queryByTestId('matrix-row-total')).not.toBeInTheDocument(); + expect(screen.queryByTestId('matrix-total-row')).not.toBeInTheDocument(); + expect(screen.queryByTestId('matrix-grand-total')).not.toBeInTheDocument(); + }); + it('matrix without `columns` degrades to the flat grouped table', async () => { const src = makeSource({ task_metrics: [{ status: 'Backlog', priority: 'High', est_hours: 10 }] }); render(