From c1d4f3dd31d37667cd3fbc33a6ee8f027d27250e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 13:07:20 +0000 Subject: [PATCH 1/2] feat(spec)!: a single-series dashboard widget takes one measure with a dimension too (WIP) Extends the dimensionless measure-arity rule to the dimensioned arm for pie / donut / funnel / treemap / sankey, and renames the check export to checkDashboardWidgetChartMeasureArity. Generated artefacts follow. Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude --- ...293-single-series-multi-measure-refused.md | 66 ++++++ ...get-dimensionless-multi-measure-refused.ts | 12 +- ...get-single-series-multi-measure-refused.ts | 80 ++++++++ packages/spec/src/ui/dashboard.test.ts | 191 ++++++++++++++++-- packages/spec/src/ui/dashboard.zod.ts | 177 ++++++++++++---- .../object-refinement-check-exports.test.ts | 49 +++-- 6 files changed, 497 insertions(+), 78 deletions(-) create mode 100644 .changeset/21293-single-series-multi-measure-refused.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.dashboard-widget-single-series-multi-measure-refused.ts diff --git a/.changeset/21293-single-series-multi-measure-refused.md b/.changeset/21293-single-series-multi-measure-refused.md new file mode 100644 index 00000000000..4a151c41bef --- /dev/null +++ b/.changeset/21293-single-series-multi-measure-refused.md @@ -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. + + + +**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. diff --git a/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-dimensionless-multi-measure-refused.ts b/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-dimensionless-multi-measure-refused.ts index 26fd59cb114..bf1b5f3cdbc 100644 --- a/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-dimensionless-multi-measure-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-dimensionless-multi-measure-refused.ts @@ -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 ' diff --git a/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-single-series-multi-measure-refused.ts b/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-single-series-multi-measure-refused.ts new file mode 100644 index 00000000000..d7899d1f085 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-single-series-multi-measure-refused.ts @@ -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.', +}; diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts index e369c844126..961a306ea79 100644 --- a/packages/spec/src/ui/dashboard.test.ts +++ b/packages/spec/src/ui/dashboard.test.ts @@ -17,7 +17,7 @@ import { DashboardWidgetOptionsSchema, checkDashboardWidgetStageOrder, checkDashboardWidgetMetricMeasureArity, - checkDashboardWidgetDimensionlessMeasureArity, + checkDashboardWidgetChartMeasureArity, DASHBOARD_WIDGET_MULTI_MEASURE_TYPES, } from './dashboard.zod'; import * as ui from './index'; @@ -1159,19 +1159,34 @@ describe('[#17779] DashboardWidgetSchema — the metric family takes exactly one expect(issue.message).toContain('declares no `type` at all'); }); - it.each(['bar', 'horizontal-bar', 'column', 'line', 'area', 'pie', 'donut', 'funnel', - 'scatter', 'treemap', 'sankey', 'combo', 'radar', 'table', 'pivot'] as const)( - 'leaves `%s` — every NON-metric type — accepting three measures, unmoved', + const OTHERS = ['bar', 'horizontal-bar', 'column', 'line', 'area', 'pie', 'donut', 'funnel', + 'scatter', 'treemap', 'sankey', 'combo', 'radar', 'table', 'pivot'] as const; + // [#21293] `WIDGET_BASE` carries a dimension, and with one the single-series + // types are refused at two or more measures — by the SIBLING chart check, not + // by this one. So this block's own claim, "the metric check leaves every + // non-metric type alone", is read off the metric export directly, and the + // parse leg keeps only the types no check refuses here. + const SINGLE_SERIES: readonly string[] = ['pie', 'donut', 'funnel', 'treemap', 'sankey']; + + it.each(OTHERS)('the metric check leaves `%s` — every NON-metric type — silent at three measures', (type) => { + const out: unknown[] = []; + checkDashboardWidgetMetricMeasureArity( + widget({ type, values: ['a', 'b', 'c'] }) as never, + { addIssue: (i: unknown) => out.push(i) } as unknown as z.RefinementCtx, + ); + expect(out).toHaveLength(0); + }); + + it.each(OTHERS.filter((t) => !SINGLE_SERIES.includes(t)))( + 'leaves `%s` — a non-metric, non-single-series type — accepting three measures with a dimension, unmoved', (type) => { expect(DashboardWidgetSchema.safeParse(widget({ type, values: ['a', 'b', 'c'] })).success).toBe(true); }, ); it('covers the whole taxonomy — the metric family plus the others IS `ChartTypeSchema`', () => { - // Guards the two `it.each` lists above against a new chart type landing in - // the enum and being covered by neither. - const OTHERS = ['bar', 'horizontal-bar', 'column', 'line', 'area', 'pie', 'donut', 'funnel', - 'scatter', 'treemap', 'sankey', 'combo', 'radar', 'table', 'pivot']; + // Guards the `it.each` lists above against a new chart type landing in the + // enum and being covered by neither. expect([...FAMILY, ...OTHERS].sort()).toEqual([...ChartTypeSchema.options].sort()); }); @@ -1293,7 +1308,10 @@ describe('[#20958] DashboardWidgetSchema — a dimensionless widget takes severa expect(issue.path.join('.')).toBe('values'); }); - it.each(SEVEN)('accepts `%s` with ONE dimension and two measures — the rule is about the dimensionless shape', (type) => { + // [#21293] With a dimension, five of the seven are the single-series arm's to + // refuse (its own block below); this arm's dimensioned control is the two the + // ruling left out. + it.each(['scatter', 'radar'] as const)('accepts `%s` with ONE dimension and two measures — the dimensionless arm is about the dimensionless shape', (type) => { expect(DashboardWidgetSchema.safeParse(widget({ type, dimensions: ['stage'], values: ['amount_sum', 'count'] })).success) .toBe(true); }); @@ -1356,7 +1374,7 @@ describe('[#20958] DashboardWidgetSchema — a dimensionless widget takes severa // silent on them, and on a typeless widget (the metric default's case). const collect = (value: Record) => { const out: unknown[] = []; - checkDashboardWidgetDimensionlessMeasureArity( + checkDashboardWidgetChartMeasureArity( value as never, { addIssue: (i: unknown) => out.push(i) } as unknown as z.RefinementCtx, ); @@ -1370,18 +1388,24 @@ describe('[#20958] DashboardWidgetSchema — a dimensionless widget takes severa it('the rule the door runs is the EXPORT, attached by identifier — no inline copy', () => { const src = readFileSync(new URL('./dashboard.zod.ts', import.meta.url), 'utf8'); - expect(src).toContain('export function checkDashboardWidgetDimensionlessMeasureArity('); - expect(src.match(/^\s*(export )?function checkDashboardWidgetDimensionlessMeasureArity\b/gm)).toHaveLength(1); - expect(src.match(/^[ \t]*\.superRefine\(checkDashboardWidgetDimensionlessMeasureArity\)/gm)).toHaveLength(1); + expect(src).toContain('export function checkDashboardWidgetChartMeasureArity('); + expect(src.match(/^\s*(export )?function checkDashboardWidgetChartMeasureArity\b/gm)).toHaveLength(1); + expect(src.match(/^[ \t]*\.superRefine\(checkDashboardWidgetChartMeasureArity\)/gm)).toHaveLength(1); // ONE list: the constant is declared once and no second literal of the set // sits beside it. expect(src.match(/^export const DASHBOARD_WIDGET_MULTI_MEASURE_TYPES\b/gm)).toHaveLength(1); }); it('`@objectstack/spec/ui` ships the same function object', () => { - expect((ui as Record).checkDashboardWidgetDimensionlessMeasureArity) - .toBe(checkDashboardWidgetDimensionlessMeasureArity); - expect(checkDashboardWidgetDimensionlessMeasureArity.length).toBe(2); + expect((ui as Record).checkDashboardWidgetChartMeasureArity) + .toBe(checkDashboardWidgetChartMeasureArity); + expect(checkDashboardWidgetChartMeasureArity.length).toBe(2); + }); + + it('[#21293] no longer exports `checkDashboardWidgetDimensionlessMeasureArity` — renamed with its second arm', () => { + // The surviving export above is the lit control that the namespace is + // really populated. + expect('checkDashboardWidgetDimensionlessMeasureArity' in (ui as Record)).toBe(false); }); it('the gate travels with the widget through `DashboardSchema.widgets[]`', () => { @@ -1402,3 +1426,138 @@ describe('[#20958] DashboardWidgetSchema — a dimensionless widget takes severa expect(described).toContain(DASHBOARD_WIDGET_MULTI_MEASURE_TYPES.join('/')); }); }); + +/** + * [#21293] The single-series arm — `pie` / `donut` / `funnel` / `treemap` / + * `sankey` take ONE measure WITH a dimension too (triage's ruling on + * objectui#11417, extending #20958's rule to the dimensioned arm). + * + * Before this, `{ type: 'pie', dimensions: ['stage'], values: ['revenue', 'cost'] }` + * parsed at every door, and objectui's chart renderer drew `revenue` alone: it + * binds `series[0]` on those five arms. Same check as the dimensionless arm + * (`checkDashboardWidgetChartMeasureArity`), same `custom` issue at `values`; + * `scatter` and `radar` with a dimension are outside the ruling. + */ +describe('[#21293] DashboardWidgetSchema — a single-series type takes one measure WITH a dimension too', () => { + // `WIDGET_BASE` carries `dimensions: ['status']` — the variable this block + // holds fixed. + const widget = (over: Record) => ({ ...WIDGET_BASE, ...over }); + const issuesOf = (value: Record) => { + const r = DashboardWidgetSchema.safeParse(value); + return r.success ? [] : r.error.issues; + }; + const refusal = (value: Record) => { + const issues = issuesOf(value); + expect(issues).toHaveLength(1); + return issues[0]!; + }; + const collect = (value: Record) => { + const out: Array<{ message?: string; path?: unknown }> = []; + checkDashboardWidgetChartMeasureArity( + value as never, + { addIssue: (i: { message?: string; path?: unknown }) => out.push(i) } as unknown as z.RefinementCtx, + ); + return out; + }; + + // The five, read off the refusal rather than off the module-private constant: + // the message interpolates the authored type, so a member silently admitted + // fails HERE rather than in a list that agrees with itself. + const FIVE = ['pie', 'donut', 'funnel', 'treemap', 'sankey'] as const; + const FAMILY = ['metric', 'kpi', 'gauge', 'solid-gauge', 'bullet'] as const; + + it.each(FIVE)('refuses two measures on `%s` WITH a dimension, at `values`', (type) => { + const issue = refusal(widget({ type, values: ['amount_sum', 'count'] })); + expect(issue.code).toBe('custom'); + expect(issue.path.join('.')).toBe('values'); + expect(issue.message).toContain(`\`type: '${type}'\``); + expect(issue.message).toContain('single-series'); + }); + + it.each(FIVE)('control: accepts ONE measure on `%s` with a dimension', (type) => { + expect(DashboardWidgetSchema.safeParse(widget({ type, values: ['amount_sum'] })).success).toBe(true); + }); + + it.each(FIVE)('refuses `%s` with TWO dimensions as well — whatever the dimension', (type) => { + const issue = refusal(widget({ type, dimensions: ['status', 'owner'], values: ['amount_sum', 'count', 'avg_days'] })); + expect(issue.path.join('.')).toBe('values'); + expect(issue.message).toContain('declares 3 measures'); + }); + + it.each(['scatter', 'radar'] as const)('outside the ruling: `%s` with a dimension keeps accepting two measures', (type) => { + expect(DashboardWidgetSchema.safeParse(widget({ type, values: ['amount_sum', 'count'] })).success).toBe(true); + }); + + it.each([...DASHBOARD_WIDGET_MULTI_MEASURE_TYPES])( + 'control: `%s` — a multi-measure type — keeps accepting three measures with a dimension', + (type) => { + expect(DashboardWidgetSchema.safeParse(widget({ type, values: ['a', 'b', 'c'] })).success).toBe(true); + }, + ); + + it('with a dimension, the types refused at two measures are EXACTLY the metric family and the five', () => { + // The whole taxonomy, read off the door: a new chart type, or a member + // silently moved in or out of the single-series set, turns this red. + const refused = ChartTypeSchema.options.filter( + (type) => !DashboardWidgetSchema.safeParse(widget({ type, values: ['a', 'b'] })).success, + ); + expect([...refused].sort()).toEqual([...FAMILY, ...FIVE].sort()); + }); + + it('names the widget, the count, the type and the five, and steers to `table`, a bar-family type, or one widget per measure', () => { + const issue = refusal(widget({ id: 'revenue_by_stage', type: 'funnel', values: ['revenue', 'cost', 'margin'] })); + expect(issue.message).toContain('`revenue_by_stage`'); + expect(issue.message).toContain('declares 3 measures'); + expect(issue.message).toContain("`type: 'funnel'`"); + expect(issue.message).toContain('whatever its `dimensions`'); + expect(issue.message).toContain("`type: 'table'`"); + expect(issue.message).toContain('bar-family'); + expect(issue.message).toContain('its own widget'); + for (const t of FIVE) expect(issue.message).toContain('`' + t + '`'); + }); + + it.each(FIVE)('a DIMENSIONLESS `%s` keeps the dimensionless refusal word for word — one issue, not two', (type) => { + const value = { id: 'pipeline_mix', dataset: 'contracts', type, values: ['amount_sum', 'count'] }; + const issue = refusal(value); + expect(issue.message).toContain('with no `dimensions`'); + expect(issue.message).not.toContain('single-series'); + // The very message the export produces on the same body — the parse leg and + // the direct call agree on the arm. + const direct = collect(value); + expect(direct).toHaveLength(1); + expect(issue.message).toBe(direct[0]!.message); + }); + + it.each(FAMILY)('the metric family keeps its OWN refusal with a dimension — one issue, the metric check\'s', (type) => { + const value = widget({ type, values: ['a', 'b'] }); + const issue = refusal(value); + expect(issue.message).not.toContain('single-series'); + expect(collect(value)).toHaveLength(0); + }); + + it('the export, called directly, refuses a dimensioned `pie` and judges only the types the spec declares', () => { + expect(collect({ id: 'w', type: 'pie', dimensions: ['stage'], values: ['a', 'b'] })).toHaveLength(1); + // A mirror's wider enum (objectui's `list` / `custom` / component types) is + // not this export's to judge, with a dimension as without one. + expect(collect({ id: 'w', type: 'custom', dimensions: ['stage'], values: ['a', 'b'] })).toHaveLength(0); + expect(collect({ id: 'w', type: 'scatter', dimensions: ['stage'], values: ['a', 'b'] })).toHaveLength(0); + }); + + it('the gate travels with the widget through `DashboardSchema.widgets[]`', () => { + const r = DashboardSchema.safeParse({ + name: 'sales_dashboard', + label: 'Sales', + widgets: [widget({ type: 'sankey', values: ['a', 'b'] })], + }); + expect(r.success).toBe(false); + const paths = (r.success ? [] : r.error.issues).map((i) => i.path.join('.')); + expect(paths).toContain('widgets.0.values'); + }); + + it('the shipped `values` doc string states the single-series rule', () => { + const described = (DashboardWidgetSchema as unknown as { shape: Record }) + .shape.values.description ?? ''; + expect(described).toContain('single-series'); + expect(described).toContain(FIVE.join('/')); + }); +}); diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index d9374312f3a..9a8c2284f0c 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -710,7 +710,7 @@ export function checkDashboardWidgetMetricMeasureArity( /** * The widget `type`s that DECLARE a rendering for several measures on a widget * with NO dimension — the multi-measure set. One exported constant, and the only - * list of it anywhere: {@link checkDashboardWidgetDimensionlessMeasureArity} + * list of it anywhere: {@link checkDashboardWidgetChartMeasureArity} * reads it, that check's refusal text reads it, the `values` doc string reads it, * and objectui's `.shape` mirror is to import it rather than restate it. * @@ -730,8 +730,10 @@ export function checkDashboardWidgetMetricMeasureArity( * {@link checkDashboardWidgetMetricMeasureArity}, which refuses a second measure * at ANY dimensionality, and the remaining seven — `pie`, `donut`, `funnel`, * `scatter`, `radar`, `treemap`, `sankey`, named here as a reading, never as a - * list any code consults — by the dimensionless check. Those seven, with nothing - * to split by, drew `values[0]` and dropped the rest. + * list any code consults — by the chart check's dimensionless arm. Those seven, + * with nothing to split by, drew `values[0]` and dropped the rest. Five of them + * also draw one series WITH a dimension, and that check refuses those at any + * dimensionality ({@link SINGLE_SERIES_CHART_TYPES}). * * Widening is free and is the direction this constant moves: a type that gains a * DECLARED multi-measure rendering (a radar of measures, a funnel of measure @@ -754,14 +756,59 @@ export const DASHBOARD_WIDGET_MULTI_MEASURE_TYPES = [ ] as const satisfies readonly ChartType[]; /** - * objectui#8894 ruling D, applied to the chart families — a widget with NO - * dimension declares two or more measures only on a type that renders them. + * The chart `type`s that draw ONE series whatever the widget's `dimensions` — + * the single-series set. Module-private, like the metric family's list above, + * and the only list of it in this package: + * {@link checkDashboardWidgetChartMeasureArity} reads it, that check's refusal + * text reads it, and the `values` doc string reads it. + * + * Measured in objectui's shared chart renderer (`AdvancedChartImpl` in + * `@object-ui/plugin-charts`, at the `.objectui-sha` pin `89cad75d5570`): the + * `pie` / `donut`, `funnel`, `treemap` and `sankey` arms each bind + * `series[0]?.dataKey` and read no other series, so a widget of one of these + * types WITH a dimension draws its first measure per bucket and drops every + * other one, although the dataset widget hands each measure on as a series. The + * other two types the dimensionless arm refuses sit outside it: `radar` draws + * every series, and `scatter` refuses a second series out loud, on the chart. + * + * Every member is outside {@link DASHBOARD_WIDGET_MULTI_MEASURE_TYPES} and the + * metric family, so on a DIMENSIONLESS widget the dimensionless arm already + * answers for it; this set decides only the widget that declares a dimension. + * + * Shrinking is free and is the direction this constant moves, the mirror of the + * multi-measure set's widening: a type whose renderer gains a declared rendering + * for several measures WITH a dimension leaves here with no migration. ⛔ Never + * remove a member to make a document parse — only when a renderer draws every + * measure. + */ +const SINGLE_SERIES_CHART_TYPES = [ + 'pie', + 'donut', + 'funnel', + 'treemap', + 'sankey', +] as const satisfies readonly ChartType[]; + +/** + * objectui#8894 ruling D, applied to the chart families — a chart widget + * declares two or more measures only on a type that renders them. One check, + * two arms: + * + * - the DIMENSIONLESS arm: with no dimension, several measures only on a type + * in {@link DASHBOARD_WIDGET_MULTI_MEASURE_TYPES}; + * - the SINGLE-SERIES arm: on a type in {@link SINGLE_SERIES_CHART_TYPES} + * (`pie` / `donut` / `funnel` / `treemap` / `sankey`), one measure whatever + * the dimension. + * + * It was exported as `checkDashboardWidgetDimensionlessMeasureArity` until the + * second arm landed. That name said a widget WITH a dimension was outside the + * check, which stopped being true, and a name is the first thing a reader — or + * a mirror deciding which exports to chain — reasons from. * * ## What was wrong * * `values` is `z.array(z.string()).min(1)`, and outside the metric family it has - * no upper bound at all. With a dimension, the several measures of a `pie` have a - * category axis to sit against; with NONE, `pie`, `donut`, `funnel`, `scatter`, + * no upper bound at all. With NO dimension, `pie`, `donut`, `funnel`, `scatter`, * `radar`, `treemap` and `sankey` have one mark to draw and drew `values[0]`: the * rest were selected, queried, and dropped on the floor by the renderer. Every * door accepted the document. That is the declared≠delivered shape ADR-0049 @@ -769,24 +816,34 @@ export const DASHBOARD_WIDGET_MULTI_MEASURE_TYPES = [ * the protocol is fixed where it admits measures a widget type cannot render, * rather than a display semantics being invented for `values[1..]`. * + * A dimension does not rescue five of them. A `pie` with `dimensions: ['stage']` + * and `values: ['revenue', 'cost']` drew one slice per stage for `revenue` and no + * trace of `cost` (objectui#11417's rendered probe), because the renderer binds + * the first series on those arms ({@link SINGLE_SERIES_CHART_TYPES} carries the + * reading). Triage's ruling there extended this rule to the dimensioned arm for + * those five — the spec refuses, the renderer does not invent: a pie of several + * measures has zero measured pull. + * * ## The rule, exactly * - * Refused when ALL of these hold: - * - * 1. `dimensions` is absent or empty; - * 2. `values` carries two or more measures; - * 3. `type` is a declared `ChartTypeSchema` member outside - * {@link DASHBOARD_WIDGET_MULTI_MEASURE_TYPES}; - * 4. and `type` is NOT a metric-family member — those are refused by - * {@link checkDashboardWidgetMetricMeasureArity} already, at any - * dimensionality, and a second issue on the same `values` would change that - * existing refusal from one issue to two. Conditions 3 + 4 together are "the - * rest of the taxonomy", computed from `ChartTypeSchema` and the two sets, so - * no third list exists to drift. - * - * One `custom` issue at `values`, naming the widget's `id`, the count and the - * type, and steering to the shapes that DO render several measures: `table` (a - * row of measures), a bar-family type (one bar per measure), or one widget per + * Both arms first require that `values` carries two or more measures and that + * `type` is a declared `ChartTypeSchema` member outside the metric family — that + * family is refused by {@link checkDashboardWidgetMetricMeasureArity} already, at + * any dimensionality, and a second issue on the same `values` would change that + * existing refusal from one issue to two. Then: + * + * 1. **Dimensionless arm** — `dimensions` is absent or empty, and `type` is + * outside {@link DASHBOARD_WIDGET_MULTI_MEASURE_TYPES}. "Outside the metric + * family and the multi-measure set" is computed from `ChartTypeSchema` and + * the two sets, so no third list exists to drift. + * 2. **Single-series arm** — `dimensions` is declared and non-empty, and `type` + * is in {@link SINGLE_SERIES_CHART_TYPES}. + * + * At most ONE `custom` issue at `values`, naming the widget's `id`, the count and + * the type. The arms are exclusive on `dimensions`, so a dimensionless `pie` + * keeps the dimensionless refusal word for word; the single-series refusal says + * the type draws one series whatever its `dimensions`. Both steer to the shapes + * that DO render several measures: `table`, a bar-family type, or one widget per * measure. * * Same spelling as the metric check and for the same measured reason: an @@ -796,9 +853,11 @@ export const DASHBOARD_WIDGET_MULTI_MEASURE_TYPES = [ * * ## What this check deliberately does NOT reach * - * 1. **A widget WITH a dimension.** A `pie` with `dimensions: ['stage']` and two - * measures parses exactly as before. Whether that shape renders both - * measures is a separate question this rule does not answer. + * 1. **`scatter` and `radar` WITH a dimension.** Two measures on either parse + * exactly as before — `radar` draws every series, `scatter` says out loud + * that it draws one, and the ruling named the five. Every member of + * {@link DASHBOARD_WIDGET_MULTI_MEASURE_TYPES} parses as before WITH a + * dimension too. * 2. **The EMPTY and the single `values`.** `values: []` keeps the field's own * `too_small`, and one measure is the legal document on every type. * 3. **A widget that declares no `type`.** It resolves to `metric`, a family @@ -813,10 +872,10 @@ export const DASHBOARD_WIDGET_MULTI_MEASURE_TYPES = [ * dataset, unreachable from this schema. * 6. **objectui's CLIENT-SIDE authoring door**, a `.shape` mirror that runs only * the checks it imports and chains: until it chains this export, its editor - * keeps accepting a dimensionless two-measure `pie` and the author meets the - * refusal at PUBLISH. + * keeps accepting a two-measure `pie`, with or without a dimension, and the + * author meets the refusal at PUBLISH. */ -export function checkDashboardWidgetDimensionlessMeasureArity( +export function checkDashboardWidgetChartMeasureArity( widget: { id?: unknown; type?: unknown; dimensions?: unknown; values?: unknown }, ctx: z.RefinementCtx, ): void { @@ -825,12 +884,6 @@ export function checkDashboardWidgetDimensionlessMeasureArity( // first two verdicts and one measure is legal on every type. Non-coverage 2. if (!Array.isArray(values) || values.length <= 1) return; - // A dimension gives the measures an axis to sit against — out of this rule. - // Anything but absent-or-empty returns: through this door a non-array - // `dimensions` is the field's own `invalid_type` and the check never runs. - const dimensions = widget.dimensions; - if (dimensions !== undefined && !(Array.isArray(dimensions) && dimensions.length === 0)) return; - // `?? WIDGET_TYPE_DEFAULT` is UNREACHABLE through this schema's own door — // zod applies `type`'s default before object-level checks. It is here for the // mirror whose `type` carries no default, so the export never judges a @@ -838,8 +891,7 @@ export function checkDashboardWidgetDimensionlessMeasureArity( // is a metric-family member, which returns just below. const type = widget.type ?? WIDGET_TYPE_DEFAULT; if (typeof type !== 'string') return; - if ((DASHBOARD_WIDGET_MULTI_MEASURE_TYPES as readonly string[]).includes(type)) return; - // The metric family is the sibling check's, at every dimensionality. Rule 4. + // The metric family is the sibling check's, at every dimensionality. if ((SINGLE_MEASURE_WIDGET_TYPES as readonly string[]).includes(type)) return; // A type the spec does not declare is not this export's to judge. Non-coverage 4. if (!(ChartTypeSchema.options as readonly string[]).includes(type)) return; @@ -847,6 +899,36 @@ export function checkDashboardWidgetDimensionlessMeasureArity( const widgetName = typeof widget.id === 'string' && widget.id.length > 0 ? '`' + widget.id + '`' : 'this widget'; + + // Arm 2, the single-series arm: a declared dimension. Through this door a + // non-array `dimensions` is the field's own `invalid_type` and the check never + // runs; called directly, anything but absent-or-empty counts as declared, since + // these types draw one series whatever the dimension is. + const dimensions = widget.dimensions; + if (dimensions !== undefined && !(Array.isArray(dimensions) && dimensions.length === 0)) { + if (!(SINGLE_SERIES_CHART_TYPES as readonly string[]).includes(type)) return; + const singleSeriesTypes = SINGLE_SERIES_CHART_TYPES.map((t) => '`' + t + '`').join(' / '); + ctx.addIssue({ + code: 'custom', + path: ['values'], + message: + 'Widget ' + widgetName + ' declares ' + values.length + ' measures on `type: ' + + `'${type}'` + + '`, a single-series type (' + singleSeriesTypes + ') that draws ONE series whatever its ' + + '`dimensions`: it binds `values[0]`, and every measure after it is queried and then ' + + 'dropped on the floor by the renderer. ' + + "Write `type: 'table'` for a column per measure, or a bar-family type (`bar` / `column` / " + + '`horizontal-bar`) for one bar per measure in each category — or keep `type: ' + + `'${type}'` + + '` and give each measure its own widget, with its own `id` (and `layout`, if you pin ' + + 'positions).', + }); + return; + } + + // Arm 1, the dimensionless arm: nothing to split by, so several measures only + // on a type that declares a rendering for them. + if ((DASHBOARD_WIDGET_MULTI_MEASURE_TYPES as readonly string[]).includes(type)) return; const multiMeasureTypes = DASHBOARD_WIDGET_MULTI_MEASURE_TYPES.map((t) => '`' + t + '`').join(' / '); ctx.addIssue({ @@ -1201,12 +1283,17 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({ * With NO `dimensions`, two or more only on a type in * {@link DASHBOARD_WIDGET_MULTI_MEASURE_TYPES} — the types that render several * measures with nothing to split by; every other chart type drew `values[0]` - * and dropped the rest ({@link checkDashboardWidgetDimensionlessMeasureArity}). + * and dropped the rest. And on a SINGLE-SERIES type — + * {@link SINGLE_SERIES_CHART_TYPES}: `pie` / `donut` / `funnel` / `treemap` / + * `sankey` — exactly one WITH a dimension too, because those draw one series + * whatever the dimension ({@link checkDashboardWidgetChartMeasureArity}). */ values: z.array(z.string()).min(1) .describe( - '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 ' + 'Measure names — Y (at least one; exactly one on the metric/kpi/gauge/solid-gauge/bullet family ' + + 'and on the single-series ' + + SINGLE_SERIES_CHART_TYPES.join('/') + + '; with no dimensions, two or more only on ' + DASHBOARD_WIDGET_MULTI_MEASURE_TYPES.join('/') + ')', ) @@ -1366,10 +1453,12 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({ // level up, so the rule has to run where both keys are in scope. .superRefine(checkDashboardWidgetMetricMeasureArity) // Ruling D's principle on the chart families — a dimensionless widget takes - // several measures only on a type in `DASHBOARD_WIDGET_MULTI_MEASURE_TYPES`. - // Three siblings decide it (`dimensions`, `values`, `type`), so it too runs - // at the object, attached by identifier. - .superRefine(checkDashboardWidgetDimensionlessMeasureArity)); + // several measures only on a type in `DASHBOARD_WIDGET_MULTI_MEASURE_TYPES`, + // and a single-series type (`pie` / `donut` / `funnel` / `treemap` / + // `sankey`) takes one whatever its dimension. Three siblings decide it + // (`dimensions`, `values`, `type`), so it too runs at the object, attached by + // identifier. + .superRefine(checkDashboardWidgetChartMeasureArity)); /** * Dashboard date-range presets — the named windows a dashboard date filter may diff --git a/packages/spec/src/ui/object-refinement-check-exports.test.ts b/packages/spec/src/ui/object-refinement-check-exports.test.ts index 94721e1c451..7ab1feef2c3 100644 --- a/packages/spec/src/ui/object-refinement-check-exports.test.ts +++ b/packages/spec/src/ui/object-refinement-check-exports.test.ts @@ -62,7 +62,7 @@ import { DashboardWidgetSchema, checkDashboardWidgetStageOrder, checkDashboardWidgetMetricMeasureArity, - checkDashboardWidgetDimensionlessMeasureArity, + checkDashboardWidgetChartMeasureArity, } from './dashboard.zod'; import * as ui from './index'; @@ -338,18 +338,21 @@ const metricMeasureArityFixtures: Fixture[] = [ /** * objectui#8894 ruling D's principle on the chart families — a widget with NO * dimension takes two or more measures only on a type in - * `DASHBOARD_WIDGET_MULTI_MEASURE_TYPES`. + * `DASHBOARD_WIDGET_MULTI_MEASURE_TYPES` (the dimensionless arm), and a + * single-series type — `pie` / `donut` / `funnel` / `treemap` / `sankey` — + * takes one WITH a dimension too (the single-series arm, #21293). * * Its own base, WITHOUT `dimensions`: `WIDGET` above carries one, and the - * dimension is the variable this check turns — so no stage-order or metric - * fixture reaches it, and no fixture here carries `options.stageOrder`, which - * keeps the three exports' vectors separable for leg 2's bijection. The two - * metric-family rows are ACCEPTING paths of THIS export: that refusal is the - * sibling's, and the parse leg reads it from the sibling's direct call. + * dimension is the variable this check turns. No stage-order or metric fixture + * reaches it (every one of those carries one measure, or a metric-family type), + * and no fixture here carries `options.stageOrder`, which keeps the three + * exports' vectors separable for leg 2's bijection. The two metric-family rows + * are ACCEPTING paths of THIS export: that refusal is the sibling's, and the + * parse leg reads it from the sibling's direct call. */ const DIMLESS_WIDGET = { id: 'stage_widget', dataset: 'contracts' } as const; -const dimensionlessMeasureArityFixtures: Fixture[] = [ +const chartMeasureArityFixtures: Fixture[] = [ ...(['pie', 'donut', 'funnel', 'scatter', 'radar', 'treemap', 'sankey'] as const).map((type) => ({ label: `two measures on a dimensionless \`${type}\``, value: { ...DIMLESS_WIDGET, type, values: ['amount_sum', 'count'] }, @@ -360,9 +363,27 @@ const dimensionlessMeasureArityFixtures: Fixture[] = [ value: { ...DIMLESS_WIDGET, type: 'donut', dimensions: [], values: ['amount_sum', 'count', 'avg_days'] }, refusesAt: ['values'], }, + // [#21293] This row read "out of this rule" and accepted until the + // single-series arm landed; it is that arm's subject now, and the two types + // the ruling left out are the accepting rows beside it. + ...(['pie', 'donut', 'funnel', 'treemap', 'sankey'] as const).map((type) => ({ + label: `two measures on a \`${type}\` WITH a dimension — the single-series arm`, + value: { ...DIMLESS_WIDGET, type, dimensions: ['status'], values: ['amount_sum', 'count'] }, + refusesAt: ['values'], + })), + { + label: 'two measures on a `scatter` WITH a dimension — outside the single-series arm', + value: { ...DIMLESS_WIDGET, type: 'scatter', dimensions: ['status'], values: ['amount_sum', 'count'] }, + refusesAt: [], + }, + { + label: 'two measures on a `radar` WITH a dimension — likewise', + value: { ...DIMLESS_WIDGET, type: 'radar', dimensions: ['status'], values: ['amount_sum', 'count'] }, + refusesAt: [], + }, { - label: 'two measures on a `pie` WITH a dimension — out of this rule', - value: { ...DIMLESS_WIDGET, type: 'pie', dimensions: ['status'], values: ['amount_sum', 'count'] }, + label: 'ONE measure on a `pie` WITH a dimension', + value: { ...DIMLESS_WIDGET, type: 'pie', dimensions: ['status'], values: ['amount_sum'] }, refusesAt: [], }, { @@ -440,9 +461,9 @@ const MIRRORED: MirroredSchema[] = [ fixtures: metricMeasureArityFixtures, }, { - name: 'checkDashboardWidgetDimensionlessMeasureArity', - check: checkDashboardWidgetDimensionlessMeasureArity, - fixtures: dimensionlessMeasureArityFixtures, + name: 'checkDashboardWidgetChartMeasureArity', + check: checkDashboardWidgetChartMeasureArity, + fixtures: chartMeasureArityFixtures, }, ], cleanFixtures: [{ ...WIDGET, type: 'horizontal-bar' }], @@ -579,7 +600,7 @@ describe('`./index` (the `@objectstack/spec/ui` surface) exports the same functi // NOT the full export list: the three widget checks — // `checkDashboardWidgetStageOrder`, // `checkDashboardWidgetMetricMeasureArity` and - // `checkDashboardWidgetDimensionlessMeasureArity` — are catalogued in `MIRRORED` + // `checkDashboardWidgetChartMeasureArity` — are catalogued in `MIRRORED` // above (legs 1-2) and carry their own legs 3-4 — barrel identity and // attached-by-identifier — beside the schema they guard, in // `dashboard.test.ts`. Read this `it.each` as the rows that live here, not as From 221df98d7280bb7d2011b04c1faf3e8a0dee632e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 13:12:43 +0000 Subject: [PATCH 2/2] chore(spec): regenerate the migration registry, api-surface, export-origins and the dashboard reference Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude --- content/docs/references/ui/dashboard.mdx | 4 +- packages/spec/api-surface/ui.json | 2 +- packages/spec/export-origins/ui.json | 2 +- packages/spec/src/migrations/registry.ts | 88 ++++++++++++++++++++++-- 4 files changed, 88 insertions(+), 8 deletions(-) diff --git a/content/docs/references/ui/dashboard.mdx b/content/docs/references/ui/dashboard.mdx index ba300f7e44f..e090e1f83b1 100644 --- a/content/docs/references/ui/dashboard.mdx +++ b/content/docs/references/ui/dashboard.mdx @@ -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` | optional | Widget specific configuration | | **filterBindings** | `Record` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out | @@ -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` | optional | Widget specific configuration | | **filterBindings** | `Record` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out | diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index eda5f281e62..b1663817a1f 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -478,7 +478,7 @@ "chartAggregateCategoryKey (function)", "chartAggregateResultKeys (function)", "chartAggregateValueKey (function)", - "checkDashboardWidgetDimensionlessMeasureArity (function)", + "checkDashboardWidgetChartMeasureArity (function)", "checkDashboardWidgetMetricMeasureArity (function)", "checkDashboardWidgetStageOrder (function)", "checkGlobalFilterDateDefaultValue (function)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 8144a59475e..b1e99c14ff2 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -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)", diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 0eae471050e..a0d2cc723a1 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -8891,12 +8891,16 @@ const step18: MigrationStep = { + '`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 ' @@ -9002,6 +9006,82 @@ const step18: MigrationStep = { + 'afterwards, because the two new tiles are numbers the dashboard was ALREADY paying ' + 'to compute and had never shown.', }, + { + 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.', + }, { id: 'dashboard-widget-stage-order-non-funnel-refused', surface: 'dashboard widget stage order — `dashboard.widgets[].options.stageOrder` '