Skip to content

Commit 16e7a80

Browse files
feat(spec)!: a report chart's seriesBy that cannot split (single-series type, or the xAxis dimension) is refused at authoring time (#28439)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoVMTxdBoChiHe1Sf5J2Xcv5)_ Fixes #28424 Clause-②: yes (narrowing) Metadata-protocol: narrowed domain:spec#1 · os-tesla · session_01L9qibwvHraeucX94Rvvzrk ## 维护者速读 - 改了什么:报表图表的 `seriesBy` 拆分在两种拆不开的写法下,发布前就被 spec 拒收——饼图/环形图/漏斗/树图/桑基这类单系列图,和 `seriesBy` 与 `xAxis` 同一维度。 - 为什么改:这两种写法今天能保存、能发布,业务用户打开报表才看到报错框或每条系列只有一个点的“坏图”;作者应在 `os build`/Studio 保存/`os lint` 时就听到,而且仪表盘早已对同类写法拒收。 - 风险与回滚:收窄,已存的这两种报表会在下次保存/校验时被拒,拒收文案点名改法(换柱状族或去掉 `seriesBy`);仓内示例与夹具零命中。回滚 = 撤这个 PR。 - 先看:`packages/spec/src/ui/report.zod.ts` 的 `checkReportChartSeriesSplit`;`packages/spec/src/ui/dashboard.zod.ts` 只是把 `SINGLE_SERIES_CHART_TYPES` 导出;`packages/spec/src/ui/report.test.ts` 末尾新 describe。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/ui/report.test.ts -t "cannot split"`,预期 7 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes `ReportSchema`'s refinement now calls `checkReportChartSeriesSplit` on every non-`joined` report. It refuses `chart.seriesBy` with one `custom` issue at `chart.seriesBy`, in two arms: 1. **Single-series arm.** `chart.type` is in `SINGLE_SERIES_CHART_TYPES` (`pie` / `donut` / `funnel` / `treemap` / `sankey`). The message names the report, the type and the split, and says what to write instead: a bar-family type (`bar` / `column` / `horizontal-bar`) for one bar per split value, or drop `seriesBy`. 2. **Same-dimension arm.** `seriesBy === xAxis`. The message says `seriesBy` must name a different dimension than `xAxis`, or be dropped. The arms are exclusive. On a single-series type the first one answers, since dropping `seriesBy` fixes both. **One source for the type set.** `SINGLE_SERIES_CHART_TYPES` in `dashboard.zod.ts` changes from a module-private `const` to an export, and its docblock now names the report as a reader. `report.zod.ts` imports it, and so does the `seriesBy` `.describe()`. No second list exists. **Where the check sits.** It runs in the report-level `superRefine`, not on `ReportChartSchema`. That gives the `chart.seriesBy` path and the report's name, and it leaves `ReportChartSchema` an unrefined `ChartConfigSchema.extend(...)`. Its downstream `.extend` / `.shape` readers (`ui/packages/types` `SpecReportChartSchema`, `chart.test.ts`) are untouched. **Rider S1 superseded.** S1 named a lint warning in `packages/lint/src/validate-chart-bindings.ts` for `seriesBy === xAxis`. This spec refusal supersedes it, so no lint rule is added. `validateChartBindings` still owns whether `seriesBy` names a dimension the dataset declares, which this schema cannot see. Generated: `api-surface/ui.json` and `export-origins/ui.json` (the new export), plus `content/docs/references/ui/report.mdx` (the `.describe()` text). ## Tests A new describe block, *ReportSchema refuses a chart seriesBy that cannot split*, in `packages/spec/src/ui/report.test.ts`, 7 cases: - `pie`, `donut` and `funnel` with `seriesBy` are refused at `chart.seriesBy` as `custom`. Each message names the report, `type: 'TYPE'`, the split and the bar-family prescription. - `bar` with `seriesBy === xAxis` is refused, and its message says the split must name a different dimension. - Controls: `bar` and `line` with a distinct `seriesBy` parse and keep it. `pie` without `seriesBy` parses. - A joined report carrying a `pie` chart split by its own `xAxis` gets exactly one issue, at `chart` (the existing joined refusal), and none at `chart.seriesBy`. - One source: across every `ChartTypeSchema` member, the set this refusal fires on equals `SINGLE_SERIES_CHART_TYPES`. Lint fixtures: every `seriesBy` fixture in `validate-chart-bindings.test.ts` is `bar` with a distinct split, so none refuses now. No fixture changed. The suite stays green (80/80). ## Acceptance notes - **Existing metadata.** `git grep seriesBy` over `examples/**`, `packages/qa/**` and `ui/**`, plus the hotcrm checkout: every hit is a `bar` with a distinct split. Zero hits on either refused shape, at 5da3a8c. - **objectui's client-side authoring door.** The Studio report editor runs its own `.shape` mirror, so it keeps accepting these two shapes until it chains a check. The author then meets the refusal at save or publish, the same position the dashboard arm is in. Noted, not filed. - **Commit trailers.** The three `wip(...)` commits at the base of the branch carry no trailer pair. The branch is not `claude/issue-*`, so it is shared under AGENTS.md §3 and was not rewritten. The squash merge drops them. ## Verification All readings are at head `1707dd00` unless noted. <details><summary>Ablation, one per arm (src-resolved suite, so no build leg). Mutation through <code>scripts/ablation-replace.mjs</code>, restored to the HEAD blob each time.</summary> - Single-series arm, anchor `if (typeof type === 'string' && (SINGLE_SERIES_CHART_TYPES …).includes(type)) {` replaced with `if (false) {`. Anchor count went 1 → 0, blob `4c78418e` → `ca603b9e`. Result: 4 failed (pie, donut, funnel, one-source) and 3 passed. Restored, blob equals HEAD. - Same-dimension arm, anchor `if (seriesBy === xAxis) {` replaced with `if (false) {`. Anchor count went 1 → 0, blob → `b4975efc`. Result: 1 failed (same dimension) and 6 passed. Restored, blob equals HEAD. </details> <details><summary>Package runs</summary> - `pnpm --filter @objectstack/spec build` (declarations on): VERDICT command-exit 0. - `pnpm --filter @objectstack/spec check:generated`: 3 stale before `--fix` (api-surface, export-origins, docs). After `--fix`, all 14 are up to date. - `pnpm --filter @objectstack/spec typecheck` + `pnpm --filter @objectstack/lint exec vitest run src/validate-chart-bindings.test.ts`: VERDICT command-exit 0, 80/80. - `pnpm --filter @objectstack/spec test`: VERDICT command-exit 0, Test Files 773 passed (773), Tests 22837 passed + 1 todo. - `pnpm --filter @objectstack/dogfood test` (owed once for the `(narrowing)` arm, at 1707dd0, closure built first): VERDICT command-exit 0, Test Files 361 passed + 3 skipped (364), Tests 2917 passed + 28 skipped. </details> <details><summary>Derived gates (<code>dispatch-gates.mjs --commands</code>, 104 lines, each run with stdin closed, exit captured before any pipe)</summary> Prerequisite build first: `turbo run build --filter='./packages/**' --filter='@objectstack/dogfood^...'` with declarations (VERDICT command-exit 0). Then all 104 lines ran, and every one is `:: exit 0`. The `check:entry-nameability` log prints its standing NOT MEASURED line for `api-assembled` / `qa`, which have no callable export; that line is unrelated to this diff. `dispatch-gates.mjs --repo objectstack-ai/objectstack --ran`: "104 derived famil(ies) accounted for — 104 run, 0 NOT-MEASURED (a DERIVED zero — all 104 recorded an exit code and none of them is 3)", exit 0. The record is at `/mnt/project-files/spec-seat-1/r2026-10-31/out/28424-gates-ran-1707dd00.list` in the seat's project folder. Every other family is CI's: see this PR's check-runs. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L9qibwvHraeucX94Rvvzrk --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibwvHraeucX94Rvvzrk)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7064615 commit 16e7a80

7 files changed

Lines changed: 158 additions & 9 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: a report chart's `seriesBy` that cannot split is refused at authoring time — on a single-series chart type, or naming the same dimension as `xAxis`
6+
7+
Clause-②: yes (narrowing)
8+
Metadata-protocol: narrowed
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped: two combinations of existing values that no renderer could draw are now refused. A conversion cannot know whether the author meant a bar chart or one series, so there is nothing for a migration to rewrite; the refusal text names both fixes. -->
11+
12+
**BREAKING** for two report chart shapes, both of which parsed before and then failed on the page. `ReportSchema` (non-`joined` reports; a joined report's `chart` is refused whole, as before) now refuses, at `chart.seriesBy`:
13+
14+
- **`seriesBy` on a single-series type** — `pie`, `donut`, `funnel`, `treemap` or `sankey`. The split derives one series per value of the `seriesBy` dimension and these types draw one series, so the page showed a refusal panel instead of the chart. The refusal names the report, the type and the split. Fix: a bar-family type (`bar` / `column` / `horizontal-bar`) for one bar per split value in each `xAxis` category, or drop `seriesBy` to keep the single-series chart. This is the report spelling of the rule dashboards already apply to a dimensioned widget with several measures on the same types.
15+
- **`seriesBy` naming the same dimension as `xAxis`** — every series held exactly one point. Fix: name a different dimension than `xAxis`, or drop `seriesBy`.
16+
17+
`SINGLE_SERIES_CHART_TYPES` is now exported from `@objectstack/spec/ui` (the list `checkDashboardWidgetChartMeasureArity` reads); the report refusal reads the same constant, so the two doors cannot drift apart.

‎content/docs/references/ui/report.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ const result = JoinedReportBlockSchema.parse(data);
104104
| **annotations** | `{ type?: Enum<'line' \| 'region'>; axis?: Enum<'x' \| 'y'>; value: number \| string; endValue?: number \| string; … }[]` | optional | Reference lines/bands drawn over the plot: `{ type: "line" \| "region", axis: "x" \| "y", value, endValue?, color?, label?, style? }` |
105105
| **interaction** | `{ tooltips?: boolean; brush?: boolean }` | optional | Interaction toggles: `{ tooltips?, brush? }` |
106106
| **aria** | `never` | optional | [REMOVED] `ChartConfig.aria` — authored as `dashboard.widgets[].chartConfig.aria`, `report.chart.aria` and `report.blocks[].chart.aria` — was removed in @objectstack/spec 17 (ADR-0049 D2). No chart renderer ever applied it: the chart implementation declares no `aria` prop, the presentation lowering names it nowhere, and the react `<ObjectChart>` block never published it, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied on this same chart config is its sibling `description`, which the chart renderer lowers onto the chart graphic as `role="img"` plus `aria-label`. The shared `AriaProps` shape is NOT gone — `ariaLabel` / `ariaDescribedBy` / `role` stay live in the `aria` block on `page.aria`, `page.components[].aria` and the list view `aria`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
107-
| **seriesBy** | `string` | optional | Dataset dimension name that splits the yAxis measure into one series per value of it (bound-dataset dimension, not a raw field); with it set, `series[].name` names a value of this dimension |
107+
| **seriesBy** | `string` | optional | Dataset dimension name that splits the yAxis measure into one series per value of it (bound-dataset dimension, not a raw field); with it set, `series[].name` names a value of this dimension. Refused on a single-series type (pie/donut/funnel/treemap/sankey) and when it names the xAxis dimension |
108108

109109
### Nested Shape: `Report.dateRange`
110110

@@ -168,7 +168,7 @@ const result = JoinedReportBlockSchema.parse(data);
168168
| **annotations** | `{ type?: Enum<'line' \| 'region'>; axis?: Enum<'x' \| 'y'>; value: number \| string; endValue?: number \| string; … }[]` | optional | Reference lines/bands drawn over the plot: `{ type: "line" \| "region", axis: "x" \| "y", value, endValue?, color?, label?, style? }` |
169169
| **interaction** | `{ tooltips?: boolean; brush?: boolean }` | optional | Interaction toggles: `{ tooltips?, brush? }` |
170170
| **aria** | `never` | optional | [REMOVED] `ChartConfig.aria` — authored as `dashboard.widgets[].chartConfig.aria`, `report.chart.aria` and `report.blocks[].chart.aria` — was removed in @objectstack/spec 17 (ADR-0049 D2). No chart renderer ever applied it: the chart implementation declares no `aria` prop, the presentation lowering names it nowhere, and the react `<ObjectChart>` block never published it, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied on this same chart config is its sibling `description`, which the chart renderer lowers onto the chart graphic as `role="img"` plus `aria-label`. The shared `AriaProps` shape is NOT gone — `ariaLabel` / `ariaDescribedBy` / `role` stay live in the `aria` block on `page.aria`, `page.components[].aria` and the list view `aria`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
171-
| **seriesBy** | `string` | optional | Dataset dimension name that splits the yAxis measure into one series per value of it (bound-dataset dimension, not a raw field); with it set, `series[].name` names a value of this dimension |
171+
| **seriesBy** | `string` | optional | Dataset dimension name that splits the yAxis measure into one series per value of it (bound-dataset dimension, not a raw field); with it set, `series[].name` names a value of this dimension. Refused on a single-series type (pie/donut/funnel/treemap/sankey) and when it names the xAxis dimension |
172172

173173
### Allowed Values: `ReportChart.type`
174174

‎packages/spec/api-surface/ui.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,7 @@
429429
"RowHeight (type)",
430430
"RowHeightSchema (const)",
431431
"SECTION_HEADING_COUNT_HOLE (const)",
432+
"SINGLE_SERIES_CHART_TYPES (const)",
432433
"STRING_ARM_REGISTERED_TYPES (const)",
433434
"SelectionConfig (type)",
434435
"SelectionConfigParsed (type)",

‎packages/spec/export-origins/ui.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,7 @@
412412
"RowHeight": "src/ui/view.zod.ts#RowHeight (type)",
413413
"RowHeightSchema": "src/ui/view.zod.ts#RowHeightSchema (const)",
414414
"SECTION_HEADING_COUNT_HOLE": "src/ui/component.zod.ts#SECTION_HEADING_COUNT_HOLE (const)",
415+
"SINGLE_SERIES_CHART_TYPES": "src/ui/dashboard.zod.ts#SINGLE_SERIES_CHART_TYPES (const)",
415416
"STRING_ARM_REGISTERED_TYPES": "src/ui/component-type-vocabulary.ts#STRING_ARM_REGISTERED_TYPES (const)",
416417
"SelectionConfig": "src/ui/view.zod.ts#SelectionConfig (type)",
417418
"SelectionConfigParsed": "src/ui/view.zod.ts#SelectionConfigParsed (type)",

‎packages/spec/src/ui/dashboard.zod.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -780,10 +780,12 @@ export const DASHBOARD_WIDGET_MULTI_MEASURE_TYPES = [
780780

781781
/**
782782
* The chart `type`s that draw ONE series whatever the widget's `dimensions` —
783-
* the single-series set. Module-private, like the metric family's list above,
784-
* and the only list of it in this package:
785-
* {@link checkDashboardWidgetChartMeasureArity} reads it, that check's refusal
786-
* text reads it, and the `values` doc string reads it.
783+
* the single-series set. One exported constant, and the only list of it in
784+
* this package: {@link checkDashboardWidgetChartMeasureArity} reads it, that
785+
* check's refusal text reads it, the `values` doc string reads it, and the
786+
* report's chart reads it too — `ReportSchema` refuses a `chart.seriesBy`
787+
* split on any of these types, since the split derives one series per value
788+
* and these types draw one. Import it; ⛔ never restate it.
787789
*
788790
* Measured in objectui's shared chart renderer (`AdvancedChartImpl` in
789791
* `@object-ui/plugin-charts`; recorded in `packages/spec/liveness/dashboard.json`,
@@ -805,7 +807,7 @@ export const DASHBOARD_WIDGET_MULTI_MEASURE_TYPES = [
805807
* remove a member to make a document parse — only when a renderer draws every
806808
* measure.
807809
*/
808-
const SINGLE_SERIES_CHART_TYPES = [
810+
export const SINGLE_SERIES_CHART_TYPES = [
809811
'pie',
810812
'donut',
811813
'funnel',

‎packages/spec/src/ui/report.test.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
defineReport,
1313
} from './report.zod';
1414
import { strictObjectDeclarations } from '../shared/strict-object';
15+
import { SINGLE_SERIES_CHART_TYPES } from './dashboard.zod';
16+
import { ChartTypeSchema } from './chart.zod';
1517
import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas';
1618

1719
/**
@@ -580,3 +582,66 @@ describe('ReportChartSchema', () => {
580582
expect(r.error?.issues.map((i) => i.path.join('.'))).toContain('seriesBy');
581583
});
582584
});
585+
586+
/**
587+
* A `chart.seriesBy` split that cannot split is refused at authoring time, on
588+
* the report, at `chart.seriesBy` — not drawn as a refusal panel on the page.
589+
* Two arms: a single-series `type` (the dashboard's `SINGLE_SERIES_CHART_TYPES`,
590+
* one source), and `seriesBy` naming the `xAxis` dimension. Non-joined only.
591+
*/
592+
describe('ReportSchema refuses a chart `seriesBy` that cannot split', () => {
593+
const report = (chart: Record<string, unknown>, extra: Record<string, unknown> = {}) => ({
594+
name: 'pipeline_by_owner', label: 'Pipeline', type: 'summary',
595+
dataset: 'opportunities', rows: ['owner'], values: ['amount'],
596+
chart: { xAxis: 'owner', yAxis: 'amount', ...chart },
597+
...extra,
598+
});
599+
const splitIssues = (doc: unknown) => {
600+
const r = ReportSchema.safeParse(doc);
601+
return r.success ? [] : r.error.issues.filter((i) => i.path.join('.') === 'chart.seriesBy');
602+
};
603+
604+
it.each(['pie', 'donut', 'funnel'])('refuses `seriesBy` on a `%s` chart, naming the report, the type and the split', (type) => {
605+
const issues = splitIssues(report({ type, seriesBy: 'stage' }));
606+
expect(issues).toHaveLength(1);
607+
expect(issues[0].code).toBe('custom');
608+
expect(issues[0].message).toContain('`pipeline_by_owner`');
609+
expect(issues[0].message).toContain(`\`type: '${type}'\``);
610+
expect(issues[0].message).toContain("`seriesBy: 'stage'`");
611+
expect(issues[0].message).toContain('`bar` / `column` / `horizontal-bar`');
612+
});
613+
614+
it('refuses `seriesBy` naming the same dimension as `xAxis`', () => {
615+
const issues = splitIssues(report({ type: 'bar', seriesBy: 'owner' }));
616+
expect(issues).toHaveLength(1);
617+
expect(issues[0].code).toBe('custom');
618+
expect(issues[0].message).toContain("`seriesBy: 'owner'`");
619+
expect(issues[0].message).toContain('`seriesBy` must name a different dimension than `xAxis`');
620+
});
621+
622+
it('controls: `bar` / `line` with a distinct `seriesBy` parse, and a `pie` without one parses', () => {
623+
for (const type of ['bar', 'line']) {
624+
const r = ReportSchema.safeParse(report({ type, seriesBy: 'stage' }));
625+
expect(r.success, `${type} split by stage`).toBe(true);
626+
expect(r.data?.chart?.seriesBy).toBe('stage');
627+
}
628+
expect(ReportSchema.safeParse(report({ type: 'pie' })).success, 'pie, one series').toBe(true);
629+
});
630+
631+
it('a joined report is untouched — its `chart` refusal stands alone, at `chart`', () => {
632+
const joined = {
633+
name: 'joined_pipeline', label: 'Joined', type: 'joined',
634+
blocks: [{ name: 'open_block', type: 'summary', dataset: 'opportunities', rows: ['owner'], values: ['amount'] }],
635+
chart: { type: 'pie', xAxis: 'owner', yAxis: 'amount', seriesBy: 'owner' },
636+
};
637+
const r = ReportSchema.safeParse(joined);
638+
expect(r.success).toBe(false);
639+
expect(r.error?.issues.map((i) => i.path.join('.'))).toEqual(['chart']);
640+
});
641+
642+
it('one source: the refused type set is exactly the dashboard\'s SINGLE_SERIES_CHART_TYPES', () => {
643+
const refused = ChartTypeSchema.options.filter((type) => splitIssues(report({ type, seriesBy: 'stage' })).length > 0);
644+
expect([...refused].sort()).toEqual([...SINGLE_SERIES_CHART_TYPES].sort());
645+
expect(refused.length).toBeGreaterThan(0);
646+
});
647+
});

‎packages/spec/src/ui/report.zod.ts‎

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { ProtectionSchema } from '../shared/protection.zod';
55
import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
66
import { analyticsCarrierFilter } from './analytics-carrier-filter';
77
import { ChartConfigSchema } from './chart.zod';
8-
import { DATE_RANGE_DEFAULT_RANGES } from './dashboard.zod';
8+
import { DATE_RANGE_DEFAULT_RANGES, SINGLE_SERIES_CHART_TYPES } from './dashboard.zod';
99
import { ReportExportOptionsSchema } from './list-view-export-options';
1010
import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
1111
import { I18nLabelSchema } from './i18n.zod';
@@ -47,10 +47,13 @@ export const ReportChartSchema = lazySchema(() => ChartConfigSchema.extend({
4747
/**
4848
* Dataset **dimension** name whose values split the `yAxis` measure into one
4949
* series each (from the report's bound dataset). Omitted = one series.
50+
* `ReportSchema` refuses it on a single-series `type` and when it names the
51+
* `xAxis` dimension (`checkReportChartSeriesSplit`).
5052
*/
5153
seriesBy: z.string().optional().describe(
5254
'Dataset dimension name that splits the yAxis measure into one series per value of it '
53-
+ '(bound-dataset dimension, not a raw field); with it set, `series[].name` names a value of this dimension',
55+
+ '(bound-dataset dimension, not a raw field); with it set, `series[].name` names a value of this dimension. '
56+
+ 'Refused on a single-series type (' + SINGLE_SERIES_CHART_TYPES.join('/') + ') and when it names the xAxis dimension',
5457
),
5558
}));
5659

@@ -140,6 +143,65 @@ function checkReportOrder(
140143
}
141144
}
142145

146+
/**
147+
* Refuse a `chart.seriesBy` split that cannot split — on a non-`joined`
148+
* report (a joined report's `chart` is refused whole, so this never reaches
149+
* one). Two arms, both refusals with no valid reading, at `chart.seriesBy`:
150+
*
151+
* 1. **Single-series arm** — `chart.type` is in `SINGLE_SERIES_CHART_TYPES`
152+
* (imported from `dashboard.zod.ts`, never restated). The split derives one
153+
* series per value of the `seriesBy` dimension and these types draw one
154+
* series, so the renderer draws its one-series refusal panel where the
155+
* chart should be. The dashboard's spelling of the same split (a widget
156+
* with a dimension and several measures on these types) is refused by
157+
* `checkDashboardWidgetChartMeasureArity` — objectui#8894 ruling D; this
158+
* is the report's spelling of it.
159+
* 2. **Same-dimension arm** — `seriesBy` names the `xAxis` dimension. The
160+
* selection `[xAxis, seriesBy]` then groups by one dimension twice, so
161+
* every derived series holds exactly one point, and the chart reads as
162+
* broken with nothing saying why.
163+
*
164+
* The arms are exclusive: on a single-series type the first one answers,
165+
* since dropping `seriesBy` is the fix for both. Whether `seriesBy` names a
166+
* dimension the bound dataset DECLARES is not reachable from this schema; that
167+
* is `@objectstack/lint`'s `validateChartBindings`.
168+
*/
169+
function checkReportChartSeriesSplit(
170+
r: { name?: unknown; chart?: { type?: unknown; xAxis?: unknown; seriesBy?: unknown } },
171+
ctx: z.RefinementCtx,
172+
): void {
173+
const chart = r.chart;
174+
if (!chart || typeof chart.seriesBy !== 'string') return;
175+
const { type, xAxis, seriesBy } = chart;
176+
const report = typeof r.name === 'string' && r.name.length > 0 ? `Report \`${r.name}\`` : 'This report';
177+
178+
if (typeof type === 'string' && (SINGLE_SERIES_CHART_TYPES as readonly string[]).includes(type)) {
179+
const singleSeriesTypes = SINGLE_SERIES_CHART_TYPES.map((t) => '`' + t + '`').join(' / ');
180+
ctx.addIssue({
181+
code: 'custom',
182+
path: ['chart', 'seriesBy'],
183+
message:
184+
`${report} splits its \`type: '${type}'\` chart by \`seriesBy: '${seriesBy}'\`, but \`${type}\` is a `
185+
+ `single-series type (${singleSeriesTypes}) that draws ONE series, so the split into one series `
186+
+ `per \`${seriesBy}\` value has nowhere to go and the page shows a refusal instead of the chart. `
187+
+ `Write a bar-family type (\`bar\` / \`column\` / \`horizontal-bar\`) for one bar per \`${seriesBy}\` `
188+
+ `value in each \`xAxis\` category, or drop \`seriesBy\` to keep the \`${type}\`.`,
189+
});
190+
return;
191+
}
192+
193+
if (seriesBy === xAxis) {
194+
ctx.addIssue({
195+
code: 'custom',
196+
path: ['chart', 'seriesBy'],
197+
message:
198+
`${report} splits its chart by \`seriesBy: '${seriesBy}'\`, the same dimension as its \`xAxis\`, so `
199+
+ 'every series holds exactly one point. `seriesBy` must name a different dimension than `xAxis` '
200+
+ '— the one each `xAxis` category is split by — or be dropped for one series.',
201+
});
202+
}
203+
}
204+
143205
/**
144206
* Lower a report's authored `order` into the `DatasetSelection.order` a
145207
* preview/query request posts (framework#3916).
@@ -765,6 +827,7 @@ export const ReportSchema = lazySchema(() => strictObject({
765827
}
766828
} else {
767829
checkReportOrder(r, ctx);
830+
checkReportChartSeriesSplit(r, ctx);
768831
}
769832
}));
770833

0 commit comments

Comments
 (0)