Skip to content

Commit 3ba5206

Browse files
feat(plugin-report): a summary report with two or more row levels draws per-group subtotals (#28376)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao2NFfgMD4j4LmnpdPFFKbkT)_ Fixes #27975 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:`summary` 报表的 `rows` 有两层及以上时,按外层分组画:每组一行组头(外层值只写一次)、组内明细行(外层列留空)、一行「小计」;三层时逐层嵌套。小计取服务端按前缀分组算出的值,浏览器不做加总。 - 为什么改:之前多层汇总报表只要总计,外层值在每行重复、没有分组小计(如 HotCRM「按负责人和阶段的商机漏斗」)。triage 6539817671 裁定为默认行为、不新增键。 - 风险与回滚:所有已有的两层以上 `summary` 报表下次渲染即出现小计行(已在 changeset 写明);单层 summary、`tabular`、`matrix` 不变;服务端不回前缀分组时退回原平铺表。回滚即 revert 本 PR。 - 先看:`ui/packages/plugin-report/src/DatasetReportRenderer.tsx` 的 `DatasetReportTable`(totals 请求与 `lines` 渲染)和 `nestSummaryLines` / `spanDrillRanges`;新测试 `DatasetReportRenderer.summarySubtotals-27975.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-report/src/__tests__/DatasetReportRenderer.summarySubtotals-27975.test.tsx`,预期 16 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes **Renderer (`ui/packages/plugin-report/src/DatasetReportRenderer.tsx`, `DatasetReportTable`).** - A `summary` table with two or more `rows` levels asks the dataset for every proper prefix of `rows` plus the grand total through `totals.groupings`: `[[a], []]` for `[a, b]`, `[[a], [a, b], []]` for `[a, b, c]` (`summarySubtotalPrefixes`). The server already re-runs the selection per grouping, which is what the `matrix` path relies on. - When every prefix grouping is answered, the body is drawn as nested groups (`nestSummaryLines`): per outer group a header row naming the value once, its inner lines (the outer columns left blank), and a `Subtotal` row carrying the server's pre-aggregated row for that prefix. The subtotal lookup relabels the totals rows the same way the display rows are relabeled and matches by bucket id, the matrix's rule. Nothing is summed client-side. - Groups keep the order of their first row in the laid-out rows, so the declared-option layout and an authored `order` both hold; rows of one group that an `order` by a measure returns apart are drawn together under it. - Drill: an inner row drills by every dimension, as before. A subtotal row drills by the prefix only: `groupKey` and the raw-value filter take the prefix dimensions, and its ranges are the span of its group's ranges (`spanDrillRanges`): a prefix range passes through, an inner time bucket widens to the group's buckets (already narrowed to any report period by the server), and a key some row lacks (the empty bucket) is dropped. - The export snapshot writes the grouped table exactly as drawn. - Unchanged faces: a one-level `summary` (still `[[]]`, grand-total footer only), `tabular` (no totals), a `matrix` with `columns`, and a `matrix` without `columns` (it still degrades to this table with the grand total only; `withSubtotals` is passed for `summary` alone, top level and joined blocks). An answer missing any prefix grouping (an older server) draws the plain table with the outer value repeated. **i18n.** `report.subtotal` added to all ten packs, reusing each pack's existing `masterDetail.subtotal` wording. **Changeset.** `.changeset/27975-summary-report-subtotals.md`, patch for `@object-ui/plugin-report` and `@object-ui/i18n`, with both declaration lines and the visible default change named. ## Tests - New `ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.summarySubtotals-27975.test.tsx`, 16 cases: the totals request for two and three levels; the grouped body for two levels; the server subtotal placed where the rows would sum differently (an `avg`); three-level nesting; grouping under an interleaving authored `order`; a joined summary block; drill on a subtotal (prefix only), on an inner row (every dimension), over a time-bucketed inner dimension (span), and over a group holding the empty bucket (range dropped). Controls: older server without the prefix grouping, no totals at all, one-level summary, two-level `tabular`, `matrix` without `columns`. - `DatasetReportRenderer.export-25873.test.tsx`: one new case, the CSV of a two-level summary equals the drawn table cell for cell. - Ablation (one-off, no permanent file): `node scripts/ablation-replace.mjs` replaced `withTotals && withSubtotals ? summarySubtotalPrefixes(rows) : []` with an empty list (anchor 1 → 0, blob `dc35dc45` → `4b92969b`). Predicted: every grouped-face case red, the controls green. Measured over the two files: 11 failed, 18 passed. Restore verified by the tool: blob back to `dc35dc45`, `git diff HEAD` empty. ## Acceptance notes - **Live measure (showcase, fresh stack, head before merge `ddbba1e8`).** The showcase ships no two-level summary, so one was saved into the throwaway `--fresh` database through `PUT /api/v1/meta/report/showcase_hours_by_status_priority` (`rows: [status, priority]`, `values: [est_hours]`); no `examples/**` edit. The Console dev server rendered it from this branch's source. - Before (the `origin/main` renderer swapped in, then restored byte-identical): request `totals.groupings: [[]]`; 8 rows, each repeating its status (`Backlog · Low · 12.0`, `Backlog · Medium · 18.0`, …), footer `Total 244.0`. - After: request `totals.groupings: [["status"], []]`; the server answered `totals[0].dimensions: ["status"]` with 5 rows (Backlog 30, Done 24, In Progress 100, In Review 54, To Do 36) and `totals[1]` the grand total 244. Drawn: `Backlog` header, `Low 12.0`, `Medium 18.0`, `Subtotal 30.0`, … `Done` header, `Medium 24.0`, `Subtotal 24.0`, `Total 244.0`, in the status field's declared order. - Clicking the Backlog subtotal opened the drill drawer titled `Backlog` with both Backlog tasks (Low and Medium). - **Groups under an `order` by a measure** are ordered by their first row, not by their subtotal. Ordering groups by subtotal would need the server to order the prefix grouping, which it drops for totals by design. Noted, not filed. - **The subtotal label is the bare word `Subtotal`** (`report.subtotal`), placed in the column of the level it closes; it does not repeat the group's value. Noted. - **Assignee and reviewer are not set on this PR.** The dispatch frame bars the dev from writing them; the seat owns that. - **Serial constraint with #28305** (same file, chart path): a local `git merge-tree` of this branch against #28305's head `39eb423b` is clean. #28305 is still open; this branch merges `origin/main` again after it lands. ## Verification All readings on head `91ccb5a5` (after merging `origin/main`) unless named. <details><summary>Affected package tests and type-checks</summary> - `pnpm --dir ui exec vitest run packages/plugin-report/ packages/i18n/` under `os-verify-lock`: Test Files 124 passed (124), Tests 1728 passed, 20 skipped. VERDICT command-exit 0. - `pnpm --filter @object-ui/plugin-report --filter @object-ui/i18n run type-check` (after building the plugin-report dependency closure with declarations): VERDICT command-exit 0. - eslint over the three changed source/test files: 0 errors; the 5 warnings are on lines this diff does not touch. - `pnpm check:new-line-citations` (ui): 0 new citations. </details> <details><summary>Derived gates (dispatch-gates --commands, reconciled with --ran)</summary> - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran` on head `ddbba1e8`: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN; every recorded exit code 0. `node scripts/check-sdui-manifest.mjs` and its `--self-test`: exit 0. `pnpm --dir ui exec vitest run scripts/`: Test Files 169 passed, 2 skipped; Tests 4901 passed, 2 skipped. - After the `origin/main` merge the derivation on `91ccb5a5` names the same 34 families. Re-run there: 32 of them, all exit 0. Not re-run on `91ccb5a5`: `pnpm check:type-check-debt` and `pnpm --dir ui exec vitest run scripts/` (both green on `ddbba1e8`; the merge brought no change to `ui/scripts/` or to a type-check ledger). </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibwTmv3iyLca6doQ7h7)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0ac6dab commit 3ba5206

14 files changed

Lines changed: 646 additions & 32 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@object-ui/plugin-report': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
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
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
**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:
12+
13+
- 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.
14+
- 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.
15+
- 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.
16+
- 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.
17+
- The export (CSV and XLSX) writes the grouped table as drawn.
18+
19+
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.
20+
21+
`@object-ui/i18n` adds `report.subtotal` to every pack.

‎ui/packages/i18n/src/locales/ar.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3574,6 +3574,7 @@ const ar = {
35743574
report: {
35753575
total: "الإجمالي",
35763576
totals: "الإجماليات",
3577+
subtotal: "المجموع الفرعي",
35773578
loading: "جارٍ التحميل…",
35783579
forbiddenMessage: "ليست لديك صلاحية لعرض البيانات الخاصة بهذا التقرير.",
35793580
dateRange: {

‎ui/packages/i18n/src/locales/de.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3309,6 +3309,7 @@ const de = {
33093309
report: {
33103310
total: "Gesamt",
33113311
totals: "Summen",
3312+
subtotal: "Zwischensumme",
33123313
loading: "Wird geladen…",
33133314
forbiddenMessage: "Sie haben keine Berechtigung, die Daten dieses Berichts anzuzeigen.",
33143315
dateRange: {

‎ui/packages/i18n/src/locales/en.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1782,6 +1782,7 @@ const en = {
17821782
report: {
17831783
total: 'Total',
17841784
totals: 'Totals',
1785+
subtotal: 'Subtotal',
17851786
loading: 'Loading…',
17861787
// objectui#12330 — the report's no-access state; its title is `dashboard.widgetForbiddenTitle`.
17871788
forbiddenMessage: 'You don’t have permission to view the data behind this report.',

‎ui/packages/i18n/src/locales/es.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3376,6 +3376,7 @@ const es = {
33763376
report: {
33773377
total: "Total",
33783378
totals: "Totales",
3379+
subtotal: "Subtotal",
33793380
loading: "Cargando…",
33803381
forbiddenMessage: "No tienes permiso para ver los datos de este informe.",
33813382
dateRange: {

‎ui/packages/i18n/src/locales/fr.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3372,6 +3372,7 @@ const fr = {
33723372
report: {
33733373
total: "Total",
33743374
totals: "Totaux",
3375+
subtotal: "Sous-total",
33753376
loading: "Chargement…",
33763377
forbiddenMessage: "Vous n’avez pas l’autorisation de voir les données de ce rapport.",
33773378
dateRange: {

‎ui/packages/i18n/src/locales/ja.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3311,6 +3311,7 @@ const ja = {
33113311
report: {
33123312
total: "合計",
33133313
totals: "合計",
3314+
subtotal: "小計",
33143315
loading: "読み込み中…",
33153316
forbiddenMessage: "このレポートのデータを表示する権限がありません。",
33163317
dateRange: {

‎ui/packages/i18n/src/locales/ko.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3308,6 +3308,7 @@ const ko = {
33083308
report: {
33093309
total: "합계",
33103310
totals: "합계",
3311+
subtotal: "소계",
33113312
loading: "로딩 중…",
33123313
forbiddenMessage: "이 보고서의 데이터를 볼 수 있는 권한이 없습니다.",
33133314
dateRange: {

‎ui/packages/i18n/src/locales/pt.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3371,6 +3371,7 @@ const pt = {
33713371
report: {
33723372
total: "Total",
33733373
totals: "Totais",
3374+
subtotal: "Subtotal",
33743375
loading: "Carregando…",
33753376
forbiddenMessage: "Você não tem permissão para ver os dados deste relatório.",
33763377
dateRange: {

‎ui/packages/i18n/src/locales/ru.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3452,6 +3452,7 @@ const ru = {
34523452
report: {
34533453
total: "Итого",
34543454
totals: "Итоги",
3455+
subtotal: "Промежуточный итог",
34553456
loading: "Загрузка…",
34563457
forbiddenMessage: "У вас нет прав на просмотр данных этого отчёта.",
34573458
dateRange: {

0 commit comments

Comments
 (0)