Skip to content

Commit 32c2f56

Browse files
committed
feat(spec)!: a metric-family dashboard widget declares exactly one measure
`DashboardWidgetSchema.values` was `z.array(z.string()).min(1)` with no upper bound on every widget type, so a `metric` tile could declare three measures: the query ran all three and the tile rendered `values[0]`. objectui#8894 decision batch #119 item 4 took option D — judge the protocol wrong. `checkDashboardWidgetMetricMeasureArity` refuses more than one measure on the metric family (`metric` / `kpi` / `gauge` / `solid-gauge` / `bullet`, and the `metric` default a typeless widget resolves to), at `values`, naming the widget and prescribing one tile per measure. Every other widget type is untouched. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 72dd95f commit 32c2f56

3 files changed

Lines changed: 386 additions & 7 deletions

File tree

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

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
DATE_RANGE_DEFAULT_RANGES,
1717
DashboardWidgetOptionsSchema,
1818
checkDashboardWidgetStageOrder,
19+
checkDashboardWidgetMetricMeasureArity,
1920
} from './dashboard.zod';
2021
import * as ui from './index';
2122
import { readFileSync } from 'node:fs';
@@ -1049,3 +1050,139 @@ describe('DashboardWidgetOptions.stageOrder — the ADR-0049 type gate', () => {
10491050
expect(paths).toContain('widgets.0.options.stageOrder');
10501051
});
10511052
});
1053+
1054+
/**
1055+
* [#17779] objectui#8894 ruling D — a metric-family widget declares EXACTLY one
1056+
* measure.
1057+
*
1058+
* The maintainer took **D** on objectui#8894 (decision batch #119 item 4,
1059+
* 2026-09-12 「同意」): judge the protocol wrong rather than invent display
1060+
* semantics for `values[1..]`. Before this, `values` was
1061+
* `z.array(z.string()).min(1)` with no upper bound on EVERY widget type, so a
1062+
* `metric` tile could declare three measures, the query ran all three, and the
1063+
* tile rendered `values[0]`.
1064+
*
1065+
* "Exactly one" is the CONJUNCTION of two rules and the tests below read both:
1066+
* the field's own `.min(1)` (0 measures → `too_small`) and
1067+
* `checkDashboardWidgetMetricMeasureArity` (>1 on the family → `custom` at
1068+
* `values`).
1069+
*/
1070+
describe('[#17779] DashboardWidgetSchema — the metric family takes exactly one measure', () => {
1071+
const widget = (over: Record<string, unknown>) => ({ ...WIDGET_BASE, ...over });
1072+
const refusal = (value: Record<string, unknown>) => {
1073+
const r = DashboardWidgetSchema.safeParse(value);
1074+
expect(r.success).toBe(false);
1075+
const issues = r.success ? [] : r.error.issues;
1076+
expect(issues).toHaveLength(1);
1077+
return issues[0]!;
1078+
};
1079+
1080+
// The family, read off the refusal rather than re-listed as a literal: the
1081+
// message interpolates the authored type, so a member silently dropped from
1082+
// the set would fail HERE rather than in a list that agrees with itself.
1083+
const FAMILY = ['metric', 'kpi', 'gauge', 'solid-gauge', 'bullet'] as const;
1084+
1085+
it.each(FAMILY)('refuses two measures on a `%s` tile, at `values`', (type) => {
1086+
const issue = refusal(widget({ type, values: ['amount_sum', 'count'] }));
1087+
expect(issue.code).toBe('custom');
1088+
expect(issue.path.join('.')).toBe('values');
1089+
expect(issue.message).toContain(`\`type: '${type}'\``);
1090+
});
1091+
1092+
it.each(FAMILY)('accepts ONE measure on a `%s` tile — the legal single-value card', (type) => {
1093+
expect(DashboardWidgetSchema.safeParse(widget({ type, values: ['amount_sum'] })).success).toBe(true);
1094+
});
1095+
1096+
it('names the widget, the count, and the ruling\'s own prescription', () => {
1097+
const issue = refusal(widget({ id: 'pipeline_total', type: 'metric', values: ['a', 'b', 'c'] }));
1098+
// "The refusal names the widget and says one measure per tile, 'make N
1099+
// tiles for N measures'" — the card's acceptance sentence, as an assertion.
1100+
expect(issue.message).toContain('`pipeline_total`');
1101+
expect(issue.message).toContain('declares 3 measures');
1102+
expect(issue.message).toContain('one measure per tile');
1103+
expect(issue.message).toContain('make N tiles for N measures');
1104+
// …and it names the visuals that DO render several numbers, so "I really
1105+
// want three" has an answer that is not "delete two".
1106+
expect(issue.message).toContain("`type: 'table'`");
1107+
});
1108+
1109+
it('a widget that declares NO type is refused too — `type` defaults to `metric`', () => {
1110+
const issue = refusal(widget({ values: ['a', 'b'] }));
1111+
expect(issue.path.join('.')).toBe('values');
1112+
expect(issue.message).toContain("`type: 'metric'`");
1113+
// …and says so, because the gate cannot tell the two apart.
1114+
expect(issue.message).toContain('declares no `type` at all');
1115+
});
1116+
1117+
it.each(['bar', 'horizontal-bar', 'column', 'line', 'area', 'pie', 'donut', 'funnel',
1118+
'scatter', 'treemap', 'sankey', 'combo', 'radar', 'table', 'pivot'] as const)(
1119+
'leaves `%s` — every NON-metric type — accepting three measures, unmoved',
1120+
(type) => {
1121+
expect(DashboardWidgetSchema.safeParse(widget({ type, values: ['a', 'b', 'c'] })).success).toBe(true);
1122+
},
1123+
);
1124+
1125+
it('covers the whole taxonomy — the metric family plus the others IS `ChartTypeSchema`', () => {
1126+
// Guards the two `it.each` lists above against a new chart type landing in
1127+
// the enum and being covered by neither.
1128+
const OTHERS = ['bar', 'horizontal-bar', 'column', 'line', 'area', 'pie', 'donut', 'funnel',
1129+
'scatter', 'treemap', 'sankey', 'combo', 'radar', 'table', 'pivot'];
1130+
expect([...FAMILY, ...OTHERS].sort()).toEqual([...ChartTypeSchema.options].sort());
1131+
});
1132+
1133+
it('the EMPTY array keeps the field\'s own verdict — not a second `custom` issue', () => {
1134+
// "Exactly one" is `.min(1)` AND this check; the check returns on 0 so the
1135+
// author reads one refusal about an empty tile, not two.
1136+
const issue = refusal(widget({ type: 'metric', values: [] }));
1137+
expect(issue.code).toBe('too_small');
1138+
expect(issue.path.join('.')).toBe('values');
1139+
});
1140+
1141+
it('a `type` outside the enum reports the TYPE refusal alone, not both', () => {
1142+
// Same zod behaviour the stage-order gate pins: `invalid_value` on the enum
1143+
// aborts, so object-level checks are skipped for that input.
1144+
const issue = refusal(widget({ type: 'ziggurat', values: ['a', 'b'] }));
1145+
expect(issue.code).toBe('invalid_value');
1146+
expect(issue.path.join('.')).toBe('type');
1147+
});
1148+
1149+
it('does NOT reach whether the one measure exists in the dataset', () => {
1150+
// A fact about the dataset, not about the widget — unreachable from here,
1151+
// stated as a pin rather than left implied.
1152+
expect(DashboardWidgetSchema.safeParse(widget({ type: 'metric', values: ['no_such_measure'] })).success)
1153+
.toBe(true);
1154+
});
1155+
1156+
it('the rule the door runs is the EXPORT, attached by identifier — no inline copy', () => {
1157+
const src = readFileSync(new URL('./dashboard.zod.ts', import.meta.url), 'utf8');
1158+
expect(src).toContain('export function checkDashboardWidgetMetricMeasureArity(');
1159+
expect(src.match(/^\s*(export )?function checkDashboardWidgetMetricMeasureArity\b/gm)).toHaveLength(1);
1160+
expect(src.match(/^[ \t]*\.superRefine\(checkDashboardWidgetMetricMeasureArity\)/gm)).toHaveLength(1);
1161+
});
1162+
1163+
it('`@objectstack/spec/ui` ships the same function object', () => {
1164+
expect((ui as Record<string, unknown>).checkDashboardWidgetMetricMeasureArity)
1165+
.toBe(checkDashboardWidgetMetricMeasureArity);
1166+
expect(checkDashboardWidgetMetricMeasureArity.length).toBe(2);
1167+
});
1168+
1169+
it('the gate travels with the widget through `DashboardSchema.widgets[]`', () => {
1170+
const r = DashboardSchema.safeParse({
1171+
name: 'sales_dashboard',
1172+
label: 'Sales',
1173+
widgets: [widget({ type: 'kpi', values: ['a', 'b'] })],
1174+
});
1175+
expect(r.success).toBe(false);
1176+
const paths = (r.success ? [] : r.error.issues).map((i) => i.path.join('.'));
1177+
expect(paths).toContain('widgets.0.values');
1178+
});
1179+
1180+
it('the shipped `values` doc string states the arity rule it enforces', () => {
1181+
// declared = documented: the `.describe()` an author reads in the generated
1182+
// reference cannot still say only "at least one".
1183+
const described = (DashboardWidgetSchema as unknown as { shape: Record<string, { description?: string }> })
1184+
.shape.values.description ?? '';
1185+
expect(described).toContain('exactly one');
1186+
for (const type of FAMILY) expect(described).toContain(type);
1187+
});
1188+
});

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

Lines changed: 174 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,162 @@ export function checkDashboardWidgetStageOrder(
518518
});
519519
}
520520

521+
/**
522+
* The widget `type`s that render exactly ONE number — the metric FAMILY.
523+
*
524+
* Read off `ChartTypeSchema`'s own "Performance (single value)" group, which
525+
* is the taxonomy's word for the same set: `metric`/`kpi` render a number and
526+
* `gauge`/`solid-gauge`/`bullet` "render a value today and gain a dial when a
527+
* gauge renderer lands". A dial is still one value; nothing in the group has a
528+
* second mark to put a second measure on.
529+
*
530+
* Declared here beside the check rather than exported from `chart.zod.ts`: the
531+
* taxonomy groups by RENDERER FAMILY in a comment, and a comment is not a set.
532+
* Widening it later (a real gauge that draws a target band, say) is a one-line
533+
* edit here plus a relaxation of this rule — the direction that costs an author
534+
* nothing.
535+
*/
536+
const SINGLE_MEASURE_WIDGET_TYPES = ['metric', 'kpi', 'gauge', 'solid-gauge', 'bullet'] as const;
537+
538+
/**
539+
* objectui#8894 ruling D — a metric-family widget declares EXACTLY ONE measure.
540+
*
541+
* ## What was wrong
542+
*
543+
* `values` is `z.array(z.string()).min(1)` with no upper bound, so a `metric`
544+
* tile could declare three measures. All three were selected, the analytics
545+
* query ran all three, and the tile rendered `values[0]`: the other two were
546+
* queried and thrown away. That is the declared≠delivered shape ADR-0049 exists
547+
* to end, and it had been kept alive by a runtime warning — objectui#8887
548+
* landed a sub-caption saying the extra measures are not rendered, which makes
549+
* the tile HONEST about dropping them without making the document legal.
550+
*
551+
* The maintainer's standing ruling on this class is 「协议不正确的应该先修改协议。」
552+
* and objectui#8894 decision batch #119 item 4 (2026-09-12) took option **D**
553+
* on this instance: judge the protocol wrong. A single-value card is one
554+
* measure on every mainstream dashboard product; several numbers is a different
555+
* visual, not a variant of this one.
556+
*
557+
* ## Why an object-level check and not a per-`type` union arm — MEASURED
558+
*
559+
* The card left the spelling to this seat. Both spellings refuse the same
560+
* document; they differ in what the author is told about EVERY OTHER mistake.
561+
* Measured on this tree, eight widget bodies through
562+
* `z.union([metricArm, otherArm])` (arms built with `.safeExtend()`, since zod
563+
* 4.4.3 throws `Cannot overwrite keys on object schemas containing refinements`
564+
* on a plain `.extend()` that redeclares a key) versus one more `.superRefine`
565+
* on this strict object:
566+
*
567+
* | body | union arms | this spelling |
568+
* |---|---|---|
569+
* | `bogusProp` on a widget | `(root) invalid_union: Invalid input` | the strict-object refusal, naming the key + the history sentence |
570+
* | `categoryField`/`valueField` | `(root) invalid_union: Invalid input` | the {@link WIDGET_GUIDANCE_SETS} ADR-0021 prescription |
571+
* | `titel` | `(root) invalid_union: Invalid input` | `Did you mean \`titel\` → \`title\`?` |
572+
* | `type: 'ziggurat'` | `(root) invalid_union: Invalid input` | `invalid_value` at `type`, listing all twenty |
573+
*
574+
* Four of eight bodies lose their whole diagnostic to one bare `Invalid input`.
575+
* That is not a new observation on this file — the `compareTo` docblock above
576+
* records the same measurement for the same reason (#5014: "a union collapses
577+
* into one bare `Invalid input` on the wire … A plain strict object's errors
578+
* reach the author"), and `view-union-diagnostics.test.ts` is the whole
579+
* apparatus objectui needed because `ViewMetadataSchema` IS a union. Adding a
580+
* second union to this file would be commissioning that apparatus again to buy
581+
* a refusal the object-level form gives for free.
582+
*
583+
* So: one more check on the same door, attached by identifier, exactly as
584+
* {@link checkDashboardWidgetStageOrder} is.
585+
*
586+
* ## What the refusal says
587+
*
588+
* It names the widget (its `id` and its `type`), states the rule in the ruling's
589+
* own words — one measure per tile, make N tiles for N measures — and names the
590+
* shapes that DO render several numbers, so "I really do want three" has an
591+
* answer that is not "delete two".
592+
*
593+
* ## What this check deliberately does NOT reach
594+
*
595+
* Five shapes, named so the gate is not read as complete:
596+
*
597+
* 1. **The EMPTY array.** `values: []` is refused by the field's own `.min(1)`
598+
* with `too_small`, and this check returns on it rather than adding a
599+
* second issue about a tile with no measure at all. "Exactly one" is the
600+
* CONJUNCTION of that `.min(1)` and this upper bound, not this check alone
601+
* — a mirror that re-attaches this export onto a shape whose `values`
602+
* carries no `.min(1)` gets the upper bound only.
603+
* 2. **A widget that declares no `type`.** `type` carries
604+
* `.default(WIDGET_TYPE_DEFAULT)`, which is `metric` — a member of this
605+
* family — and zod applies defaults BEFORE object-level checks, so an
606+
* omitted `type` arrives here as `metric` and is refused like an authored
607+
* one. The verdict is right either way; the message carries an extra
608+
* sentence in that ambiguous case rather than claiming the author wrote it.
609+
* 3. **A `type` outside `ChartTypeSchema`.** zod treats that `invalid_value`
610+
* as aborting and skips every object-level check for the input, so
611+
* `type: 'ziggurat'` plus four measures reports the type refusal alone.
612+
* 4. **Whether the measures EXIST in the bound dataset.** Still a fact about
613+
* the dataset, not about the widget, and unreachable from this schema — a
614+
* tile naming one measure nobody declared parses exactly as before.
615+
* 5. **objectui's CLIENT-SIDE authoring door**, a `.shape` mirror that runs no
616+
* object-level check of this schema's: at the `.objectui-sha` pin,
617+
* `@object-ui/types` builds its own `DashboardWidgetSchema` from
618+
* `specFieldsExcept(SpecDashboardWidgetSchema.shape, …).extend({…}).strict()`
619+
* and re-attaches none of this file's exported checks. Until it imports and
620+
* chains this one, the dashboard EDITOR keeps accepting three measures on a
621+
* `metric` and the author meets the refusal at PUBLISH. That mirror also
622+
* redeclares `type` with no default, so a typeless widget reaches a
623+
* re-attached check as `undefined`; this function defaults it itself for
624+
* exactly that caller.
625+
*/
626+
export function checkDashboardWidgetMetricMeasureArity(
627+
widget: { id?: unknown; type?: unknown; values?: unknown },
628+
ctx: z.RefinementCtx,
629+
): void {
630+
const values = widget.values;
631+
// Not an array, or empty, or already the one measure the family takes: the
632+
// field's own `z.array(z.string()).min(1)` owns both of the first two
633+
// verdicts and says them better (`too_small` at `values`), and the third is
634+
// the legal document. See non-coverage 1.
635+
if (!Array.isArray(values) || values.length <= 1) return;
636+
637+
// `?? WIDGET_TYPE_DEFAULT` is UNREACHABLE through this schema's own door —
638+
// zod applies `type`'s default before object-level checks. It is here for the
639+
// mirror that re-attaches this export onto a shape whose `type` carries no
640+
// default (non-coverage 5), so the export never refuses LESS than the door it
641+
// is exported from; `object-refinement-check-exports.test.ts` pins that
642+
// equivalence on the raw fixture.
643+
const type = widget.type ?? WIDGET_TYPE_DEFAULT;
644+
if (typeof type !== 'string') return;
645+
if (!(SINGLE_MEASURE_WIDGET_TYPES as readonly string[]).includes(type)) return;
646+
647+
// Same ambiguity the stage-order check carries, and the same repair: a widget
648+
// that declared NO type arrives here as `metric` and cannot be told apart
649+
// from one that wrote it, so the extra sentence is added only in that case.
650+
const defaultedTypeNote = type === WIDGET_TYPE_DEFAULT
651+
? ' (`' + WIDGET_TYPE_DEFAULT + '` is also what a widget that declares no `type` at all '
652+
+ 'resolves to — if you meant a chart, the `type` key is missing rather than wrong.)'
653+
: '';
654+
const widgetName = typeof widget.id === 'string' && widget.id.length > 0
655+
? '`' + widget.id + '`'
656+
: 'this widget';
657+
658+
ctx.addIssue({
659+
code: 'custom',
660+
path: ['values'],
661+
message:
662+
'Widget ' + widgetName + ' declares ' + values.length + ' measures on `type: '
663+
+ `'${type}'`
664+
+ '`, and a metric-family widget ('
665+
+ SINGLE_MEASURE_WIDGET_TYPES.map((t) => '`' + t + '`').join(' / ')
666+
+ ') renders exactly ONE number: one measure per tile, so make N tiles for N '
667+
+ 'measures. Every measure after `values[0]` was queried and then dropped on the '
668+
+ 'floor by the renderer — keep the one this tile is for, and give each of the '
669+
+ 'others its own widget with its own `id` (and `layout`, if you pin positions). '
670+
+ 'If you meant several numbers in ONE widget, that is a different visual: '
671+
+ "`type: 'table'` renders a row of measures, and the chart families "
672+
+ "(`bar` / `line` / `area` / `combo`) render one mark per measure."
673+
+ defaultedTypeNote,
674+
});
675+
}
676+
521677
/**
522678
* Dashboard Widget Schema
523679
* A single component on the dashboard grid.
@@ -702,8 +858,19 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({
702858
dataset: SnakeCaseIdentifierSchema.describe('Dataset name to bind (ADR-0021)').meta({ title: 'Dataset' }),
703859
/** Dimension names (from the dataset) for X / group / split. */
704860
dimensions: z.array(z.string()).optional().describe('Dimension names — X/group/split').meta({ title: 'Dimensions' }),
705-
/** Measure names (from the dataset) for the value axis. */
706-
values: z.array(z.string()).min(1).describe('Measure names — Y (at least one)').meta({ title: 'Values' }),
861+
/**
862+
* Measure names (from the dataset) for the value axis.
863+
*
864+
* At least one, always. For the METRIC FAMILY — `metric` / `kpi` / `gauge` /
865+
* `solid-gauge` / `bullet`, and the `metric` default a widget with no `type`
866+
* resolves to — exactly one: those types render a single number and dropped
867+
* every measure after `values[0]` on the floor, so the second one is now a
868+
* parse error rather than a queried-and-discarded column
869+
* ({@link checkDashboardWidgetMetricMeasureArity}).
870+
*/
871+
values: z.array(z.string()).min(1)
872+
.describe('Measure names — Y (at least one; exactly one on the metric/kpi/gauge/solid-gauge/bullet family)')
873+
.meta({ title: 'Values' }),
707874

708875
/**
709876
* Layout Position (React-Grid-Layout style)
@@ -853,7 +1020,11 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({
8531020
// ADR-0049 enforce-or-remove on `options.stageOrder`. Attached by identifier
8541021
// rather than inlined, the way `GlobalFilterSchema` attaches its own check:
8551022
// the exported function IS the rule this door runs.
856-
.superRefine(checkDashboardWidgetStageOrder));
1023+
.superRefine(checkDashboardWidgetStageOrder)
1024+
// objectui#8894 ruling D — the metric FAMILY takes exactly one measure. Same
1025+
// idiom, same reason: `values`'s arity is decided by its sibling `type` one
1026+
// level up, so the rule has to run where both keys are in scope.
1027+
.superRefine(checkDashboardWidgetMetricMeasureArity));
8571028

8581029
/**
8591030
* Dashboard date-range presets — the named windows a dashboard date filter may

0 commit comments

Comments
 (0)