Skip to content

Commit 2d3aa8c

Browse files
feat(plugin-report): a report chart draws its seriesBy split (#28305)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoDx1NeBKzNcQPKReWziCHqf)_ Fixes #28206 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:报表图表写了 `chart.seriesBy` 后真的按第二个维度拆分系列了(例如每个负责人一根柱,按阶段分段);台账里 `chart.seriesBy` 从 planned 改为 live;顺手改掉了 lint 说明里因此变假的两句话。 - 为什么改:`seriesBy` 已能写、发布时也会校验,但图表只查 `[xAxis] × [yAxis]`,拆分从来没画出来;HotCRM 的「按负责人和阶段的商机漏斗」只能退回矩阵表。 - 风险与回滚:只有写了 `seriesBy` 的报表图表行为改变;没写的图表查询和输出逐字节不变(有对照用例钉住)。回滚即 revert 本 PR。 - 先看:`ui/packages/plugin-report/src/DatasetReportRenderer.tsx` 的 `DatasetReportChart`(查询维度、系列排序、系列标签三处)、新测试 `DatasetReportRenderer.chartSeriesBy-28206.test.tsx`、`packages/spec/liveness/report.json` 的 `seriesBy` 行。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-report/src/__tests__/DatasetReportRenderer.chartSeriesBy-28206.test.tsx`,预期 4 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes **Renderer (`ui/packages/plugin-report/src/DatasetReportRenderer.tsx`, `DatasetReportChart`).** - Reads `chart.seriesBy`. When it is set on a series chart, the chart's own dataset query is `[xAxis, seriesBy]` × `[yAxis]` instead of `[xAxis]` × `[yAxis]`, and the rows go to `buildChartSeries` with both dimensions. That takes the helper's existing pivot branch (the one a two-dimension dashboard widget already takes): one row per x bucket, one series per split value, null group included. `chart-series.ts` is unchanged. - The split dimension joins the chart's dimension-metadata read, so its values are relabeled to their (localized) option labels, and the series are listed in that dimension's declared option order with the empty group last. This is the same derivation `DatasetWidget` runs for its second dimension (`seriesOrder`), and it uses the same opt-out the categories use: a report `order` that reaches the chart's selection keeps the server's order. - Series labels: with a split, every series is a dimension value, so the measure's display name (objectui#4020) is no longer stamped onto a series. A series keeps the value it plots, unless a `chart.series[]` entry names that value (the spec's reading of `series[].name` when `seriesBy` is set). Then that entry's presentation lands on it through `mergeAuthoredSeries`, and its label resolves through `pickLocalized` in the UI language rather than first-string-wins. - Without `seriesBy`, the query, the rows and the series are what they were. - The file's own prose that said the chart queries only `chart.xAxis` × `chart.yAxis` now names the split. **Liveness (`packages/spec/liveness/report.json`).** - `chart.children.seriesBy`: `planned` → `live`, `verifiedAt` 2026-10-31, the renderer and the new test as evidence. `authorWarn` / `authorHint` are dropped (a `live` row may not carry them), so `os validate` stops printing "nothing draws it yet" for the key. - S2: the `chart` row's note dropped its stale sentence about a `groupBy` sub-key (`ChartConfigSchema` declares no `groupBy`) and now says the chart also groups by `seriesBy` when it is set. - `packages/spec/liveness/state-counts/report.md` regenerated (`gen:liveness-counts`): report live 39 → 40, planned 1 → 0. **Prose this change made false, corrected (no rule, severity or finding change).** - `packages/lint/src/validate-chart-bindings.ts`, module header: the report-charts bullet quoted the renderer as "queries only `chart.xAxis` × `chart.yAxis`", and the `chart-axis-not-selected` section quoted "the selection is exactly one dimension × one measure ... returns ONE series". Both now quote the current renderer text and say what happens with a split. - Same file, `ChartBinding.ownSelection` doc comment: "queries `chart.xAxis` × `chart.yAxis` alone and derives exactly ONE series" now names the split. - `packages/lint/src/rule-explanations.ts`, `chart-axis-not-selected` explanation (`os explain`): "the embedded chart queries exactly one dimension × one measure and derives ONE series" now says it queries its `chart.xAxis` dimension (and its `chart.seriesBy` split, when set) × that one measure and, without a split, derives one series. **Changeset:** `.changeset/28206-report-chart-series-by-renderer.md`, patch for `@object-ui/plugin-report`, `@objectstack/spec` (the published liveness ledger) and `@objectstack/lint` (the `os explain` text), with both declaration lines. ## Tests New `ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.chartSeriesBy-28206.test.tsx`, four cases over HotCRM's `opportunity_funnel_owner_stage` shape (a bar per owner, split by stage; the table groups by owner alone, so the only selection naming `stage` is the chart's): 1. the chart's query is `{ dimensions: ['owner', 'stage'], measures: ['total_amount'] }`, and the chart gets `xAxisKey: 'owner'` and one row per owner carrying each stage's amount (the null stage under `(None)`); 2. the series are in the stage field's declared order (Prospecting, Proposal, Negotiation, Closed Won), the null group last, each keeping its own column; 3. a `series[]` entry naming the value `Proposal` lands its colour on that series and its `{ en, zh }` label resolves to the zh limb on a zh console; the other series are untouched; 4. control: the same chart without `seriesBy` queries `owner` alone and draws one `total_amount` series labelled `Total Amount`, rows passed through. Ablation (one-off, no permanent file): `node scripts/ablation-replace.mjs` replaced the split arm of the chart query (`(seriesBy ? [xAxis, seriesBy] : [xAxis]) : [];` → `[xAxis] : [];`, anchor 1 → 0, blob `280c006b` → `983b4e78`). Predicted before the run: cases 1–3 red, the control green. Measured: 3 failed, 1 passed. Restore verified: blob back to `280c006b` and `git diff HEAD` empty. ## Acceptance notes - **S1 is not in this PR.** The seat dropped it per triage 6528521739 (spec lane). Measured location for the hand-over: `validateChartBindings` lives in `packages/lint/src/validate-chart-bindings.ts` (`@objectstack/lint`), not in `packages/spec`. The warning wanted is `seriesBy === xAxis` on a report chart. - **A single-series chart family with `seriesBy`.** A report `pie` / `donut` / `funnel` that sets `seriesBy` now hands the chart several series, which the chart component draws as its single-series refusal notice rather than a split; before this PR it drew one series and ignored the key. The spec does not refuse `seriesBy` on those types today. Noted for the spec lane beside S1, not filed. - **What `series[].name` matches with a split.** It matches the series key, which is the split value as the rows carry it: the server-resolved display label (ADR-0021), not the stored option value. For a select split, an entry naming the stored value (`proposal`) lands on nothing, as an unknown name does today. Noted, not filed. ## Verification All readings on head `39eb423b` unless named. <details><summary>Affected package tests and type-checks</summary> - `pnpm --dir ui exec vitest run packages/plugin-report/` under `os-verify-lock`: Test Files 31 passed (31), Tests 282 passed, 7 skipped. VERDICT command-exit 0. - `pnpm --filter @object-ui/plugin-report run type-check` (after building its dependency closure with declarations): VERDICT command-exit 0. - `pnpm --filter @objectstack/lint test`: Test Files 181 passed (181), Tests 7679 passed. `pnpm --filter @objectstack/lint typecheck`: VERDICT command-exit 0. - eslint over the four changed source/test files: 0 errors; the 5 warnings are pre-existing lines outside this diff. </details> <details><summary>Derived gates (dispatch-gates --commands, reconciled with --ran)</summary> `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran` → 71 derived families accounted for: 70 run, all exit 0, 1 NOT MEASURED. - NOT MEASURED: `pnpm check:dual-build-cjs-loads`, exit 3 (PREREQUISITE NOT MET: reads built `dist/` of every package; not built locally). Declared to CI. - `check:liveness` first went red on the stale `state-counts/report.md` shard; regenerated with `gen:liveness-counts` and re-run green. - `node scripts/check-plugin-teardown-shape.mjs --self-test` first exited 3 on the shallow clone (its pinned fixture commit was missing); green after fetching that commit. - `node scripts/check-sdui-manifest.mjs` and its `--self-test`: exit 0. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibvUw5sBjvzer4Z7WHt)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent aaa5c1d commit 2d3aa8c

7 files changed

Lines changed: 360 additions & 52 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/plugin-report': patch
3+
'@objectstack/spec': patch
4+
'@objectstack/lint': patch
5+
---
6+
7+
feat(plugin-report): a report chart draws its `seriesBy` split
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
A report chart that sets `chart.seriesBy` now draws the split it declares: one bar per `xAxis` value, split into one series per value of the `seriesBy` dimension (a bar per owner, split by stage). The chart's own dataset query asks for `[xAxis, seriesBy]` × `[yAxis]` and the rows are pivoted the way a dashboard widget with two dimensions pivots them, null group included. The series follow the split dimension's declared option order, and a `chart.series[]` entry whose `name` is a split value lands its colour and label on that series. Before this, the key was accepted and checked at publish, but the chart still drew a single series.
13+
14+
- **Liveness.** The `chart.seriesBy` row of the report ledger is graded `live` with the renderer as its evidence, so `os validate` no longer prints the "nothing draws it yet" author warning for it.
15+
- **Lint.** `os explain chart-axis-not-selected` now says the report chart queries its `xAxis` dimension and, when set, its `seriesBy` split. No rule, severity or finding changes.
16+
17+
Nothing to do for existing reports. A chart without `seriesBy` queries and draws exactly as before.

‎packages/lint/src/rule-explanations.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3302,8 +3302,9 @@ const CHART_AXIS_NOT_SELECTED_EXPLANATION: RuleExplanation = {
33023302
'chart drawn is unaffected. Advisory: the selection may legitimately be widened at runtime. A ' +
33033303
'query position is never reported: it IS the selection, or the query itself.',
33043304
'The selection is per surface. On a page chart it is `values`, the measure set the query asks ' +
3305-
'for. On a report it is the chart\'s own `chart.yAxis`: the embedded chart queries ' +
3306-
'exactly one dimension × one measure and derives ONE series, while `report.values` is the ' +
3305+
'for. On a report it is the chart\'s own `chart.yAxis`: the embedded chart queries its ' +
3306+
'`chart.xAxis` dimension (and its `chart.seriesBy` split, when set) × that one measure and, ' +
3307+
'without a split, derives ONE series, while `report.values` is the ' +
33073308
'selection of the table beneath it. So a report\'s `chart.yAxis` is never reported — a chart ' +
33083309
'cannot fail to select what it queries — and a `chart.series[].name` override lands only when ' +
33093310
'it names `chart.yAxis`. With `chart.seriesBy` set the chart derives one series per value of ' +

‎packages/lint/src/validate-chart-bindings.ts‎

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,12 @@
5858
* the three surfaces do not agree, so the tier and the consequence are per
5959
* POSITION rather than per rule:
6060
*
61-
* - **Report charts.** The chart runs its OWN query out of the two axis
62-
* strings — `useDatasetRows(dataset, [xAxis], [yAxis], …)` in
61+
* - **Report charts.** The chart runs its OWN query out of the axis
62+
* strings — `useDatasetRows(dataset, [xAxis], [yAxis], …)`, or
63+
* `[xAxis, seriesBy]` × `[yAxis]` with a split, in
6364
* `plugin-report/src/DatasetReportRenderer.tsx`, and in that file's own
64-
* words *"the embedded chart queries only `chart.xAxis` × `chart.yAxis`"*.
65+
* words *"the embedded chart queries only `chart.xAxis` (with
66+
* `chart.seriesBy`) × `chart.yAxis`"*.
6567
* So `chart.yAxis` IS the binding and keeps `error`. `chart.series[]` is
6668
* not: it is *"The author's per-chart override for ONE measure's display
6769
* name — the entry of `chart.series[]` whose `name` IS that measure"*,
@@ -117,13 +119,15 @@
117119
*
118120
* The consequence is per POSITION; the selection it is measured against is per
119121
* SURFACE — and on the report surface that selection is not `report.values`.
120-
* At the same pinned revision `DatasetReportRenderer.tsx` runs
121-
* `useDatasetRows(dataset, plan.kind === 'series' && xAxis ? [xAxis] : [],
122-
* wantsQuery && yAxis ? [yAxis] : [], …)` and derives the plotted series with
123-
* `buildChartSeries(…, [xAxis], [yAxis], …)` — *"the selection is exactly one
124-
* dimension × one measure, so this takes the helper's single-dimension branch
125-
* and returns ONE series"*. `report.values` is the selection of the TABLE
126-
* beneath the chart; the chart's own is the axis pair. Two things follow:
122+
* `DatasetReportRenderer.tsx` queries `[xAxis]` (`[xAxis, seriesBy]` with a
123+
* split) × `[yAxis]` through `useDatasetRows` and derives the plotted series
124+
* with `buildChartSeries(…, queryDimensions, [yAxis], …)` — *"Without
125+
* `seriesBy` the selection is one dimension × one
126+
* measure, so this takes the helper's single-dimension branch and returns ONE
127+
* series"*; with it, one series per `seriesBy` value, whose override names a
128+
* value and is not judged here (below). `report.values` is the selection of
129+
* the TABLE beneath the chart; the chart's own is the axis pair. Two things
130+
* follow:
127131
*
128132
* - **Nothing to report at the report `chart.yAxis`.** That position IS the
129133
* chart's query, so it cannot fail to select itself. The warning that fired
@@ -293,8 +297,9 @@ interface ChartBinding {
293297
/**
294298
* The measures THIS chart's own query selects, when that is narrower than
295299
* the `values` limb above (#15734). Set on the report surface and nowhere
296-
* else: the embedded report chart queries `chart.xAxis` × `chart.yAxis`
297-
* alone and derives exactly ONE series from it, while `report.values` is the
300+
* else: the embedded report chart queries `chart.xAxis` (with its
301+
* `chart.seriesBy` split, when set) × `chart.yAxis` alone and, without a
302+
* split, derives exactly ONE series from it, while `report.values` is the
298303
* selection of the table beneath it. `chart-axis-not-selected` resolves
299304
* against this set where it is present — which is both why a `series[].name`
300305
* override is measured against the singleton `{ chart.yAxis }`, and why the

‎packages/spec/liveness/report.json‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
"chart": {
5252
"status": "live",
5353
"verifiedAt": "2026-09-27",
54-
"note": "objectui: DatasetReportChart plots `chart.xAxis` (dataset dimension) × `chart.yAxis` (measure) via useDatasetRows, honoring `type`/`title` (framework#1890/#3441). Supersedes the 2026-06 audit's DEAD finding. Sub-key `groupBy` stays experimental (its own describe marker) — the dataset renderer plots a single series and never reads it. Live on NON-joined reports only (#20161, re-read at the `.objectui-sha` pin f8a9d0fb): the one `report.chart` read (DatasetReportRenderer.tsx:1557) sits after the joined branch's return (:1462-1524), so on a `type: 'joined'` report the schema's refinement REFUSES `chart` rather than accepting a key nothing reads. A `joined` report draws no chart (packages/spec/src/ui/report.zod.ts, the joined-chart prescription, #20161; read point moved out of spec source, #23434): at objectui @f8a9d0fb0596 packages/plugin-report/src/DatasetReportRenderer.tsx#DatasetReportRenderer the joined branch draws each block as a `DatasetMatrixTable` / `DatasetReportTable` and returns before the one `report.chart` read, and no renderer reads a block's `chart`.",
54+
"note": "objectui: DatasetReportChart plots `chart.xAxis` (dataset dimension) × `chart.yAxis` (measure) via useDatasetRows, honoring `type`/`title` (framework#1890/#3441). Supersedes the 2026-06 audit's DEAD finding. With `chart.seriesBy` set it also groups by that second dimension and draws a series per value of it (the `seriesBy` row below). Live on NON-joined reports only (#20161, re-read at the `.objectui-sha` pin f8a9d0fb): the one `report.chart` read (DatasetReportRenderer.tsx:1557) sits after the joined branch's return (:1462-1524), so on a `type: 'joined'` report the schema's refinement REFUSES `chart` rather than accepting a key nothing reads. A `joined` report draws no chart (packages/spec/src/ui/report.zod.ts, the joined-chart prescription, #20161; read point moved out of spec source, #23434): at objectui @f8a9d0fb0596 packages/plugin-report/src/DatasetReportRenderer.tsx#DatasetReportRenderer the joined branch draws each block as a `DatasetMatrixTable` / `DatasetReportTable` and returns before the one `report.chart` read, and no renderer reads a block's `chart`.",
5555
"childrenDefault": "live",
5656
"children": {
5757
"aria": {
@@ -60,13 +60,11 @@
6060
"note": "REMOVED in @objectstack/spec 17 (ADR-0049 D2; the `chart-config-aria-removed` conversion): `ChartConfigSchema` tombstones it with retiredKey, so authoring `report.chart.aria` is a tsc error and a parse error carrying the prescription (use `description` on this same chart config). The row exists because the tombstone keeps the key in the walked shape now that `chart` drills; the retirement record is `dashboard.json`'s `widgets.chartConfig.aria` row."
6161
},
6262
"seriesBy": {
63-
"status": "planned",
64-
"verifiedAt": "2026-10-30",
63+
"status": "live",
64+
"verifiedAt": "2026-10-31",
6565
"evidenceScope": "in-repo",
66-
"evidence": "packages/spec/src/ui/report.zod.ts#ReportChartSchema (declares `seriesBy`, an optional dataset dimension name); packages/lint/src/validate-chart-bindings.ts#validateChartBindings (resolves it against the report's bound dataset dimensions exactly as `xAxis`, `chart-dimension-unknown` at error, at os validate / os build and the report publish door)",
67-
"authorWarn": true,
68-
"authorHint": "Keep it, but nothing draws it yet: the report chart still draws a single series, and the split by this dimension appears once the report renderer reads `seriesBy`.",
69-
"note": "Spec half of the report chart series split (#27974): a second dataset dimension that splits the one `yAxis` measure into a series per value, the report's spelling of a dashboard widget's two-entry `dimensions` selection. Planned because the renderer half is a follow-up: at this tree ui/packages/plugin-report/src/DatasetReportRenderer.tsx#DatasetReportChart still queries `[xAxis]` × `[yAxis]` and hands `buildChartSeries` one dimension, so the split is not drawn. The other children of `chart` inherit the row's `live` verdict through `childrenDefault`; this row exists to keep that blanket from vouching for the one key nothing reads yet. Flip to `live` with the renderer card."
66+
"evidence": "ui/packages/plugin-report/src/DatasetReportRenderer.tsx#DatasetReportChart (reads `chart.seriesBy`, queries `[xAxis, seriesBy]` × `[yAxis]` and pivots the rows through `buildChartSeries` into one series per value); ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.chartSeriesBy-28206.test.tsx; packages/lint/src/validate-chart-bindings.ts#validateChartBindings (resolves it against the report's bound dataset dimensions exactly as `xAxis`, `chart-dimension-unknown` at error, at os validate / os build and the report publish door)",
67+
"note": "The report chart series split (#27974 spec half, #28206 renderer half): a second dataset dimension that splits the one `yAxis` measure into a series per value, the report's spelling of a dashboard widget's two-entry `dimensions` selection. LIVE: DatasetReportChart widens its own query to `[xAxis, seriesBy]` × `[yAxis]` and hands both dimensions to `buildChartSeries` (ui/packages/core/src/utils/chart-series.ts), whose pivot branch draws one row per x bucket and one series per `seriesBy` value, the null group included; the series follow the split dimension's declared option order, and a `series[]` entry whose `name` is a split value lands its presentation on that series. Without `seriesBy` the chart queries and draws exactly what it did before. The other children of `chart` inherit the row's `live` verdict through `childrenDefault`; this row stays to carry the reader as evidence."
7068
}
7169
}
7270
},

‎packages/spec/liveness/state-counts/report.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them.
1212

1313
| Type | live | exp | elsewhere | dead | planned | classified |
1414
|---|---|---|---|---|---|---|
15-
| `report` | 39 | 0 | 0 | 1 | 1 | 41 |
15+
| `report` | 40 | 0 | 0 | 1 | 0 | 41 |

0 commit comments

Comments
 (0)