diff --git a/.changeset/28206-report-chart-series-by-renderer.md b/.changeset/28206-report-chart-series-by-renderer.md new file mode 100644 index 00000000000..91ea5f90f29 --- /dev/null +++ b/.changeset/28206-report-chart-series-by-renderer.md @@ -0,0 +1,17 @@ +--- +'@object-ui/plugin-report': patch +'@objectstack/spec': patch +'@objectstack/lint': patch +--- + +feat(plugin-report): a report chart draws its `seriesBy` split + +Clause-②: no +Metadata-protocol: none + +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. + +- **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. +- **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. + +Nothing to do for existing reports. A chart without `seriesBy` queries and draws exactly as before. diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 1232cb8877b..e169d3f11d2 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -3302,8 +3302,9 @@ const CHART_AXIS_NOT_SELECTED_EXPLANATION: RuleExplanation = { 'chart drawn is unaffected. Advisory: the selection may legitimately be widened at runtime. A ' + 'query position is never reported: it IS the selection, or the query itself.', 'The selection is per surface. On a page chart it is `values`, the measure set the query asks ' + - 'for. On a report it is the chart\'s own `chart.yAxis`: the embedded chart queries ' + - 'exactly one dimension × one measure and derives ONE series, while `report.values` is the ' + + 'for. On a report it is the chart\'s own `chart.yAxis`: the embedded chart queries its ' + + '`chart.xAxis` dimension (and its `chart.seriesBy` split, when set) × that one measure and, ' + + 'without a split, derives ONE series, while `report.values` is the ' + 'selection of the table beneath it. So a report\'s `chart.yAxis` is never reported — a chart ' + 'cannot fail to select what it queries — and a `chart.series[].name` override lands only when ' + 'it names `chart.yAxis`. With `chart.seriesBy` set the chart derives one series per value of ' + diff --git a/packages/lint/src/validate-chart-bindings.ts b/packages/lint/src/validate-chart-bindings.ts index 9692cf37186..f6b77ac83ab 100644 --- a/packages/lint/src/validate-chart-bindings.ts +++ b/packages/lint/src/validate-chart-bindings.ts @@ -58,10 +58,12 @@ * the three surfaces do not agree, so the tier and the consequence are per * POSITION rather than per rule: * - * - **Report charts.** The chart runs its OWN query out of the two axis - * strings — `useDatasetRows(dataset, [xAxis], [yAxis], …)` in + * - **Report charts.** The chart runs its OWN query out of the axis + * strings — `useDatasetRows(dataset, [xAxis], [yAxis], …)`, or + * `[xAxis, seriesBy]` × `[yAxis]` with a split, in * `plugin-report/src/DatasetReportRenderer.tsx`, and in that file's own - * words *"the embedded chart queries only `chart.xAxis` × `chart.yAxis`"*. + * words *"the embedded chart queries only `chart.xAxis` (with + * `chart.seriesBy`) × `chart.yAxis`"*. * So `chart.yAxis` IS the binding and keeps `error`. `chart.series[]` is * not: it is *"The author's per-chart override for ONE measure's display * name — the entry of `chart.series[]` whose `name` IS that measure"*, @@ -117,13 +119,15 @@ * * The consequence is per POSITION; the selection it is measured against is per * SURFACE — and on the report surface that selection is not `report.values`. - * At the same pinned revision `DatasetReportRenderer.tsx` runs - * `useDatasetRows(dataset, plan.kind === 'series' && xAxis ? [xAxis] : [], - * wantsQuery && yAxis ? [yAxis] : [], …)` and derives the plotted series with - * `buildChartSeries(…, [xAxis], [yAxis], …)` — *"the selection is exactly one - * dimension × one measure, so this takes the helper's single-dimension branch - * and returns ONE series"*. `report.values` is the selection of the TABLE - * beneath the chart; the chart's own is the axis pair. Two things follow: + * `DatasetReportRenderer.tsx` queries `[xAxis]` (`[xAxis, seriesBy]` with a + * split) × `[yAxis]` through `useDatasetRows` and derives the plotted series + * with `buildChartSeries(…, queryDimensions, [yAxis], …)` — *"Without + * `seriesBy` the selection is one dimension × one + * measure, so this takes the helper's single-dimension branch and returns ONE + * series"*; with it, one series per `seriesBy` value, whose override names a + * value and is not judged here (below). `report.values` is the selection of + * the TABLE beneath the chart; the chart's own is the axis pair. Two things + * follow: * * - **Nothing to report at the report `chart.yAxis`.** That position IS the * chart's query, so it cannot fail to select itself. The warning that fired @@ -293,8 +297,9 @@ interface ChartBinding { /** * The measures THIS chart's own query selects, when that is narrower than * the `values` limb above (#15734). Set on the report surface and nowhere - * else: the embedded report chart queries `chart.xAxis` × `chart.yAxis` - * alone and derives exactly ONE series from it, while `report.values` is the + * else: the embedded report chart queries `chart.xAxis` (with its + * `chart.seriesBy` split, when set) × `chart.yAxis` alone and, without a + * split, derives exactly ONE series from it, while `report.values` is the * selection of the table beneath it. `chart-axis-not-selected` resolves * against this set where it is present — which is both why a `series[].name` * override is measured against the singleton `{ chart.yAxis }`, and why the diff --git a/packages/spec/liveness/report.json b/packages/spec/liveness/report.json index 3a391b9c913..0b47eb9090e 100644 --- a/packages/spec/liveness/report.json +++ b/packages/spec/liveness/report.json @@ -51,7 +51,7 @@ "chart": { "status": "live", "verifiedAt": "2026-09-27", - "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`.", + "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`.", "childrenDefault": "live", "children": { "aria": { @@ -60,13 +60,11 @@ "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." }, "seriesBy": { - "status": "planned", - "verifiedAt": "2026-10-30", + "status": "live", + "verifiedAt": "2026-10-31", "evidenceScope": "in-repo", - "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)", - "authorWarn": true, - "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`.", - "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." + "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)", + "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." } } }, diff --git a/packages/spec/liveness/state-counts/report.md b/packages/spec/liveness/state-counts/report.md index c7292b2f903..480128f1475 100644 --- a/packages/spec/liveness/state-counts/report.md +++ b/packages/spec/liveness/state-counts/report.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `report` | 39 | 0 | 0 | 1 | 1 | 41 | +| `report` | 40 | 0 | 0 | 1 | 0 | 41 | diff --git a/ui/packages/plugin-report/src/DatasetReportRenderer.tsx b/ui/packages/plugin-report/src/DatasetReportRenderer.tsx index 8f61bf2da49..280c006bf29 100644 --- a/ui/packages/plugin-report/src/DatasetReportRenderer.tsx +++ b/ui/packages/plugin-report/src/DatasetReportRenderer.tsx @@ -27,8 +27,9 @@ * with no totals row/column. A matrix without `columns` degrades to the * flat grouped table. * - `chart` (any non-joined type) → the embedded chart, its own - * `chart.xAxis` × `chart.yAxis` dataset query: above the grouped / flat - * table, below the matrix cross-tab (objectui#10964). + * `chart.xAxis` × `chart.yAxis` dataset query (`[xAxis, seriesBy]` when a + * `seriesBy` split is authored): above the grouped / flat table, below the + * matrix cross-tab (objectui#10964). * - `joined` → a vertical stack of blocks, each its own dataset-bound table, * with the report-level `runtimeFilter` merged into every block. * @@ -382,8 +383,8 @@ function readOrder(value: unknown): SelectionOrder | undefined { * (a deliberate 400 — a mistyped sort key that silently returns arbitrary rows * is worse than a loud failure). But one report's `order` is validated against * its WHOLE selection, while this renderer issues narrower sub-selections from - * it: the embedded chart queries only `chart.xAxis` × `chart.yAxis`, and the - * flat table path drops the matrix across-dimensions. Forwarding the full list + * it: the embedded chart queries only `chart.xAxis` (with `chart.seriesBy`) × + * `chart.yAxis`, and the flat table path drops the matrix across-dimensions. Forwarding the full list * to those would turn a perfectly valid report into a failed query. * * So keys outside a sub-selection are dropped HERE rather than sent and @@ -1124,8 +1125,9 @@ function authoredSeriesLabel(series: unknown, measure: string, language: string /** * Render a report's embedded `chart` (ADR-0021) by running its OWN dataset - * query — the `xAxis` dimension grouped, the `yAxis` measure aggregated — and - * feeding the rows to the registered generic chart component (plugin-charts). + * query — the `xAxis` dimension grouped (and the `seriesBy` split with it, + * when authored), the `yAxis` measure aggregated — and feeding the rows to the + * registered generic chart component (plugin-charts). * * Decoupled via {@link ComponentRegistry} (same approach as the legacy * renderer) so plugin-report keeps no hard dependency on plugin-charts: if the @@ -1192,12 +1194,19 @@ function DatasetReportChart({ }) { const xAxis = typeof chart.xAxis === 'string' ? chart.xAxis : ''; const yAxis = typeof chart.yAxis === 'string' ? chart.yAxis : ''; + // `ReportChartSchema.seriesBy` — a SECOND dataset dimension that splits the + // one `yAxis` measure into a series per value of it (a bar per owner, split + // by stage). It is the report's spelling of a dashboard widget's two-entry + // `dimensions` selection, so it is queried and pivoted the way that one is: + // `[xAxis, seriesBy]` × `[yAxis]`, through `buildChartSeries`' pivot branch. + const seriesBy = typeof chart.seriesBy === 'string' ? chart.seriesBy : ''; const plan = planReportChart(chart.type); - // The chart plots a NARROWER selection than the table beside it (one - // dimension × one measure), so `useDatasetRows` scopes the report's order to - // those two columns — a "biggest first" on the plotted measure still sorts - // the bars; a key naming some other row dimension is simply not applicable - // here and is dropped rather than posted and rejected. + // The chart plots a NARROWER selection than the table beside it (its x + // dimension, plus the `seriesBy` split when one is authored, × one measure), + // so `useDatasetRows` scopes the report's order to those columns — a + // "biggest first" on the plotted measure still sorts the bars; a key naming + // some other row dimension is simply not applicable here and is dropped + // rather than posted and rejected. // // A single-value chart (metric / kpi / gauge…) instead queries the measure // with NO dimension: the server hands back its one pre-aggregated grand @@ -1205,7 +1214,7 @@ function DatasetReportChart({ // client-side recombination (the ADR-0021 red line). Plans that render no // chart at all select no measures, which parks the hook in `idle`. const wantsQuery = plan.kind === 'series' || plan.kind === 'single_value'; - const queryDimensions = plan.kind === 'series' && xAxis ? [xAxis] : []; + const queryDimensions = plan.kind === 'series' && xAxis ? (seriesBy ? [xAxis, seriesBy] : [xAxis]) : []; const queryMeasures = wantsQuery && yAxis ? [yAxis] : []; const state = useDatasetRows( dataset, @@ -1239,32 +1248,45 @@ function DatasetReportChart({ // objectui#4330 — the embedded chart plots the SAME dimension the table // beside it groups by, so it takes the same label map. Leaving it out would // put the two spellings of one value on one screen, which is the defect this - // family exists to close. - const chartDimensions = React.useMemo(() => (xAxis ? [xAxis] : []), [xAxis]); + // family exists to close. The `seriesBy` split is a dimension the chart + // queries too, so its values (the series the legend lists) take theirs. + const chartDimensions = React.useMemo( + () => (xAxis ? (seriesBy ? [xAxis, seriesBy] : [xAxis]) : []), + [xAxis, seriesBy], + ); // objectui#4906 — `useDatasetDimensionMeta`, NOT the label-only // `useDatasetDimensionLabels`: this chart derives more from the resolved // field metadata than label maps (see the file-level doc comment above this // component). Mirrors `DatasetWidget`'s own call exactly. (Since // objectui#12243 the tables above read the same hook, for the declared order.) const dimensionMeta = useDatasetDimensionMeta(state.object, state.dimensionFields, chartDimensions); - const { categoryColors, dimensionLabels, categoryOrder } = React.useMemo(() => { - if (!dimensionMeta) return { categoryColors: null, dimensionLabels: null, categoryOrder: null }; + const { categoryColors, dimensionLabels, categoryOrder, seriesOrder } = React.useMemo(() => { + if (!dimensionMeta) return { categoryColors: null, dimensionLabels: null, categoryOrder: null, seriesOrder: null }; const { metaByPath, relabel } = dimensionMeta; // Colours and declared order read `option.label`, so they are fed the // LOCALIZED options — the same "translate the options, then render them" // shape `DatasetWidget` uses — which is what keeps them keyed by the - // string the relabeled rows below actually carry. `relabel[0]` is the - // chart's one x-axis dimension: `chartDimensions` above is at most one - // entry, so `relabel` never has a second. + // string the relabeled rows below actually carry. `relabel` follows + // `chartDimensions` in order, so `relabel[0]` is the x-axis dimension and + // `relabel[1]`, present only with a `seriesBy` split, is that split. const firstDimPath = relabel[0]?.path; const firstDimMeta = firstDimPath ? metaByPath[firstDimPath] : undefined; const firstDimOptions = firstDimPath ? localizeFieldOptions(firstDimMeta?.options, dimensionOptionTranslator(firstDimMeta, fieldOptionLabel)) : undefined; + // The split's declared order: its values are the pivoted series the legend + // lists, and a select there reads in its declared order as the axis does + // (`DatasetWidget` derives the same `seriesOrder` for its second dimension). + const secondDimPath = relabel[1]?.path; + const secondDimMeta = secondDimPath ? metaByPath[secondDimPath] : undefined; + const secondDimOptions = secondDimPath + ? localizeFieldOptions(secondDimMeta?.options, dimensionOptionTranslator(secondDimMeta, fieldOptionLabel)) + : undefined; return { categoryColors: buildOptionColorMap(firstDimOptions), dimensionLabels: deriveDimensionLabelMaps(metaByPath, relabel, fieldOptionLabel), categoryOrder: buildCategoryOrder(firstDimOptions), + seriesOrder: buildCategoryOrder(secondDimOptions), }; }, [dimensionMeta, fieldOptionLabel]); @@ -1389,10 +1411,12 @@ function DatasetReportChart({ // null group (#4508) — is a property of `buildChartSeries`, so it arrives // here by inheritance rather than by a third re-derivation. // - // The selection is exactly one dimension × one measure, so this takes the - // helper's single-dimension branch and returns ONE series; the pivot branch - // (2+ dimensions) is unreachable from a report chart, whose schema declares a - // single `xAxis`/`yAxis` pair. + // Without `seriesBy` the selection is one dimension × one measure, so this + // takes the helper's single-dimension branch and returns ONE series. With it, + // the selection is `[xAxis, seriesBy]` × `[yAxis]` and the helper's PIVOT + // branch runs — one row per x bucket, one series per `seriesBy` value — the + // branch the dashboard's two-dimension widget already takes, null groups and + // all, so the split inherits that family rather than re-deriving it. // // `builtinAggregateLabels` (objectui#7258): a measure the server minted as a // built-in default (`builtinAggregate: 'count'`, objectstack#14492) reads its @@ -1418,16 +1442,31 @@ function DatasetReportChart({ categoryRank ? { [xAxis]: categoryRank } : null, ); const plotRows = plotLayout ? plotLayout.map((i) => displayRows[i]) : displayRows; - const { data: chartData, xAxisKey, series: derivedSeries } = buildChartSeries( + const nullCategoryLabel = tt('chart.nullCategory', '(None)'); + const { data: chartData, xAxisKey, series: pivotOrDerivedSeries } = buildChartSeries( plotRows, - [xAxis], + queryDimensions, [yAxis], state.fields, { - nullCategoryLabel: tt('chart.nullCategory', '(None)'), + nullCategoryLabel, builtinAggregateLabels: builtinAggregateLabels(tt), }, ); + // The split's series in its DECLARED option order, the empty group last — + // the pivot lists them in arrival order, which is the dataset's `GROUP BY` + // order. The same rank, and the same opt-out, as the categories above: a + // report whose `order` reaches this selection keeps the server's order. + const seriesRank = seriesBy && !scopeOrder(order, queryDimensions, queryMeasures) + ? buildCategoryRank(seriesOrder) + : null; + const seriesPosition = (label: string, rank: ReadonlyMap): number => + !rank.has(label) && label === nullCategoryLabel ? Number.MAX_SAFE_INTEGER : declaredOptionPosition(label, rank); + const derivedSeries = seriesRank + ? [...pivotOrDerivedSeries].sort( + (a, b) => seriesPosition(a.label, seriesRank) - seriesPosition(b.label, seriesRank), + ) + : pivotOrDerivedSeries; // ── The PRESENTATION half: authored, merged forward (#4229) ────────────── // @@ -1452,8 +1491,17 @@ function DatasetReportChart({ // holds no provider — the very thing that would paint English on a zh // console. `measureLabel` already resolved ① through `pickLocalized` and ② // through `headerLabel`, so it wins outright. - const chartSeries = authoredSeries.map((s) => - s.dataKey === yAxis ? { ...s, label: measureLabel } : s, + // + // With a `seriesBy` split every series is a VALUE of that dimension, not the + // measure, so `measureLabel` names none of them: a series keeps the value it + // plots (already relabeled to its option label above), unless an authored + // `series[]` entry names that value — the spec's reading of `series[].name` + // when `seriesBy` is set — and then that entry's label resolves through + // `pickLocalized`, for the same reason as the measure's. + const chartSeries = authoredSeries.map((s, i) => + seriesBy + ? { ...s, label: authoredSeriesLabel(chart.series, s.dataKey, language) ?? derivedSeries[i].label } + : s.dataKey === yAxis ? { ...s, label: measureLabel } : s, ); // The authored CHROME — `showLegend`, `showDataLabels`, `colors`, `subtitle`, @@ -1484,7 +1532,8 @@ function DatasetReportChart({ chartType: plan.chartType, data: chartData, xAxisKey, - // One series, carrying its display name. `ChartRenderer` turns a + // One series (one per split value with `seriesBy`), each carrying + // its display name. `ChartRenderer` turns a // series `label` into `config[dataKey].label`, which is the single // input the legend, the mark's tooltip name and the single-value // caption all read — so legend and tooltip follow this by @@ -1988,7 +2037,7 @@ export const DatasetReportRenderer: React.FC = ({ // the chart plots the dataset's yAxis measure across the xAxis dimension; // the table always carries the exact numbers). ONE slot for every non-joined // presentation, the cross-tab included: `DatasetReportChart` runs its OWN - // `chart.xAxis` × `chart.yAxis` dataset query, so it binds the same way + // `chart.xAxis` (+ `chart.seriesBy`) × `chart.yAxis` dataset query, so it binds the same way // whichever table sits next to it — a matrix's rows and columns are dataset // dimensions like a summary's rows, and the chart's axes name them. // diff --git a/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.chartSeriesBy-28206.test.tsx b/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.chartSeriesBy-28206.test.tsx new file mode 100644 index 00000000000..582dc6a55c0 --- /dev/null +++ b/ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.chartSeriesBy-28206.test.tsx @@ -0,0 +1,238 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28206 — a report chart draws its `seriesBy` split. + * + * `ReportChartSchema.seriesBy` (`@objectstack/spec`) names a SECOND dataset + * dimension that splits the chart's one `yAxis` measure into a series per value + * of it: HotCRM's `opportunity_funnel_owner_stage` wants a bar per owner, split + * by stage. The key was declared and checked at publish, but + * `DatasetReportChart` queried `[xAxis]` × `[yAxis]` alone, so the split was + * never drawn. The chart now queries `[xAxis, seriesBy]` × `[yAxis]` and hands + * both dimensions to `buildChartSeries`, whose pivot branch is the one the + * dashboard's two-dimension widget already takes. + * + * Pinned here: + * - the chart's own query carries the split, and the chart is handed one row + * per owner and one series per stage, each row carrying its stages' amounts; + * - the series follow the split dimension's DECLARED option order, the empty + * group last, as the categories do; + * - an authored `series[]` entry names a VALUE of the split (the spec's reading + * with `seriesBy` set): its presentation lands on that series, and its label + * resolves in the UI language; + * - the CONTROL, the same chart without `seriesBy`, queries and draws exactly + * what it did before: one dimension, one series, the measure's label. + * + * DIRECTIONS, written before the ablation was run. The mutation drops the split + * from the chart's query (`queryDimensions` back to `[xAxis]` alone): + * - RED: the three split cases (the query has no `stage`, the chart gets one + * `total_amount` series and no per-stage columns); + * - GREEN on both sides: the control, which must not move a byte. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { I18nProvider } from '@object-ui/i18n'; +import { DatasetReportRenderer } from '../DatasetReportRenderer'; + +// HotCRM `OPPORTUNITY_STAGE_OPTIONS`, in its declared (pipeline) order. +const STAGE_OPTIONS = [ + { label: 'Prospecting', value: 'prospecting' }, + { label: 'Qualification', value: 'qualification' }, + { label: 'Needs Analysis', value: 'needs_analysis' }, + { label: 'Proposal', value: 'proposal' }, + { label: 'Negotiation', value: 'negotiation' }, + { label: 'Closed Won', value: 'closed_won' }, + { label: 'Closed Lost', value: 'closed_lost' }, +]; + +const META_DOCS: Record = { + crm_opportunity: { + name: 'crm_opportunity', + fields: { + stage: { type: 'select', options: STAGE_OPTIONS }, + owner_id: { type: 'lookup', reference: 'sys_user' }, + }, + }, +}; + +const META_OBJECT_ROUTE = /^\/api\/v1\/meta\/object\/(.+)$/; + +beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown) => { + const m = META_OBJECT_ROUTE.exec(String(input)); + const doc = m ? META_DOCS[decodeURIComponent(m[1])] : undefined; + if (!doc) return { ok: false, status: 404, json: async () => ({}) }; + return { ok: true, status: 200, json: async () => ({ item: doc }) }; + }), + ); +}); + +type ChartSeries = { dataKey: string; label: string; color?: string }; +type CapturedChartSchema = { xAxisKey?: string; series?: ChartSeries[]; data?: Array> }; +let captured: { schema: CapturedChartSchema } | null = null; +beforeEach(() => { + captured = null; + ComponentRegistry.register('chart', (props: { schema: CapturedChartSchema }) => { + captured = props; + return null; + }); +}); + +afterEach(() => { + // Unmount before restoring the real `fetch` (see DatasetReportRenderer.test.tsx). + cleanup(); + vi.unstubAllGlobals(); +}); + +type Selection = { dimensions: string[]; measures: string[] }; + +/** Answer each dataset selection by its dimension list, recording what was asked. */ +function sourceByDimensions(byDims: Record>) { + const calls: Selection[] = []; + return { + calls, + queryDataset: vi.fn(async (_dataset: string, selection: Selection) => { + calls.push(selection); + return byDims[selection.dimensions.join(',')] ?? { rows: [] }; + }), + }; +} + +const FIELDS = [ + { name: 'owner', type: 'lookup', label: 'Owner' }, + { name: 'stage', type: 'string', label: 'Stage' }, + { name: 'total_amount', type: 'number', label: 'Total Amount' }, +]; + +// Server-resolved to the authored stage label (ADR-0021), in `GROUP BY` order: +// stored values alphabetically, NULL first. +const OWNER_STAGE_RESULT = { + rows: [ + { owner: 'Zoe Admin', stage: null, total_amount: 9 }, + { owner: 'Zoe Admin', stage: 'Closed Won', total_amount: 1 }, + { owner: 'Zoe Admin', stage: 'Proposal', total_amount: 2 }, + { owner: 'Amy Chen', stage: 'Negotiation', total_amount: 5 }, + { owner: 'Amy Chen', stage: 'Prospecting', total_amount: 6 }, + ], + fields: FIELDS, + object: 'crm_opportunity', + dimensionFields: { owner: 'owner_id', stage: 'stage' }, +}; + +const OWNER_RESULT = { + rows: [ + { owner: 'Zoe Admin', total_amount: 12 }, + { owner: 'Amy Chen', total_amount: 11 }, + ], + fields: [FIELDS[0], FIELDS[2]], + object: 'crm_opportunity', + dimensionFields: { owner: 'owner_id' }, +}; + +const FUNNEL_BY_OWNER_STAGE = { + name: 'opportunity_funnel_owner_stage', + type: 'summary', + dataset: 'opportunity_metrics', + // The table groups by owner alone, so the only selection that names `stage` + // is the chart's own. + rows: ['owner'], + values: ['total_amount'], + chart: { type: 'bar', xAxis: 'owner', yAxis: 'total_amount', seriesBy: 'stage' }, +}; + +const NONE = '(None)'; + +function renderReport(report: Record, src: ReturnType, language?: string) { + const element = ; + render( + language ? ( + + {element} + + ) : ( + element + ), + ); +} + +describe('a report chart draws its seriesBy split (objectstack#28206)', () => { + it('queries [xAxis, seriesBy] × [yAxis] and hands the chart a row per owner, a series per stage', async () => { + const src = sourceByDimensions({ 'owner,stage': OWNER_STAGE_RESULT, owner: OWNER_RESULT }); + renderReport(FUNNEL_BY_OWNER_STAGE, src); + await waitFor(() => expect(captured?.schema?.series?.length).toBe(5)); + // The table asks for `owner`; the chart asks for the split and the one measure. + expect(src.calls).toContainEqual( + expect.objectContaining({ dimensions: ['owner', 'stage'], measures: ['total_amount'] }), + ); + const schema = captured!.schema; + expect(schema.xAxisKey).toBe('owner'); + expect(schema.data).toEqual([ + { owner: 'Zoe Admin', [NONE]: 9, 'Closed Won': 1, Proposal: 2 }, + { owner: 'Amy Chen', Negotiation: 5, Prospecting: 6 }, + ]); + }); + + it('lists the series in the split dimension\'s declared option order, the empty group last', async () => { + const src = sourceByDimensions({ 'owner,stage': OWNER_STAGE_RESULT, owner: OWNER_RESULT }); + renderReport(FUNNEL_BY_OWNER_STAGE, src); + // The order lands once the stage options are read; arrival order is + // `(None), Closed Won, Proposal, Negotiation, Prospecting`. + await waitFor(() => + expect(captured?.schema?.series?.map((s) => s.label)).toEqual([ + 'Prospecting', + 'Proposal', + 'Negotiation', + 'Closed Won', + NONE, + ]), + ); + // Each series keeps the column it plots. + expect(captured!.schema.series!.map((s) => s.dataKey)).toEqual([ + 'Prospecting', + 'Proposal', + 'Negotiation', + 'Closed Won', + NONE, + ]); + }); + + it('pairs an authored series[] entry with the split VALUE it names, its label in the UI language', async () => { + const src = sourceByDimensions({ 'owner,stage': OWNER_STAGE_RESULT, owner: OWNER_RESULT }); + renderReport( + { + ...FUNNEL_BY_OWNER_STAGE, + chart: { + ...FUNNEL_BY_OWNER_STAGE.chart, + series: [{ name: 'Proposal', label: { en: 'Proposals', zh: '提案阶段' }, color: '#ff0000' }], + }, + }, + src, + 'zh', + ); + await waitFor(() => expect(captured?.schema?.series?.length).toBe(5)); + const proposal = captured!.schema.series!.find((s) => s.dataKey === 'Proposal'); + expect(proposal).toMatchObject({ label: '提案阶段', color: '#ff0000' }); + // The entry names one value; the other series are untouched. + const won = captured!.schema.series!.find((s) => s.dataKey === 'Closed Won'); + expect(won?.label).toBe('Closed Won'); + expect(won?.color).toBeUndefined(); + }); + + it('the control — the same chart without seriesBy — queries one dimension and draws one series', async () => { + const src = sourceByDimensions({ 'owner,stage': OWNER_STAGE_RESULT, owner: OWNER_RESULT }); + const { seriesBy: _split, ...chart } = FUNNEL_BY_OWNER_STAGE.chart; + renderReport({ ...FUNNEL_BY_OWNER_STAGE, chart }, src); + await waitFor(() => expect(captured?.schema?.data?.length).toBe(2)); + // Table and chart both ask for `owner` alone: nothing asks for the split. + expect(src.calls.length).toBeGreaterThan(0); + expect(src.calls.every((c) => c.dimensions.join(',') === 'owner')).toBe(true); + const schema = captured!.schema; + expect(schema.xAxisKey).toBe('owner'); + expect(schema.series).toEqual([expect.objectContaining({ dataKey: 'total_amount', label: 'Total Amount' })]); + expect(schema.data).toEqual(OWNER_RESULT.rows); + }); +});