diff --git a/.changeset/27975-summary-report-subtotals.md b/.changeset/27975-summary-report-subtotals.md new file mode 100644 index 00000000000..7adf6903974 --- /dev/null +++ b/.changeset/27975-summary-report-subtotals.md @@ -0,0 +1,21 @@ +--- +'@object-ui/plugin-report': patch +'@object-ui/i18n': patch +--- + +feat(plugin-report): a summary report with two or more `rows` levels draws a subtotal per outer group, and no longer repeats the outer value on every row + +Clause-②: no +Metadata-protocol: none + +**Visible change on existing reports.** Every dataset-bound `summary` report whose `rows` names two or more dimensions now renders grouped on its next render, with nothing for the author to change: + +- Each outer group draws a header row naming its value once, then its inner rows with the outer column left blank, then a `Subtotal` row. With three or more levels the groups nest, one header and one subtotal per level above the innermost. +- The subtotals are the server's. The report asks the dataset for every proper prefix of `rows` through `totals.groupings` (`[[owner], []]` for `rows: [owner, stage]`), the way a `matrix` already asks for its row and column totals. They are never summed in the browser, so an `avg` or a distinct count subtotals correctly. +- Groups keep the order their first row arrives in: the declared option order of a select dimension, or the report's own `order`. Rows of one group that an `order` by a measure brings back apart are drawn together under it. +- Clicking a subtotal row drills into the whole group, scoped by the outer dimensions only. Clicking an inner row drills by every dimension, as before. +- The export (CSV and XLSX) writes the grouped table as drawn. + +Unchanged: a one-level `summary` (grand-total footer only), a `tabular` report, and a `matrix`, including a matrix without `columns`. A server that does not answer the prefix groupings gets the plain table, with the outer value repeated and no subtotal rows. + +`@object-ui/i18n` adds `report.subtotal` to every pack. diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index a0e4bed19ba..90b61a81126 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -3569,6 +3569,7 @@ const ar = { report: { total: "الإجمالي", totals: "الإجماليات", + subtotal: "المجموع الفرعي", loading: "جارٍ التحميل…", forbiddenMessage: "ليست لديك صلاحية لعرض البيانات الخاصة بهذا التقرير.", dateRange: { diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 246d6a95e43..a560a5dd614 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -3304,6 +3304,7 @@ const de = { report: { total: "Gesamt", totals: "Summen", + subtotal: "Zwischensumme", loading: "Wird geladen…", forbiddenMessage: "Sie haben keine Berechtigung, die Daten dieses Berichts anzuzeigen.", dateRange: { diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 0ced9c95101..555074ab9a0 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -1781,6 +1781,7 @@ const en = { report: { total: 'Total', totals: 'Totals', + subtotal: 'Subtotal', loading: 'Loading…', // objectui#12330 — the report's no-access state; its title is `dashboard.widgetForbiddenTitle`. forbiddenMessage: 'You don’t have permission to view the data behind this report.', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 2807a984a8b..ed62f2ee3e5 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -3371,6 +3371,7 @@ const es = { report: { total: "Total", totals: "Totales", + subtotal: "Subtotal", loading: "Cargando…", forbiddenMessage: "No tienes permiso para ver los datos de este informe.", dateRange: { diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 8bec754d9e7..c6f36c77bf7 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -3367,6 +3367,7 @@ const fr = { report: { total: "Total", totals: "Totaux", + subtotal: "Sous-total", loading: "Chargement…", forbiddenMessage: "Vous n’avez pas l’autorisation de voir les données de ce rapport.", dateRange: { diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 72a3cf3b765..d137ac0bb2d 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -3306,6 +3306,7 @@ const ja = { report: { total: "合計", totals: "合計", + subtotal: "小計", loading: "読み込み中…", forbiddenMessage: "このレポートのデータを表示する権限がありません。", dateRange: { diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 07470827138..329ba7c05dd 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -3303,6 +3303,7 @@ const ko = { report: { total: "합계", totals: "합계", + subtotal: "소계", loading: "로딩 중…", forbiddenMessage: "이 보고서의 데이터를 볼 수 있는 권한이 없습니다.", dateRange: { diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index dde4cbf8d7d..c0ba16591c1 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -3366,6 +3366,7 @@ const pt = { report: { total: "Total", totals: "Totais", + subtotal: "Subtotal", loading: "Carregando…", forbiddenMessage: "Você não tem permissão para ver os dados deste relatório.", dateRange: { diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 75355b27376..e5c1cd208c9 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -3447,6 +3447,7 @@ const ru = { report: { total: "Итого", totals: "Итоги", + subtotal: "Промежуточный итог", loading: "Загрузка…", forbiddenMessage: "У вас нет прав на просмотр данных этого отчёта.", dateRange: { diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index a110de05872..6b69fae1219 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -1479,6 +1479,7 @@ const zh = { report: { total: '总计', totals: '汇总', + subtotal: '小计', loading: '加载中…', forbiddenMessage: '你没有查看该报表数据的权限。', dateRange: { diff --git a/ui/packages/plugin-report/src/DatasetReportRenderer.tsx b/ui/packages/plugin-report/src/DatasetReportRenderer.tsx index 8f61bf2da49..dc35dc4548d 100644 --- a/ui/packages/plugin-report/src/DatasetReportRenderer.tsx +++ b/ui/packages/plugin-report/src/DatasetReportRenderer.tsx @@ -14,7 +14,11 @@ * - `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. + * (#2941), the data shape never does. With two or more `rows` levels it + * also asks for every proper prefix of `rows` (`[[a], []]` for `[a, b]`) + * and draws each outer group as a header row, its inner rows (the outer + * value not repeated) and a SERVER-computed subtotal row + * (objectstack-ai/objectstack#27975). * - `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, @@ -510,6 +514,87 @@ function arrangeByDeclaredOptions( return arrange(items.map((_, i) => i), 0); } +/** + * The totals groupings a summary table asks for besides the grand total: every + * proper, non-empty prefix of its `rows`, outermost first — `[[a]]` for + * `[a, b]`, `[[a], [a, b]]` for `[a, b, c]` (objectstack-ai/objectstack#27975). + * Empty for one level, whose only total is the grand total. + */ +function summarySubtotalPrefixes(rows: readonly string[]): string[][] { + return rows.slice(0, -1).map((_, k) => rows.slice(0, k + 1)); +} + +/** + * One drawn line of a multi-level summary table. `index` addresses the dataset + * row (`state.rows[index]`) the line reads: a detail line's own row, or the + * FIRST row of a group, which carries the group's prefix values for the header + * label, the subtotal lookup and the drill payload. A subtotal's `members` are + * all of its group's rows. + */ +type SummaryLine = + | { kind: 'header'; level: number; index: number } + | { kind: 'detail'; index: number } + | { kind: 'subtotal'; level: number; index: number; members: readonly number[] }; + +/** + * Nest the laid-out rows into outer groups, one `rows` level at a time down to + * the innermost: each group is a header line, its nested lines, and a subtotal + * line. Groups keep the order their first row arrives in `order`, so the + * declared-option layout and an authored `order` both survive; rows of one + * group that arrive apart are drawn together under it. + */ +function nestSummaryLines(order: readonly number[], displayRows: readonly Row[], dims: readonly string[]): SummaryLine[] { + const nest = (indices: readonly number[], level: number): SummaryLine[] => { + if (level >= dims.length - 1) return indices.map((index) => ({ kind: 'detail' as const, index })); + const groups = new Map(); + for (const i of indices) { + const id = pivotBucketId([pivotDimensionValue(displayRows[i][dims[level]])]); + const group = groups.get(id); + if (group) group.push(i); + else groups.set(id, [i]); + } + return [...groups.values()].flatMap((group): SummaryLine[] => [ + { kind: 'header', level, index: group[0] }, + ...nest(group, level + 1), + { kind: 'subtotal', level, index: group[0], members: group }, + ]); + }; + return nest(order, 0); +} + +/** + * The drill ranges of a subtotal row: per range key, the span `[min gte, max lt)` + * of its group's rows, kept only when EVERY row carries that key on one field. + * A prefix dimension's range is the same on every row, so it passes through; an + * inner time bucket widens to cover the group's buckets, which stay inside a + * report period the server already narrowed them to; a key some row lacks (the + * empty bucket) is dropped, so the drill is the whole group. Bounds compare as + * text, the form the server sends them in. + */ +function spanDrillRanges( + ranges: ReadonlyArray | undefined>, +): Record | undefined { + const [first, ...rest] = ranges; + if (!first) return undefined; + const out: Record = {}; + for (const [key, range] of Object.entries(first)) { + let gte = range.gte; + let lt = range.lt; + let everyRow = true; + for (const other of rest) { + const r = other?.[key]; + if (!r || r.field !== range.field) { + everyRow = false; + break; + } + if (String(r.gte) < String(gte)) gte = r.gte; + if (String(r.lt) > String(lt)) lt = r.lt; + } + if (everyRow) out[key] = { field: range.field, gte, lt }; + } + return out; +} + /** * Shared fetch for one dataset selection. * @@ -771,6 +856,13 @@ const DRILL_CLASS = 'cursor-pointer hover:bg-accent/40'; * 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. + * + * `withSubtotals` (a `summary`, never a `tabular` or a `matrix` degraded to + * this table) also asks for every prefix grouping of a two-or-more-level + * `rows` ({@link summarySubtotalPrefixes}) and draws the grouped face + * ({@link nestSummaryLines}) — the subtotals server-aggregated by the same + * rule. When any prefix grouping is missing from the answer (an older server) + * the table is the plain one, with no subtotal rows. */ function DatasetReportTable({ dataset, @@ -781,6 +873,7 @@ function DatasetReportTable({ onDrill, order, withTotals, + withSubtotals, timeWindow, }: { dataset: string; @@ -791,8 +884,10 @@ function DatasetReportTable({ onDrill?: (args: DatasetDrillArgs) => void; order?: SelectionOrder; withTotals?: boolean; + withSubtotals?: boolean; timeWindow?: ReportTimeWindow; }) { + const subtotalPrefixes = withTotals && withSubtotals ? summarySubtotalPrefixes(rows) : []; // With no grouping dimensions the single result row already IS the grand // total — requesting totals would only duplicate it. const state = useDatasetRows( @@ -801,7 +896,7 @@ function DatasetReportTable({ values, runtimeFilter, dataSource, - withTotals && rows.length > 0 ? [[]] : undefined, + withTotals && rows.length > 0 ? [...subtotalPrefixes, []] : undefined, order, timeWindow, ); @@ -835,10 +930,15 @@ function DatasetReportTable({ // 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 drillDimsAll = state.dimensionFields ? rows.filter((d) => d in state.dimensionFields!) : []; + // `dims` is the grouping the clicked line stands for: every `rows` dimension + // for a data row, the prefix for a subtotal row, whose drill scopes by the + // prefix values alone (read off the group's first row, which carries them) + // and by the span of its `members`' ranges (`spanDrillRanges`). + const drill = (row: Row, index: number, dims: readonly string[] = rows, members?: readonly number[]) => { const groupKey: Record = {}; - for (const dim of rows) groupKey[dim] = row[dim]; + for (const dim of dims) groupKey[dim] = row[dim]; + const drillDims = drillDimsAll.filter((d) => dims.includes(d)); // 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 @@ -846,7 +946,9 @@ function DatasetReportTable({ // objectstack-ai/objectstack#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 rowRanges = members + ? spanDrillRanges(members.map((i) => state.drillRanges?.[i])) + : state.drillRanges?.[index]; const hasRange = !!rowRanges && Object.keys(rowRanges).length > 0; let objectFilter: Record | undefined; if (state.object && (drillDims.length > 0 || hasRange)) { @@ -894,18 +996,59 @@ function DatasetReportTable({ // grand total passes none and so draws no symbol on such a column. const fmt = (measure: string, value: unknown, row?: Readonly>) => formatMeasure(value, measureField(measure)?.format, measureCellCurrency(measureField(measure), row), measureField(measure)?.percentScale, displayLocale); + // objectstack-ai/objectstack#27975 — the server's subtotals, one map per + // prefix grouping, keyed by the prefix's bucket id over RELABELED values: the + // display rows take the same relabel, so both sides of the lookup speak one + // vocabulary (the matrix's rule). `null` for a grouping the answer lacks. + const subtotalMaps = subtotalPrefixes.map((prefix) => { + const found = state.totals?.find( + (t) => Array.isArray(t.dimensions) && t.dimensions.join(',') === prefix.join(','), + )?.rows; + if (!found) return null; + const byId = new Map(); + for (const r of relabelDimensions(found, dimensionLabels)) byId.set(bucketId(prefix, r), r); + return byId; + }); + // The grouped face only when EVERY prefix grouping was answered; otherwise + // (an older server) the plain table, the matrix's degrade rule. + const grouped = subtotalPrefixes.length > 0 && subtotalMaps.every((m) => m !== null); + const lines: SummaryLine[] = grouped + ? nestSummaryLines(layout, displayRows, rows) + : layout.map((index) => ({ kind: 'detail' as const, index })); + const subtotalOf = (level: number, index: number): Row | undefined => + subtotalMaps[level]?.get(bucketId(subtotalPrefixes[level], displayRows[index])); + const subtotalText = tt('report.subtotal', 'Subtotal'); + // A grouped detail row leaves its outer dimensions blank: the group header + // above it names them once. + const detailDimension = (c: string, i: number) => + grouped && rows.indexOf(c) < rows.length - 1 ? '' : formatDimensionValue(displayRows[i][c], displayLocale); // The cells below, as the export writes them (`ReportExport.tsx`). const snapshot: ReportTableSnapshot = { object: state.object, header: columns.map((c) => headerLabel(c)), rows: [ - ...layout.map((i) => - columns.map((c) => + ...lines.map((line) => { + const i = line.index; + if (line.kind === 'header') { + return columns.map((_, p) => + p === line.level + ? exportDimensionCell(formatDimensionValue(displayRows[i][rows[line.level]], displayLocale)) + : { text: '' }, + ); + } + if (line.kind === 'subtotal') { + const total = subtotalOf(line.level, i); + return [ + ...rows.map((_, p) => ({ text: p === line.level ? subtotalText : '' })), + ...values.map((m) => exportMeasureCell(fmt(m, total?.[m]), total?.[m])), + ]; + } + return columns.map((c) => values.includes(c) ? exportMeasureCell(fmt(c, displayRows[i][c], state.rows[i]), displayRows[i][c]) - : exportDimensionCell(formatDimensionValue(displayRows[i][c], displayLocale)), - ), - ), + : exportDimensionCell(detailDimension(c, i)), + ); + }), ...(grandTotal ? [[...rows.map((_, k) => ({ text: k === 0 ? totalText : '' })), ...values.map((m) => exportMeasureCell(fmt(m, grandTotal[m]), grandTotal[m]))]] : []), @@ -925,27 +1068,62 @@ function DatasetReportTable({ - {layout.map((i) => ( - drill(state.rows[i], i) : undefined} - > - {columns.map((c) => ( - - {values.includes(c) - ? fmt(c, displayRows[i][c], state.rows[i]) - : formatDimensionValue(displayRows[i][c], displayLocale)} - - ))} - - ))} + {lines.map((line) => { + const i = line.index; + if (line.kind === 'header') { + return ( + + {line.level > 0 && } + + {formatDimensionValue(displayRows[i][rows[line.level]], displayLocale)} + + + ); + } + if (line.kind === 'subtotal') { + const total = subtotalOf(line.level, i); + const prefix = subtotalPrefixes[line.level]; + return ( + drill(state.rows[i], i, prefix, line.members) : undefined} + > + {line.level > 0 && } + + {subtotalText} + + {values.map((measure) => ( + + {fmt(measure, total?.[measure])} + + ))} + + ); + } + return ( + drill(state.rows[i], i) : undefined} + > + {columns.map((c) => ( + + {values.includes(c) ? fmt(c, displayRows[i][c], state.rows[i]) : detailDimension(c, i)} + + ))} + + ); + })} {grandTotal && ( {rows.length > 0 && ( @@ -1955,6 +2133,7 @@ export const DatasetReportRenderer: React.FC = ({ onDrill={drillSink} order={blockOrder} withTotals={blockPresentation === 'summary' || blockPresentation === 'matrix'} + withSubtotals={blockPresentation === 'summary'} /> ); return ( @@ -2036,6 +2215,7 @@ export const DatasetReportRenderer: React.FC = ({ onDrill={drillSink} order={reportOrder} withTotals={presentation === 'summary' || presentation === 'matrix'} + withSubtotals={presentation === 'summary'} timeWindow={timeWindow} /> ); diff --git a/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.export-25873.test.tsx b/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.export-25873.test.tsx index 24e8dc4e126..75cc885be04 100644 --- a/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.export-25873.test.tsx +++ b/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.export-25873.test.tsx @@ -187,6 +187,35 @@ describe('report export control (exportOptions, #25873)', () => { expect(downloads[0].blob.type).toContain('text/csv'); }); + it('CSV of a two-level summary is the drawn grouped table: headers, inner rows, subtotals, total', async () => { + // objectstack-ai/objectstack#27975 — the export writes the grouped face cell for cell. + renderReport( + { ...SUMMARY, rows: ['owner', 'stage'], values: ['amount'] }, + { + result: { + rows: [ + { owner: 'Amy Chen', stage: 'Prospecting', amount: 1200 }, + { owner: 'Amy Chen', stage: 'Proposal', amount: 300 }, + { owner: 'Zoe Admin', stage: 'Proposal', amount: 50 }, + ], + object: OBJECT, + totals: [ + { dimensions: ['owner'], rows: [{ owner: 'Amy Chen', amount: 1500 }, { owner: 'Zoe Admin', amount: 50 }] }, + { dimensions: [], rows: [{ amount: 1550 }] }, + ], + }, + }, + ); + await screen.findByTestId('dataset-report-total-row'); + await openMenu(); + fireEvent.click(await screen.findByTestId('report-export-csv')); + await waitFor(() => expect(downloads).toHaveLength(1)); + + const csv = parseCsv(await blobText(downloads[0].blob)); + expect(csv).toEqual(drawnTable()); + expect(csv.map((row) => row[0])).toEqual(['owner', 'Amy Chen', '', '', 'Subtotal', 'Zoe Admin', '', 'Subtotal', 'Total']); + }); + it('absent formats offers both CSV and XLSX', async () => { renderReport(SUMMARY); await openMenu(); diff --git a/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.summarySubtotals-27975.test.tsx b/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.summarySubtotals-27975.test.tsx new file mode 100644 index 00000000000..d6d37fa028a --- /dev/null +++ b/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.summarySubtotals-27975.test.tsx @@ -0,0 +1,374 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * A `summary` report with two or more `rows` levels draws per-group subtotals + * (objectstack-ai/objectstack#27975, triage: the default, no new key). + * + * It asks the server for every proper prefix of `rows` through + * `totals.groupings` and draws each outer group as a header row, its inner rows + * with the outer value left blank, and the SERVER's subtotal row; the subtotal + * is placed, never summed here. A one-level summary, a `tabular` and a matrix + * degraded to this table keep their face, and an answer without the prefix + * groupings (an older server) draws the plain table. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { DatasetReportRenderer } from '../DatasetReportRenderer'; + +type Rows = Array>; +type Result = { + rows: Rows; + object?: string; + dimensionFields?: Record; + drillRawRows?: Rows; + drillRanges?: Array>; + totals?: Array<{ dimensions: string[]; rows: Rows }>; +}; + +function makeSource(result: Result) { + const selections: Array> = []; + return { + selections, + queryDataset: vi.fn(async (_dataset: string, selection: Record) => { + selections.push(selection); + return result; + }), + }; +} + +beforeEach(() => { + // The dimension-label probe reads object metadata over `fetch`; it declares + // no option fields, so the rows are drawn as the server sent them. + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ item: { fields: {} } }) }))); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +/** The drawn body, one array of cell texts per row, a spanned cell padded with blanks. */ +function bodyCells(): string[][] { + const table = screen.getByRole('table'); + return Array.from(table.querySelectorAll('tbody tr')).map((tr) => + Array.from(tr.querySelectorAll('td')).flatMap((c) => { + const span = Number((c as HTMLTableCellElement).colSpan || 1); + return [c.textContent ?? '', ...Array.from({ length: span - 1 }, () => '')]; + }), + ); +} + +// The server's GROUP BY order, inner rows of one owner together. +const TWO_LEVEL: Result = { + rows: [ + { owner: 'Amy Chen', stage: 'Negotiation', amount: 500 }, + { owner: 'Amy Chen', stage: 'Prospecting', amount: 600 }, + { owner: 'Zoe Admin', stage: 'Proposal', amount: 200 }, + ], + totals: [ + { dimensions: ['owner'], rows: [{ owner: 'Amy Chen', amount: 1100 }, { owner: 'Zoe Admin', amount: 200 }] }, + { dimensions: [], rows: [{ amount: 1300 }] }, + ], +}; + +const SUMMARY = { + name: 'opportunity_funnel_owner_stage', + type: 'summary', + dataset: 'opportunity_metrics', + rows: ['owner', 'stage'], + values: ['amount'], +}; + +describe('summary report subtotals (objectstack-ai/objectstack#27975)', () => { + it('a two-level summary asks for the outer prefix grouping and the grand total', async () => { + const src = makeSource(TWO_LEVEL); + render(); + await screen.findByRole('table'); + expect(src.selections[0].totals).toEqual({ groupings: [['owner'], []] }); + }); + + it('a three-level summary asks for every proper prefix, outermost first, then the grand total', async () => { + const src = makeSource({ rows: [{ a: 'x', b: 'y', c: 'z', n: 1 }] }); + render( + , + ); + await screen.findByRole('table'); + expect(src.selections[0].totals).toEqual({ groupings: [['a'], ['a', 'b'], []] }); + }); + + it('draws a header and a server subtotal per outer group, never repeating the outer value', async () => { + render(); + await screen.findByTestId('dataset-report-total-row'); + expect(bodyCells()).toEqual([ + ['Amy Chen', '', ''], + ['', 'Negotiation', '500'], + ['', 'Prospecting', '600'], + ['Subtotal', '', '1100'], + ['Zoe Admin', '', ''], + ['', 'Proposal', '200'], + ['Subtotal', '', '200'], + ['Total', '', '1300'], + ]); + expect(screen.getAllByTestId('dataset-report-group-header')).toHaveLength(2); + expect(screen.getAllByTestId('dataset-report-subtotal-row')).toHaveLength(2); + }); + + it('places the server subtotal even where the inner rows would sum to something else', async () => { + // An `avg` cannot be recombined from its buckets: the drawn subtotal is the + // server's number, not the sum of the rows above it. + const result: Result = { + rows: [ + { owner: 'Amy Chen', stage: 'Negotiation', avg_amount: 10 }, + { owner: 'Amy Chen', stage: 'Prospecting', avg_amount: 30 }, + ], + totals: [ + { dimensions: ['owner'], rows: [{ owner: 'Amy Chen', avg_amount: 25 }] }, + { dimensions: [], rows: [{ avg_amount: 25 }] }, + ], + }; + render(); + await screen.findByTestId('dataset-report-subtotal-row'); + expect(bodyCells()[3]).toEqual(['Subtotal', '', '25']); + }); + + it('nests three levels: an inner header and subtotal inside each outer group', async () => { + const result: Result = { + rows: [ + { region: 'East', owner: 'Amy', stage: 'Won', n: 2 }, + { region: 'East', owner: 'Amy', stage: 'Lost', n: 1 }, + { region: 'East', owner: 'Bob', stage: 'Won', n: 4 }, + ], + totals: [ + { dimensions: ['region'], rows: [{ region: 'East', n: 7 }] }, + { dimensions: ['region', 'owner'], rows: [{ region: 'East', owner: 'Amy', n: 3 }, { region: 'East', owner: 'Bob', n: 4 }] }, + { dimensions: [], rows: [{ n: 7 }] }, + ], + }; + render( + , + ); + await screen.findByTestId('dataset-report-total-row'); + expect(bodyCells()).toEqual([ + ['East', '', '', ''], + ['', 'Amy', '', ''], + ['', '', 'Won', '2'], + ['', '', 'Lost', '1'], + ['', 'Subtotal', '', '3'], + ['', 'Bob', '', ''], + ['', '', 'Won', '4'], + ['', 'Subtotal', '', '4'], + ['Subtotal', '', '', '7'], + ['Total', '', '', '7'], + ]); + }); + + it('draws one group per outer value even when an authored order interleaves them', async () => { + // `order` by the measure: the server answers by amount, so one owner's rows + // arrive apart. Groups keep the order of their first row. + const result: Result = { + rows: [ + { owner: 'Zoe Admin', stage: 'Proposal', amount: 900 }, + { owner: 'Amy Chen', stage: 'Prospecting', amount: 600 }, + { owner: 'Zoe Admin', stage: 'Qualification', amount: 100 }, + ], + totals: [ + { dimensions: ['owner'], rows: [{ owner: 'Amy Chen', amount: 600 }, { owner: 'Zoe Admin', amount: 1000 }] }, + { dimensions: [], rows: [{ amount: 1600 }] }, + ], + }; + render( + , + ); + await screen.findByTestId('dataset-report-total-row'); + expect(bodyCells()).toEqual([ + ['Zoe Admin', '', ''], + ['', 'Proposal', '900'], + ['', 'Qualification', '100'], + ['Subtotal', '', '1000'], + ['Amy Chen', '', ''], + ['', 'Prospecting', '600'], + ['Subtotal', '', '600'], + ['Total', '', '1600'], + ]); + }); + + it('an answer without the prefix grouping (older server) draws the plain table, outer value repeated', async () => { + const result: Result = { ...TWO_LEVEL, totals: [{ dimensions: [], rows: [{ amount: 1300 }] }] }; + render(); + await screen.findByTestId('dataset-report-total-row'); + expect(bodyCells()).toEqual([ + ['Amy Chen', 'Negotiation', '500'], + ['Amy Chen', 'Prospecting', '600'], + ['Zoe Admin', 'Proposal', '200'], + ['Total', '', '1300'], + ]); + expect(screen.queryByTestId('dataset-report-subtotal-row')).not.toBeInTheDocument(); + }); + + it('an answer with no totals at all draws the plain table and no total row', async () => { + render(); + await screen.findByRole('table'); + expect(bodyCells()).toEqual([ + ['Amy Chen', 'Negotiation', '500'], + ['Amy Chen', 'Prospecting', '600'], + ['Zoe Admin', 'Proposal', '200'], + ]); + }); + + it('a one-level summary keeps the grand-total face: no prefix grouping, no subtotal rows', async () => { + const src = makeSource({ + rows: [{ owner: 'Amy Chen', amount: 1100 }], + totals: [{ dimensions: [], rows: [{ amount: 1100 }] }], + }); + render(); + await screen.findByTestId('dataset-report-total-row'); + expect(src.selections[0].totals).toEqual({ groupings: [[]] }); + expect(bodyCells()).toEqual([['Amy Chen', '1100'], ['Total', '1100']]); + }); + + it('a tabular report with two levels asks for no totals and repeats the outer value as before', async () => { + const src = makeSource(TWO_LEVEL); + render(); + await screen.findByRole('table'); + expect(src.selections[0].totals).toBeUndefined(); + expect(bodyCells()).toEqual([ + ['Amy Chen', 'Negotiation', '500'], + ['Amy Chen', 'Prospecting', '600'], + ['Zoe Admin', 'Proposal', '200'], + ]); + }); + + it('a matrix without columns keeps the grand total only', async () => { + const src = makeSource(TWO_LEVEL); + render(); + await screen.findByRole('table'); + expect(src.selections[0].totals).toEqual({ groupings: [[]] }); + expect(screen.queryByTestId('dataset-report-subtotal-row')).not.toBeInTheDocument(); + }); + + it('a summary block in a joined report draws its subtotals too', async () => { + const src = makeSource(TWO_LEVEL); + render( + , + ); + await screen.findByTestId('dataset-report-total-row'); + expect(src.selections[0].totals).toEqual({ groupings: [['owner'], []] }); + expect(screen.getAllByTestId('dataset-report-subtotal-row')).toHaveLength(2); + }); + + describe('drill', () => { + const DRILLABLE: Result = { + ...TWO_LEVEL, + object: 'opportunity', + dimensionFields: { owner: 'owner_id', stage: 'stage' }, + drillRawRows: [ + { owner: 'usr_amy', stage: 'negotiation' }, + { owner: 'usr_amy', stage: 'prospecting' }, + { owner: 'usr_zoe', stage: 'proposal' }, + ], + }; + + it('a subtotal row drills by the outer dimension alone', async () => { + const onDrill = vi.fn(); + render(); + fireEvent.click((await screen.findAllByTestId('dataset-report-subtotal-row'))[0]); + expect(onDrill).toHaveBeenCalledWith({ + dataset: 'opportunity_metrics', + groupKey: { owner: 'Amy Chen' }, + runtimeFilter: undefined, + object: 'opportunity', + objectFilter: { owner_id: 'usr_amy' }, + }); + }); + + it('an inner row still drills by every dimension', async () => { + const onDrill = vi.fn(); + render(); + fireEvent.click((await screen.findAllByTestId('dataset-drill-row'))[1]); + expect(onDrill).toHaveBeenCalledWith( + expect.objectContaining({ + groupKey: { owner: 'Amy Chen', stage: 'Prospecting' }, + objectFilter: { owner_id: 'usr_amy', stage: 'prospecting' }, + }), + ); + }); + + it("a subtotal over a time-bucketed inner dimension spans its group's buckets", async () => { + const result: Result = { + rows: [ + { owner: 'Amy Chen', month: '2026-01', amount: 1 }, + { owner: 'Amy Chen', month: '2026-03', amount: 2 }, + ], + object: 'opportunity', + dimensionFields: { owner: 'owner_id' }, + drillRawRows: [{ owner: 'usr_amy' }, { owner: 'usr_amy' }], + drillRanges: [ + { month: { field: 'close_date', gte: '2026-01-01', lt: '2026-02-01' } }, + { month: { field: 'close_date', gte: '2026-03-01', lt: '2026-04-01' } }, + ], + totals: [ + { dimensions: ['owner'], rows: [{ owner: 'Amy Chen', amount: 3 }] }, + { dimensions: [], rows: [{ amount: 3 }] }, + ], + }; + const onDrill = vi.fn(); + render( + , + ); + fireEvent.click(await screen.findByTestId('dataset-report-subtotal-row')); + expect(onDrill).toHaveBeenCalledWith( + expect.objectContaining({ + groupKey: { owner: 'Amy Chen' }, + objectFilter: { owner_id: 'usr_amy', close_date: { $gte: '2026-01-01', $lt: '2026-04-01' } }, + }), + ); + }); + + it('a subtotal whose group holds the empty time bucket drops that range: the whole group', async () => { + const result: Result = { + rows: [ + { owner: 'Amy Chen', month: '2026-01', amount: 1 }, + { owner: 'Amy Chen', month: null, amount: 2 }, + ], + object: 'opportunity', + dimensionFields: { owner: 'owner_id' }, + drillRawRows: [{ owner: 'usr_amy' }, { owner: 'usr_amy' }], + drillRanges: [{ month: { field: 'close_date', gte: '2026-01-01', lt: '2026-02-01' } }, {}], + totals: [ + { dimensions: ['owner'], rows: [{ owner: 'Amy Chen', amount: 3 }] }, + { dimensions: [], rows: [{ amount: 3 }] }, + ], + }; + const onDrill = vi.fn(); + render( + , + ); + fireEvent.click(await screen.findByTestId('dataset-report-subtotal-row')); + expect(onDrill).toHaveBeenCalledWith(expect.objectContaining({ objectFilter: { owner_id: 'usr_amy' } })); + }); + }); +});