Skip to content

Commit ce83d76

Browse files
os-zhuangclaude
andcommitted
feat(spec,lint): the ui vocabularies admit what the renderers implement (objectui#2945)
Additions-only follow-up to the vocabulary audit. Nothing narrows, so no already-stored metadata changes meaning. - ChartTypeSchema admits `combo`. The taxonomy could not name the one family the rest of chart.zod.ts is written for: ChartSeriesSchema.type exists to override a series' type ("combo charts", per its own doc comment) and ChartSeriesSchema.yAxis binds left/right, which only means something for mixed marks. objectui draws it distinctly and carried `combo` in a local fork whose comment claimed to mirror this list. - WidgetActionTypeSchema is ActionType. They disagreed by `form`, and backwards: a widget/header action dispatches through the same ActionRunner that implements `form` (DashboardRenderer routes everything but a raw `url` into it, deliberately — #3528), so the narrower enum rejected at validation what the dispatcher executes. - ListChartConfigSchema.chartType is ChartTypeSchema.extract([...]) — same five members, a de-duplication rather than a widening. - @objectstack/lint's chart-family set derives from the taxonomy. Its omissions failed in the worst direction: an unlisted family read as "not a chart", so a widget with no chartConfig measure mapping PASSED validation. Pinning the old hand-written list back reproduces exactly that for `combo` — zero findings — which is the new test's teeth. ActionType deliberately does NOT gain `navigation`: executeNavigation is a strictly weaker executeUrl (no ${param} interpolation, no apiBase promotion, no openIn) differing only by `replace`, and its one live producer is the SDUI element:button `action` prop that ElementButtonPropsSchema does not model at all. Adding the name would put a second spelling of "navigate" into a closed vocabulary without closing the real gap. spec 6944 tests / 267 files and lint 544 / 37 green; tsc clean on both. Refs objectstack-ai/objectui#2945, objectstack-ai/objectui#2901, #3528 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 01e124d commit ce83d76

8 files changed

Lines changed: 203 additions & 17 deletions
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/lint": patch
4+
---
5+
6+
feat(spec,lint): the `ui` vocabularies admit what the renderers implement, and derive instead of restating (objectui#2945)
7+
8+
Additions-only follow-up to the vocabulary audit
9+
(objectstack-ai/objectui#2901, #2945). Nothing here narrows a vocabulary, so no
10+
already-stored metadata changes meaning — three of the four `ui/` enums that had
11+
drifted from what is actually implemented, plus the fork that drift had made
12+
invisible.
13+
14+
**`ChartTypeSchema` admits `combo`.** The taxonomy could not name the one chart
15+
family the rest of `chart.zod.ts` is written for: `ChartSeriesSchema.type`
16+
exists to override a series' type — its doc comment literally says *"combo
17+
charts"* — and `ChartSeriesSchema.yAxis` binds a series to the left or right
18+
axis, which is only meaningful for mixed marks. objectui's renderer draws it
19+
distinctly (mixed bar/line/area on dual axes, per-series type) and had to carry
20+
`combo` in a local fork of this list, whose own comment claimed to mirror it.
21+
22+
**`WidgetActionTypeSchema` is `ActionType`.** The two disagreed by one member,
23+
`form`, and the disagreement was backwards: a dashboard header or widget action
24+
button dispatches through the same `ActionRunner` that implements `form`
25+
objectui's `DashboardRenderer` deliberately routes everything except a raw `url`
26+
into it, so a `flow` header action works (#3528). The narrower enum therefore
27+
rejected at validation exactly what the shared dispatcher then executes.
28+
Derived, so the next type the runner implements needs one edit, not two.
29+
30+
**`ListChartConfigSchema.chartType` is `ChartTypeSchema.extract([...])`.** Same
31+
five members as before — a de-duplication, not a widening. A member renamed in
32+
the taxonomy now fails at build time instead of leaving a second list quietly
33+
disagreeing.
34+
35+
**`@objectstack/lint`'s chart-family set is derived from the taxonomy.**
36+
`validate-widget-bindings` decides which widgets need a `chartConfig` measure
37+
mapping from a hand-written list of families, and its omissions fail in the
38+
worst direction: an unlisted family reads as *"not a chart"*, so a widget
39+
missing its mapping **passes** validation. `combo` was exactly that case —
40+
verified by pinning the old list back, where a `combo` widget with no
41+
`chartConfig` produced zero findings. The set is now the taxonomy minus an
42+
explicit `MEASURE_EXEMPT_CHART_TYPES` (single-value and tabular families), so a
43+
family added to the spec is covered without editing the rule.
44+
45+
Guards: `packages/spec/src/ui/vocabulary-derivation.test.ts` asserts both
46+
derivations still hold (a restated list fails silently — it keeps validating,
47+
just not what the other list says), and the lint suite now walks every
48+
multi-series family in the taxonomy rather than a list of its own.
49+
50+
`ActionType` deliberately does **not** gain `navigation`, which the audit
51+
suggested. `ActionRunner.executeNavigation` is a strictly weaker
52+
`executeUrl` — no `${param.X}` interpolation, no `apiBase` promotion, no
53+
`openIn` — differing only by a `replace` option, and its one live producer is
54+
the SDUI `element:button` `action` prop, which `ElementButtonPropsSchema` does
55+
not model at all. Promoting the name would add a second spelling of *navigate*
56+
to a closed authorable vocabulary (members cannot be removed later) without
57+
closing the gap that actually exists. Tracked separately.
58+
59+
Verified: `@objectstack/spec` **6944 tests / 267 files**, `@objectstack/lint`
60+
**544 tests / 37 files**, both green; `tsc --noEmit` clean on both.

packages/lint/src/validate-widget-bindings.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,11 +203,25 @@ describe('validateWidgetBindings (reference integrity, issue #1721)', () => {
203203
});
204204

205205
it('(d) non-chart types do not warn on missing chartConfig', () => {
206-
for (const type of ['metric', 'kpi', 'gauge', 'table']) {
206+
for (const type of ['metric', 'kpi', 'gauge', 'solid-gauge', 'bullet', 'table', 'pivot']) {
207207
expect(validateWidgetBindings(chartStack({ type, chartConfig: undefined }))).toHaveLength(0);
208208
}
209209
});
210210

211+
it('(d) every multi-series family in the taxonomy warns — including a newly added one', () => {
212+
// The set of chart families that need a measure mapping is derived from
213+
// `ChartTypeSchema`, so a family added to the taxonomy is covered without
214+
// editing a list here. `combo` is the case that proved it: as a hand-written
215+
// list, an unlisted family read as "not a chart" and the missing mapping
216+
// went unreported. objectui#2945.
217+
for (const type of ['bar', 'horizontal-bar', 'column', 'line', 'area', 'pie',
218+
'donut', 'funnel', 'scatter', 'treemap', 'sankey', 'radar', 'combo']) {
219+
const findings = validateWidgetBindings(chartStack({ type, chartConfig: undefined }));
220+
expect(findings, `expected a warning for chart type '${type}'`).toHaveLength(1);
221+
expect(findings[0].rule).toBe(CHART_CONFIG_MISSING);
222+
}
223+
});
224+
211225
it('errors are NOT suppressible via suppressWarnings', () => {
212226
const findings = validateWidgetBindings(chartStack({
213227
dataset: 'no_such_dataset',

packages/lint/src/validate-widget-bindings.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { isIncoherentAggregate } from '@objectstack/spec/data';
4+
import { ChartTypeSchema } from '@objectstack/spec/ui';
45

56
/**
67
* Build-time dashboard widget binding diagnostics (issues #1719, #1721).
@@ -139,17 +140,29 @@ function asStrings(v: unknown): string[] {
139140
}
140141

141142
/**
142-
* Chart families whose renderer needs a `chartConfig` measure mapping.
143-
* Single-value types (metric/kpi/gauge/…) plot their lone value and tabular
144-
* types (table/pivot) render every column, so they are exempt.
143+
* Chart families that plot a single value or every column, and so need no
144+
* `chartConfig` measure mapping: single-value types plot their lone value,
145+
* tabular types render each column as-is.
145146
*/
146-
const CHART_TYPES = new Set([
147-
'bar', 'horizontal-bar', 'column',
148-
'line', 'area',
149-
'pie', 'donut', 'funnel',
150-
'scatter', 'treemap', 'sankey', 'radar',
147+
const MEASURE_EXEMPT_CHART_TYPES = new Set([
148+
'gauge', 'solid-gauge', 'metric', 'kpi', 'bullet',
149+
'table', 'pivot',
151150
]);
152151

152+
/**
153+
* Chart families whose renderer needs a `chartConfig` measure mapping — the
154+
* taxonomy minus the exemptions above.
155+
*
156+
* Derived from `ChartTypeSchema` rather than restated. As a hand-written list it
157+
* had no way to know when the taxonomy grew, and the omission is silent in
158+
* exactly the wrong direction: an unlisted family is treated as "not a chart",
159+
* so a widget missing its measure mapping passes validation instead of being
160+
* reported. objectui#2945.
161+
*/
162+
const CHART_TYPES = new Set<string>(
163+
ChartTypeSchema.options.filter(t => !MEASURE_EXEMPT_CHART_TYPES.has(t)),
164+
);
165+
153166
function levenshtein(a: string, b: string): number {
154167
const m = a.length, n = b.length;
155168
let prev = Array.from({ length: n + 1 }, (_, j) => j);

packages/spec/src/ui/chart.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ describe('ChartTypeSchema', () => {
4747
});
4848
});
4949

50+
it('should accept the mixed chart type', () => {
51+
// `combo` draws bar/line/area series together on left/right axes — the
52+
// family `ChartSeriesSchema.type` and `ChartSeriesSchema.yAxis` configure.
53+
expect(() => ChartTypeSchema.parse('combo')).not.toThrow();
54+
});
55+
5056
it('should accept all performance chart types', () => {
5157
const types = ['gauge', 'metric', 'kpi'] as const;
5258

packages/spec/src/ui/chart.zod.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ export const ChartTypeSchema = lazySchema(() => z.enum([
3737
'treemap',
3838
'sankey',
3939

40+
// Mixed — bar/line/area series on shared dual axes. `ChartSeriesSchema.type`
41+
// and `ChartSeriesSchema.yAxis` below exist precisely to configure this
42+
// family (the former's own doc comment says "combo charts"), so the taxonomy
43+
// was unable to name the one chart type the rest of the file is written for.
44+
// objectui's renderer draws it distinctly — mixed marks, left/right axes,
45+
// per-series type — and had to carry `combo` in a local fork of this list.
46+
'combo',
47+
4048
// Performance (single value — metric/kpi render a number; gauge/solid-gauge/
4149
// bullet are honest single-value variants pending a real dial/target renderer)
4250
'gauge',

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

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
66
import { FilterConditionSchema } from '../data/filter.zod';
77
import { DateGranularity } from '../data/query.zod';
88
import { ChartTypeSchema, ChartConfigSchema } from './chart.zod';
9+
import { ActionType } from './action.zod';
910
import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
1011
import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod';
1112
import { ResponsiveConfigSchema, PerformanceConfigSchema } from './responsive.zod';
@@ -27,14 +28,20 @@ export const WidgetColorVariantSchema = lazySchema(() => z.enum([
2728

2829
/**
2930
* Action type for widget action buttons.
31+
*
32+
* `ActionType` itself, not a hand-kept subset of it. The two lists had drifted
33+
* apart by one member — `form` — and the disagreement was backwards: a
34+
* dashboard header or widget action button dispatches through the same
35+
* `ActionRunner` that implements `form` (objectui's `DashboardRenderer` routes
36+
* everything except a raw `url` into it, deliberately, so a `flow` header
37+
* action works — objectstack#3528). So the narrower enum rejected at validation
38+
* exactly what the shared dispatcher then executes at runtime.
39+
*
40+
* Derived rather than restated: a type added to `ActionType` is dispatchable
41+
* from a widget the moment the runner implements it, with no second list to
42+
* remember.
3043
*/
31-
export const WidgetActionTypeSchema = lazySchema(() => z.enum([
32-
'script',
33-
'url',
34-
'modal',
35-
'flow',
36-
'api',
37-
]).describe('Widget action type'));
44+
export const WidgetActionTypeSchema = lazySchema(() => ActionType.describe('Widget action type'));
3845

3946
/**
4047
* Dashboard Header Action Schema

packages/spec/src/ui/view.zod.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
77
import { ExpressionInputSchema } from '../shared/expression.zod';
88
import { normalizeVisibleWhen, strictVisibilityError } from '../shared/visibility';
99
import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod';
10+
import { ChartTypeSchema } from './chart.zod';
1011
import { SharingConfigSchema } from './sharing.zod';
1112
import { ResponsiveConfigSchema, PerformanceConfigSchema } from './responsive.zod';
1213
import { FieldType, SelectOptionSchema } from '../data/field.zod';
@@ -467,7 +468,14 @@ export const KanbanConfigSchema = lazySchema(() => z.object({
467468
* `ChartConfigSchema` in `chart.zod.ts` (which is for embedded reports).
468469
*/
469470
export const ListChartConfigSchema = lazySchema(() => z.object({
470-
chartType: z.enum(['bar', 'line', 'pie', 'area', 'scatter']).default('bar').describe('Chart visualisation type'),
471+
/**
472+
* Narrowed from `ChartTypeSchema` with `.extract()` rather than retyped.
473+
* Same five members as before — this is a de-duplication, not a widening —
474+
* but a member renamed in the chart taxonomy now fails here at build time
475+
* instead of leaving a second list quietly disagreeing with the first.
476+
*/
477+
chartType: ChartTypeSchema.extract(['bar', 'line', 'pie', 'area', 'scatter'])
478+
.default('bar').describe('Chart visualisation type'),
471479
/**
472480
* ADR-0021 — the semantic-layer `dataset` this chart binds to. Selects
473481
* dimensions/measures BY NAME so the chart's numbers stay consistent with
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Two `ui/` vocabularies are now *derived* from another rather than restated
5+
* (objectstack-ai/objectui#2945). These tests assert the derivation still holds,
6+
* because the failure mode of a restated list is silence: it keeps validating,
7+
* it just validates something the other list no longer says.
8+
*
9+
* - `ListChartConfigSchema.chartType` — a five-member `.extract()` of
10+
* `ChartTypeSchema`, not a second enum that happened to agree with it.
11+
* - `WidgetActionTypeSchema` — `ActionType` itself. It used to omit `form`,
12+
* and so rejected at validation what the `ActionRunner` a widget action
13+
* dispatches through already executes.
14+
*/
15+
import { describe, it, expect } from 'vitest';
16+
import { ChartTypeSchema } from './chart.zod';
17+
import { ListChartConfigSchema } from './view.zod';
18+
import { ActionType } from './action.zod';
19+
import { WidgetActionTypeSchema } from './dashboard.zod';
20+
21+
/** `z.enum(...).options` through the lazySchema proxy. */
22+
const optionsOf = (schema: unknown): string[] =>
23+
(schema as { options: string[] }).options;
24+
25+
describe('ListChartConfigSchema.chartType is extracted from ChartTypeSchema', () => {
26+
const listChartType = (ListChartConfigSchema as unknown as {
27+
shape: { chartType: { def: { innerType: unknown } } };
28+
}).shape.chartType.def.innerType;
29+
30+
it('admits exactly the five list-chart visualisations', () => {
31+
expect(optionsOf(listChartType).slice().sort())
32+
.toEqual(['area', 'bar', 'line', 'pie', 'scatter']);
33+
});
34+
35+
it('admits only members of the chart taxonomy', () => {
36+
const taxonomy = new Set(optionsOf(ChartTypeSchema));
37+
const strays = optionsOf(listChartType).filter(t => !taxonomy.has(t));
38+
expect(strays).toEqual([]);
39+
});
40+
41+
/** Minimal valid config — `dataset` and `values` are both required. */
42+
const minimal = { dataset: 'sales_by_month', values: ['revenue'] };
43+
44+
it('still rejects a taxonomy member outside the list-chart subset', () => {
45+
expect(() => ListChartConfigSchema.parse({ ...minimal, chartType: 'donut' })).toThrow();
46+
expect(() => ListChartConfigSchema.parse({ ...minimal, chartType: 'combo' })).toThrow();
47+
});
48+
49+
it('accepts every member of the subset, and defaults to bar', () => {
50+
for (const chartType of optionsOf(listChartType)) {
51+
expect(ListChartConfigSchema.parse({ ...minimal, chartType }).chartType).toBe(chartType);
52+
}
53+
expect(ListChartConfigSchema.parse(minimal).chartType).toBe('bar');
54+
});
55+
});
56+
57+
describe('WidgetActionTypeSchema is ActionType', () => {
58+
it('admits every action type, with no subset of its own', () => {
59+
expect(optionsOf(WidgetActionTypeSchema).slice().sort())
60+
.toEqual(optionsOf(ActionType).slice().sort());
61+
});
62+
63+
it('admits form — the member the hand-kept subset had dropped', () => {
64+
expect(() => WidgetActionTypeSchema.parse('form')).not.toThrow();
65+
});
66+
67+
it('still rejects an unknown action type', () => {
68+
expect(() => WidgetActionTypeSchema.parse('teleport')).toThrow();
69+
});
70+
});

0 commit comments

Comments
 (0)