Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .changeset/21293-single-series-multi-measure-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
'@objectstack/spec': minor
---

feat(spec)!: a `pie` / `donut` / `funnel` / `treemap` / `sankey` dashboard widget takes ONE measure with a dimension too — two or more are refused at `values`, and the check export is renamed `checkDashboardWidgetChartMeasureArity` (#21293; extends #20958)

Clause-②: yes (narrowing) — the accept set NARROWS (that is the change), and the published surface swaps one export for another: `checkDashboardWidgetDimensionlessMeasureArity` is removed and `checkDashboardWidgetChartMeasureArity` is added in its place, the same check with a second arm.

<!-- adr-0087: registered dashboard-widget-single-series-multi-measure-refused -->

**BREAKING** accept-set narrowing at `dashboard.widgets[].values`, plus one renamed
export, shipped as `minor` under this repo's launch-window convention for breaking
changes (`check-changeset-no-major` refuses `major` while the window is open, so
breaking-ness is carried by this banner and by the ADR-0087 disposition above,
never by the bump level). The prescription is registered under protocol major 18
as `dashboard-widget-single-series-multi-measure-refused`.

**What was wrong.** The previous release refused two or more measures on a
dimensionless `pie` / `donut` / `funnel` / `scatter` / `radar` / `treemap` /
`sankey`, and stepped aside for any widget that declared a dimension. Five of those
types draw ONE series whatever the dimension: objectui's chart renderer binds the
first series on its `pie` / `donut`, `funnel`, `treemap` and `sankey` arms and reads
no other, so `{ type: 'pie', dimensions: ['stage'], values: ['revenue', 'cost'] }`
drew one slice per stage for `revenue` and no trace of `cost`. Measured on this tree
before the change: that body parsed through `DashboardWidgetSchema` on all five
types (and on `scatter` / `radar` / `bar` / `table`), while `bogusProp` on the same
widget was refused by name, the lit control. After it, the five are refused at
`widgets[N].values`; `scatter` and `radar` with a dimension are outside the ruling
and parse as before.

### Write instead

| wrote | write instead |
|---|---|
| `{ id: 'mix', type: 'pie', dataset: 'sales', dimensions: ['stage'], values: ['revenue', 'cost'] }` | `{ id: 'mix', type: 'table', dataset: 'sales', dimensions: ['stage'], values: ['revenue', 'cost'] }` — a column per measure |
| the same, wanting a chart | `type: 'bar'` (or `column` / `horizontal-bar`) — one bar per measure in each stage |
| the same, wanting the pie | `{ id: 'mix', type: 'pie', …, values: ['revenue'] }` **and** `{ id: 'mix_cost', type: 'pie', …, values: ['cost'] }` — one widget per measure, each with its own `id` (and `layout`, if you pin positions) |
| `import { checkDashboardWidgetDimensionlessMeasureArity } from '@objectstack/spec/ui'` | `import { checkDashboardWidgetChartMeasureArity } from '@objectstack/spec/ui'` — same `(widget, ctx)` signature; chain it where the old name was chained |

No conversion does this for you: whether a two-measure pie by stage meant a table, a
grouped bar chart or two pies is an authoring choice. The refusal is ONE `custom`
issue at `widgets[N].values` naming the widget's `id`, the number of measures and
the authored `type`, and saying that type draws one series whatever its
`dimensions`.

**Why the export is renamed.** The dimensionless rule's check now has a second arm
that judges widgets WITH a dimension, so its old name described a boundary that no
longer exists. It refuses everything the old name refused, word for word on a
dimensionless widget. No first-party consumer chained the old name: objectui's
`DashboardWidgetSchema` mirror chains `checkDashboardWidgetStageOrder` and
`checkDashboardWidgetMetricMeasureArity` only, measured at the pinned objectui
commit and on objectui's `main`.

**Nothing else moves.** One measure parses on every type; `scatter` and `radar`
keep accepting several measures with a dimension; every type in
`DASHBOARD_WIDGET_MULTI_MEASURE_TYPES` keeps accepting any number of measures with
or without a dimension; a dimensionless widget of the five keeps the dimensionless
refusal, word for word and still ONE issue; the metric family's refusal is
unchanged; an empty `values` keeps its `too_small`; a `type` outside
`ChartTypeSchema` reports the type refusal alone. Census at the branch point
(`4b20c8474`), every tracked `.ts` / `.tsx` / `.js` / `.mjs` / `.cjs` / `.json` /
`.md` / `.mdx` / `.yml`: 496 literals carry `values: [...]`, 33 of them on one of
the seven types, and the only dimensioned multi-measure one on the five is a spec
test fixture that pinned the old acceptance (moved to the refusal in this change).
The same scan over objectui at its pinned commit (`89cad75d5`) finds no authored
widget of that shape — its one hit is the prose example in a changeset.
4 changes: 2 additions & 2 deletions content/docs/references/ui/dashboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ const result = DashboardSchema.parse(data);
| **compareTo** | `{ kind: Enum<'previousPeriod' \| 'previousYear'>; dimension?: string }` | optional | Period-over-period comparison window (`{ kind, dimension? }`) |
| **dataset** | `string` | ✅ | Dataset name to bind (ADR-0021) |
| **dimensions** | `string[]` | optional | Dimension names — X/group/split |
| **values** | `string[]` | ✅ | Measure names — Y (at least one; exactly one on the metric/kpi/gauge/solid-gauge/bullet family; with no dimensions, two or more only on table/pivot/bar/column/horizontal-bar/line/area/combo) |
| **values** | `string[]` | ✅ | Measure names — Y (at least one; exactly one on the metric/kpi/gauge/solid-gauge/bullet family and on the single-series pie/donut/funnel/treemap/sankey; with no dimensions, two or more only on table/pivot/bar/column/horizontal-bar/line/area/combo) |
| **layout** | `{ x: number; y: number; w: number; h: number }` | optional | Grid layout position (auto-flowed when omitted) |
| **options** | `{ dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; sortBy?: string; sortOrder?: Enum<'asc' \| 'desc'>; limit?: integer; … } & Record<string, any>` | optional | Widget specific configuration |
| **filterBindings** | `Record<string, string \| false>` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out |
Expand Down Expand Up @@ -182,7 +182,7 @@ Dashboard header action
| **compareTo** | `{ kind: Enum<'previousPeriod' \| 'previousYear'>; dimension?: string }` | optional | Period-over-period comparison window (`{ kind, dimension? }`) |
| **dataset** | `string` | ✅ | Dataset name to bind (ADR-0021) |
| **dimensions** | `string[]` | optional | Dimension names — X/group/split |
| **values** | `string[]` | ✅ | Measure names — Y (at least one; exactly one on the metric/kpi/gauge/solid-gauge/bullet family; with no dimensions, two or more only on table/pivot/bar/column/horizontal-bar/line/area/combo) |
| **values** | `string[]` | ✅ | Measure names — Y (at least one; exactly one on the metric/kpi/gauge/solid-gauge/bullet family and on the single-series pie/donut/funnel/treemap/sankey; with no dimensions, two or more only on table/pivot/bar/column/horizontal-bar/line/area/combo) |
| **layout** | `{ x: number; y: number; w: number; h: number }` | optional | Grid layout position (auto-flowed when omitted) |
| **options** | `{ dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; sortBy?: string; sortOrder?: Enum<'asc' \| 'desc'>; limit?: integer; … } & Record<string, any>` | optional | Widget specific configuration |
| **filterBindings** | `Record<string, string \| false>` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out |
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/api-surface/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,7 @@
"chartAggregateCategoryKey (function)",
"chartAggregateResultKeys (function)",
"chartAggregateValueKey (function)",
"checkDashboardWidgetDimensionlessMeasureArity (function)",
"checkDashboardWidgetChartMeasureArity (function)",
"checkDashboardWidgetMetricMeasureArity (function)",
"checkDashboardWidgetStageOrder (function)",
"checkGlobalFilterDateDefaultValue (function)",
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/export-origins/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,7 @@
"chartAggregateCategoryKey": "src/ui/chart-aggregate.ts#chartAggregateCategoryKey (function)",
"chartAggregateResultKeys": "src/ui/chart-aggregate.ts#chartAggregateResultKeys (function)",
"chartAggregateValueKey": "src/ui/chart-aggregate.ts#chartAggregateValueKey (function)",
"checkDashboardWidgetDimensionlessMeasureArity": "src/ui/dashboard.zod.ts#checkDashboardWidgetDimensionlessMeasureArity (function)",
"checkDashboardWidgetChartMeasureArity": "src/ui/dashboard.zod.ts#checkDashboardWidgetChartMeasureArity (function)",
"checkDashboardWidgetMetricMeasureArity": "src/ui/dashboard.zod.ts#checkDashboardWidgetMetricMeasureArity (function)",
"checkDashboardWidgetStageOrder": "src/ui/dashboard.zod.ts#checkDashboardWidgetStageOrder (function)",
"checkGlobalFilterDateDefaultValue": "src/ui/dashboard.zod.ts#checkGlobalFilterDateDefaultValue (function)",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,16 @@ export const entry: SemanticMigration = {
+ '`id`, the number of measures and the authored `type`, when `dimensions` is absent or an empty '
+ 'array, `values` carries two or more measures, and `type` is `pie`, `donut`, `funnel`, '
+ '`scatter`, `radar`, `treemap` or `sankey`. The check is the exported '
+ '`checkDashboardWidgetDimensionlessMeasureArity`, and the set it reads is the exported '
+ '`checkDashboardWidgetChartMeasureArity` (exported as '
+ '`checkDashboardWidgetDimensionlessMeasureArity` until '
+ '`dashboard-widget-single-series-multi-measure-refused` gave it a second arm and renamed it), and '
+ 'the set it reads is the exported '
+ '`DASHBOARD_WIDGET_MULTI_MEASURE_TYPES` — one list, which the check, the refusal text and the '
+ '`values` doc string all read. '
+ 'WHAT IS NOT, so this is not read as complete: the same seven types WITH a dimension keep '
+ 'accepting several measures exactly as before (whether that shape renders them all is a '
+ 'separate question this entry does not answer); one measure parses on every type; every type in '
+ 'WHAT IS NOT, so this is not read as complete: the same seven types WITH a dimension are '
+ 'outside THIS entry — `scatter` and `radar` keep accepting several measures with a dimension, and '
+ '`pie` / `donut` / `funnel` / `treemap` / `sankey` are refused with a dimension too, by '
+ '`dashboard-widget-single-series-multi-measure-refused`; one measure parses on every type; every type in '
+ '`DASHBOARD_WIDGET_MULTI_MEASURE_TYPES` keeps accepting any number of measures with no '
+ 'dimension; the metric family (`metric` / `kpi` / `gauge` / `solid-gauge` / `bullet`, and a '
+ 'widget that declares no `type`, which resolves to `metric`) keeps its OWN refusal, unchanged '
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

export const entry: SemanticMigration = {
id: 'dashboard-widget-single-series-multi-measure-refused',
surface: 'dashboard widget measure arity WITH a dimension on a single-series chart type — '
+ '`dashboard.widgets[].values` (`DashboardWidgetSchema.values`) on a widget whose `dimensions` '
+ 'declares one or more dimensions and whose `type` is `pie`, `donut`, `funnel`, `treemap` or '
+ '`sankey`; and the check export `checkDashboardWidgetDimensionlessMeasureArity` (from '
+ '`@objectstack/spec/ui`), renamed `checkDashboardWidgetChartMeasureArity`',
replacement: 'Keep ONE measure on the widget, or pick a visual that renders several. With a '
+ 'dimension, `type: \'table\'` renders a column per measure and a bar-family type (`bar` / '
+ '`column` / `horizontal-bar`) renders one bar per measure in each category; both keep the '
+ 'unbounded `values` they have always had. Or keep the type and give each measure its OWN '
+ 'widget: a new `id`, the same `dataset` and `dimensions`, that one measure in `values`, and its '
+ 'own `layout` if the dashboard pins grid positions. ⛔ The migration does not do this for you '
+ 'and no conversion could: whether a two-measure pie by stage meant a table, a grouped bar chart '
+ 'or two pies is an authoring choice, and N widgets need N ids and N boxes on a 12-column grid, '
+ 'which is a LAYOUT decision about a dashboard the registry has never seen. A mirror that '
+ 'chained the old check export by name imports `checkDashboardWidgetChartMeasureArity` instead: '
+ 'same signature, same attachment point, and it refuses everything the old name refused.',
reason:
'Triage\'s ruling on objectui\'s finding that a dimensioned pie draws only its first measure: '
+ 'the spec refuses, the renderer does not invent. It extends '
+ '`dashboard-widget-dimensionless-multi-measure-refused`, which applied maintainer ruling D '
+ '(「协议不正确的应该先修改协议」) to a widget with NO dimension, to the dimensioned arm for '
+ 'the five types that draw one series whatever the dimension. Measured in objectui\'s shared '
+ 'chart renderer: the `pie` / `donut`, `funnel`, `treemap` and `sankey` arms each bind the '
+ 'first series and read no other, so `{ type: \'pie\', dimensions: [\'stage\'], values: '
+ '[\'revenue\', \'cost\'] }` drew one slice per stage for `revenue` and no trace of `cost` — the '
+ 'dataset query selects and computes every measure, and all but the first are thrown away. '
+ 'Every door accepted the document, because the dimensionless rule stepped aside for any '
+ 'widget that declared a dimension. That is the declared≠delivered shape ADR-0049 exists to '
+ 'end. A pie of several measures has zero measured pull, so no rendering is invented for it. '
+ 'The census before the change found zero authored dimensioned multi-measure widgets of the five '
+ 'types in the platform\'s examples, its first-party dashboards or objectui\'s example apps, so '
+ 'this ships at once with no deprecation window. Relaxing later is free and needs no second '
+ 'migration — a type whose renderer gains a declared rendering for several measures with a '
+ 'dimension leaves the single-series set — while leaving the shape accepted costs an author a '
+ 'widget that silently drops what they declared. The check export is renamed in the same change '
+ 'because its old name said a widget with a dimension was outside it, which stopped being true; '
+ 'no first-party consumer chained it under that name.',
acceptanceCriteria:
'⚠️ WHICH DOOR: the refusal is the spec\'s, attached at the same point as the dimensionless rule, '
+ 'so it reaches every door that parses the spec schema — measured on `defineStack`, which throws '
+ 'naming the widget, and on the stack schema, the dashboard schema and the `dashboard` '
+ 'metadata-type schema, each refusing at `widgets[N].values`. It is NOT refused by objectui\'s '
+ 'client-side authoring door until that door chains the export: `@object-ui/types` builds its '
+ '`DashboardWidgetSchema` from a `.shape` spread of the spec\'s, which carries the FIELDS and '
+ 'drops every object-level check, so its editor keeps accepting a dimensioned two-measure `pie` '
+ 'and the author meets the refusal at publish. ⇒ Do not read a green editor as a clean '
+ 'dashboard; re-parse through the spec. '
+ '⚠️ AND THE TODO CANNOT NAME YOUR MEASURES: a `SemanticMigration` is static prose emitted once '
+ 'per hop, with no per-document interpolation and no filtering by whether the stack carries the '
+ 'shape, so `os migrate meta` prints THIS paragraph, not a list of your widgets. The refusal is '
+ 'what names them, per widget, on the re-parse — drive the fix off `os validate`, not off the '
+ 'migrate output. '
+ 'WHAT IS REFUSED, exactly: ONE `custom` issue at `widgets[N].values`, naming the widget\'s '
+ '`id`, the number of measures and the authored `type`, and saying the type draws one series '
+ 'whatever its `dimensions`, when `dimensions` declares at least one dimension, `values` carries '
+ 'two or more measures, and `type` is `pie`, `donut`, `funnel`, `treemap` or `sankey`. The check '
+ 'is the exported `checkDashboardWidgetChartMeasureArity` — the dimensionless rule\'s own check, '
+ 'with a second arm; the single-series set it reads is not exported, and the refusal prints it. '
+ 'WHAT IS NOT, so this is not read as complete: `scatter` and `radar` WITH a dimension keep '
+ 'accepting several measures exactly as before (the ruling named the five: `radar` draws every '
+ 'series, and `scatter` says on the chart that it draws one); every type in '
+ '`DASHBOARD_WIDGET_MULTI_MEASURE_TYPES` keeps accepting any number of measures with a dimension; '
+ 'one measure parses on every type; a DIMENSIONLESS widget of the five keeps the dimensionless '
+ 'refusal, word for word and still ONE issue; the metric family (`metric` / `kpi` / `gauge` / '
+ '`solid-gauge` / `bullet`, and a widget that declares no `type`, which resolves to `metric`) '
+ 'keeps its OWN refusal, unchanged; an EMPTY `values` keeps the field\'s own `too_small`; a '
+ '`type` outside `ChartTypeSchema` reports the TYPE refusal alone, and called directly on a '
+ 'wider type enum the export judges only the types the spec declares; and whether each measure '
+ 'EXISTS in the bound dataset is still unreachable from this schema. '
+ 'VERIFY by re-parsing each dashboard: a dashboard that had one two-measure `pie` by stage should '
+ 'end with a `table` or bar-family widget carrying both measures, or with two widgets of one '
+ 'measure each — check the rendered grid afterwards, because the second measure is a number the '
+ 'dashboard was ALREADY paying to compute and had never shown.',
};
Loading
Loading