Skip to content

Commit fe29483

Browse files
committed
feat(lint): report a chart widget that selects no measures — or no dimensions (#15462)
`validateWidgetBindings` said nothing about two dataset-bound chart shapes that the `@object-ui` revision this repo pins (`.objectui-sha` = 00d3f09c500c4a45b5f27aca8af80349412faaf1) visibly degrades. Both become new warning-tier ids in the widget-binding family, suppressible per widget: - `chart-measures-missing` — `DatasetWidget.tsx:683` returns the authoring placeholder "Pick measures (values) for this dataset widget." before any query runs, above every family branch, so no chart is drawn at all. - `chart-dimensions-missing` — `DatasetWidget.tsx:423` (`const isMetric = METRIC_TYPES.has(widgetType) || dimensions.length === 0;`) routes a dimensionless chart to the single-value branch, so it renders a KPI number and the declared family is silently ignored. Warning rather than error for both: an empty selection is a work-in-progress state a build must tolerate, and erroring would gate the `sys_metadata` publish path on a half-authored widget. Neither is folded into `chart-config-missing` — neither is caused by, nor repairable with, `chartConfig` — and that rule's docblock now points at these two ids instead of recording them as deliberately unreported. "Chart family" is derived from the renderer's own routing rather than hand-listed: every declared `ChartTypeSchema` option that is neither a `METRIC_TYPES` member nor tabular. Membership of both mirrored sets is held to `ChartTypeSchema` by the tests, as #14436 did for `MARK_MIXING_CHART_TYPES`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
1 parent 40a44b9 commit fe29483

4 files changed

Lines changed: 430 additions & 7 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
Two new widget-binding rule ids for a chart widget with an empty selection
6+
7+
`validateWidgetBindings` reported nothing about two dataset-bound chart shapes that the
8+
`@object-ui` revision this repo pins (`.objectui-sha`) visibly degrades. Both are now
9+
warnings, suppressible per widget with `suppressWarnings: ['<rule-id>']`:
10+
11+
- `chart-measures-missing` — a chart-family widget selects no measures (`values` empty or
12+
absent). `DatasetWidget.tsx:683` returns the authoring placeholder "Pick measures
13+
(values) for this dataset widget." before any query runs, above every family branch, so
14+
no chart is drawn at all.
15+
- `chart-dimensions-missing` — a chart-family widget selects at least one measure but no
16+
dimensions. `DatasetWidget.tsx:423` reads
17+
`const isMetric = METRIC_TYPES.has(widgetType) || dimensions.length === 0;`, so the
18+
widget renders as a single KPI number and the declared chart family is silently ignored.
19+
The hint steers the author to a dimension, or to the `metric`/`kpi` family that matches
20+
what actually renders.
21+
22+
Warning tier rather than error for both: an empty selection is a work-in-progress state a
23+
build must tolerate, and erroring would gate the `sys_metadata` publish path on a
24+
half-authored widget. Neither shape is folded into `chart-config-missing` — neither is
25+
caused by, nor repairable with, `chartConfig`, which carries presentation only.
26+
27+
"Chart family" is derived, not hand-listed: every declared `ChartTypeSchema` option that
28+
the pinned renderer routes to its chart branch — the taxonomy minus the renderer's own
29+
`METRIC_TYPES` (`metric`, `kpi`, `gauge`, `solid-gauge`, `bullet`) and its `table`/`pivot`
30+
tabular test. A `metric` tile with no dimensions, such as the shipped `system_overview`
31+
board's own KPI tiles, is therefore not a finding.

packages/lint/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ export {
1818
WIDGET_MEASURE_UNKNOWN,
1919
CHART_FIELD_UNKNOWN,
2020
CHART_CONFIG_MISSING,
21+
// [#15462] The two empty-selection shapes the pinned renderer degrades on
22+
// before `chartConfig` is ever consulted: no measures (an authoring
23+
// placeholder replaces the chart) and no dimensions (`isMetric` draws a KPI
24+
// number instead of the declared family).
25+
CHART_MEASURES_MISSING,
26+
CHART_DIMENSIONS_MISSING,
2127
TABLE_COUNT_ONLY,
2228
MEASURE_AGGREGATE_INCOHERENT,
2329
WIDGET_LEGACY_ANALYTICS_SHAPE,

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

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import { runAuthoringRules, splitBySeverity } from './authoring-rules.js';
44
import {
55
validateWidgetBindings,
66
MARK_MIXING_CHART_TYPES,
7+
METRIC_WIDGET_TYPES,
8+
TABULAR_WIDGET_TYPES,
9+
CHART_FAMILY_WIDGET_TYPES,
10+
CHART_MEASURES_MISSING,
11+
CHART_DIMENSIONS_MISSING,
712
TABLE_COUNT_ONLY,
813
WIDGET_DATASET_UNKNOWN,
914
WIDGET_DIMENSION_UNKNOWN,
@@ -1447,3 +1452,224 @@ describe('#14148 acceptance — both limbs gate `validate` AND `build`', () => {
14471452
});
14481453
}
14491454
});
1455+
1456+
/**
1457+
* [#15462] The two empty-selection shapes. Both are read at the `@object-ui`
1458+
* revision this repo PINS (`.objectui-sha`), in
1459+
* `packages/plugin-dashboard/src/DatasetWidget.tsx`:
1460+
*
1461+
* - `:683` — `if (values.length === 0)` returns the authoring placeholder
1462+
* "Pick measures (values) for this dataset widget.", above every family
1463+
* branch, so no chart is drawn at all;
1464+
* - `:423` — `const isMetric = METRIC_TYPES.has(widgetType) ||
1465+
* dimensions.length === 0;`, so a dimensionless `bar` renders as a KPI
1466+
* number instead of the family the author declared.
1467+
*
1468+
* Warning tier for both: an empty selection is a state an author passes
1469+
* through, and this family's errors are reserved for bindings the analytics
1470+
* service cannot satisfy.
1471+
*/
1472+
describe('chart-measures-missing / chart-dimensions-missing (#15462)', () => {
1473+
const rules = (findings: { rule: string }[]) => findings.map((f) => f.rule);
1474+
1475+
it('(1) warns when a chart-family widget selects no measures', () => {
1476+
const findings = validateWidgetBindings(chartStack({ values: [], chartConfig: undefined }));
1477+
expect(findings).toHaveLength(1);
1478+
expect(findings[0].severity).toBe('warning');
1479+
expect(findings[0].rule).toBe(CHART_MEASURES_MISSING);
1480+
expect(findings[0].where).toContain('spend_by_category');
1481+
expect(findings[0].path).toBe('dashboards[0].widgets[0]');
1482+
// The message names the PINNED consequence — the placeholder string the
1483+
// renderer returns — not an inferred one.
1484+
expect(findings[0].message).toContain('Pick measures (values) for this dataset widget.');
1485+
expect(findings[0].message).toContain('no chart is drawn at all');
1486+
expect(findings[0].hint).toContain('declared measures: sum_amount, ticket_count');
1487+
expect(findings[0].hint).toContain(`suppressWarnings: ['${CHART_MEASURES_MISSING}']`);
1488+
});
1489+
1490+
it('(1) an absent `values` key reports the same shape as an empty array', () => {
1491+
const stack = chartStack({ chartConfig: undefined });
1492+
delete (stack as { dashboards: { widgets: Record<string, unknown>[] }[] })
1493+
.dashboards[0].widgets[0].values;
1494+
const findings = validateWidgetBindings(stack);
1495+
expect(rules(findings)).toEqual([CHART_MEASURES_MISSING]);
1496+
});
1497+
1498+
it('(2) warns when a chart-family widget selects no dimensions', () => {
1499+
const findings = validateWidgetBindings(chartStack({ dimensions: [], chartConfig: undefined }));
1500+
expect(findings).toHaveLength(1);
1501+
expect(findings[0].severity).toBe('warning');
1502+
expect(findings[0].rule).toBe(CHART_DIMENSIONS_MISSING);
1503+
expect(findings[0].path).toBe('dashboards[0].widgets[0]');
1504+
// The pinned expression, quoted, and the consequence it produces.
1505+
expect(findings[0].message).toContain('METRIC_TYPES.has(widgetType) || dimensions.length === 0');
1506+
expect(findings[0].message).toContain('draws a single KPI number');
1507+
expect(findings[0].hint).toContain('declared dimensions: category');
1508+
// The honest alternative repair: declare the family that actually renders.
1509+
expect(findings[0].hint).toContain("'metric' or 'kpi'");
1510+
expect(findings[0].hint).toContain(`suppressWarnings: ['${CHART_DIMENSIONS_MISSING}']`);
1511+
});
1512+
1513+
it('each shape is suppressible per widget', () => {
1514+
expect(validateWidgetBindings(chartStack({
1515+
values: [], chartConfig: undefined, suppressWarnings: [CHART_MEASURES_MISSING],
1516+
}))).toHaveLength(0);
1517+
expect(validateWidgetBindings(chartStack({
1518+
dimensions: [], chartConfig: undefined, suppressWarnings: [CHART_DIMENSIONS_MISSING],
1519+
}))).toHaveLength(0);
1520+
});
1521+
1522+
it('an unrelated suppressWarnings entry suppresses neither', () => {
1523+
expect(rules(validateWidgetBindings(chartStack({
1524+
values: [], chartConfig: undefined, suppressWarnings: [CHART_DIMENSIONS_MISSING],
1525+
})))).toEqual([CHART_MEASURES_MISSING]);
1526+
expect(rules(validateWidgetBindings(chartStack({
1527+
dimensions: [], chartConfig: undefined, suppressWarnings: [CHART_MEASURES_MISSING],
1528+
})))).toEqual([CHART_DIMENSIONS_MISSING]);
1529+
});
1530+
1531+
it('a widget missing BOTH reports only the measures id — the pin returns first', () => {
1532+
// `values.length === 0` short-circuits at `:683`, ABOVE the `isMetric`
1533+
// branch, so this widget never renders the KPI number the other id
1534+
// describes. Reporting both would attribute two consequences to one widget.
1535+
const findings = validateWidgetBindings(chartStack({
1536+
values: [], dimensions: [], chartConfig: undefined,
1537+
}));
1538+
expect(rules(findings)).toEqual([CHART_MEASURES_MISSING]);
1539+
// The dimension is still named, so one fix round closes both.
1540+
expect(findings[0].hint).toContain(CHART_DIMENSIONS_MISSING);
1541+
});
1542+
1543+
it('a single-value or tabular family with no dimensions is NOT the dimensions finding', () => {
1544+
// The renderer routes these away from the chart branch on their TYPE
1545+
// (`METRIC_TYPES` / `isTable`), so a dimensionless one renders exactly what
1546+
// the author declared. `table`/`pivot` keep `table-count-only` as their own
1547+
// dimensionless rule.
1548+
for (const type of ['metric', 'kpi', 'gauge', 'solid-gauge', 'bullet', 'table', 'pivot']) {
1549+
const findings = validateWidgetBindings(chartStack({ type, dimensions: [], chartConfig: undefined }));
1550+
expect(rules(findings), `'${type}' must not report a chart-family finding`)
1551+
.not.toContain(CHART_DIMENSIONS_MISSING);
1552+
}
1553+
});
1554+
1555+
it('a single-value or tabular family with no measures is NOT the measures finding', () => {
1556+
for (const type of ['metric', 'kpi', 'gauge', 'solid-gauge', 'bullet', 'table', 'pivot']) {
1557+
const findings = validateWidgetBindings(chartStack({ type, values: [], chartConfig: undefined }));
1558+
expect(rules(findings), `'${type}' must not report a chart-family finding`)
1559+
.not.toContain(CHART_MEASURES_MISSING);
1560+
}
1561+
});
1562+
1563+
it('a chart-family widget that selects both is clean', () => {
1564+
for (const type of CHART_FAMILY_WIDGET_TYPES) {
1565+
const findings = validateWidgetBindings(chartStack({ type, chartConfig: undefined }));
1566+
const mine = findings.filter((f) => f.rule === CHART_MEASURES_MISSING
1567+
|| f.rule === CHART_DIMENSIONS_MISSING);
1568+
expect(mine, `'${type}' with one dimension and one measure must be clean`).toEqual([]);
1569+
}
1570+
});
1571+
1572+
it('every chart family reports, and no other family does', () => {
1573+
for (const type of ChartTypeSchema.options as readonly string[]) {
1574+
const noMeasures = rules(validateWidgetBindings(chartStack({ type, values: [], chartConfig: undefined })));
1575+
const noDims = rules(validateWidgetBindings(chartStack({ type, dimensions: [], chartConfig: undefined })));
1576+
if (CHART_FAMILY_WIDGET_TYPES.has(type)) {
1577+
expect(noMeasures, `'${type}' selects no measures`).toContain(CHART_MEASURES_MISSING);
1578+
expect(noDims, `'${type}' selects no dimensions`).toContain(CHART_DIMENSIONS_MISSING);
1579+
} else {
1580+
expect(noMeasures, `'${type}' is not a chart family`).not.toContain(CHART_MEASURES_MISSING);
1581+
expect(noDims, `'${type}' is not a chart family`).not.toContain(CHART_DIMENSIONS_MISSING);
1582+
}
1583+
}
1584+
});
1585+
1586+
it('a widget type outside the taxonomy is judged by neither id', () => {
1587+
expect(validateWidgetBindings(chartStack({ type: 'barr', values: [], dimensions: [], chartConfig: undefined })))
1588+
.toEqual([]);
1589+
});
1590+
1591+
it('the exception sets mirror the PINNED renderer verbatim', () => {
1592+
// `DatasetWidget.tsx:343` `METRIC_TYPES` and `:424` `isTable`. Our copies
1593+
// drifting from the quoted lines reds here; the renderer's own set moving
1594+
// is caught by re-reading the pin when `.objectui-sha` bumps, which is the
1595+
// same contract `DATE_RANGE_DEFAULT_FIELD` carries in this file.
1596+
expect([...METRIC_WIDGET_TYPES]).toEqual(['metric', 'kpi', 'gauge', 'solid-gauge', 'bullet']);
1597+
expect([...TABULAR_WIDGET_TYPES]).toEqual(['table', 'pivot']);
1598+
});
1599+
1600+
it('every member of both exception sets is a declared chart type', () => {
1601+
// #14436's check for `MARK_MIXING_CHART_TYPES`, applied to the two sets the
1602+
// chart family is derived from: a member that is not a chart type at all is
1603+
// a typo or a retired family, and would silently WIDEN the family.
1604+
for (const type of [...METRIC_WIDGET_TYPES, ...TABULAR_WIDGET_TYPES]) {
1605+
expect(ChartTypeSchema.options, `'${type}' is not a declared chart type`).toContain(type);
1606+
}
1607+
});
1608+
1609+
it('the chart family is the taxonomy minus those two sets', () => {
1610+
for (const type of ['bar', 'horizontal-bar', 'column', 'line', 'area', 'pie', 'donut',
1611+
'funnel', 'scatter', 'treemap', 'sankey', 'combo', 'radar']) {
1612+
expect(CHART_FAMILY_WIDGET_TYPES.has(type), `'${type}' should be a chart family`).toBe(true);
1613+
}
1614+
for (const type of [...METRIC_WIDGET_TYPES, ...TABULAR_WIDGET_TYPES]) {
1615+
expect(CHART_FAMILY_WIDGET_TYPES.has(type), `'${type}' should NOT be a chart family`).toBe(false);
1616+
}
1617+
});
1618+
1619+
it('the SHIPPED `system_overview` single-value tiles report nothing', () => {
1620+
// The other half of #15461's fixture: the Row 1/2 tiles of
1621+
// `packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts`
1622+
// select a measure and NO dimensions — which is exactly shape 2's input,
1623+
// and is correct here because they are `metric` widgets. Copied verbatim,
1624+
// for the same reason that fixture was: first-party metadata is where a
1625+
// mis-scoped warning-tier rule does its damage (ADR-0072 D1).
1626+
const findings = validateWidgetBindings({
1627+
datasets: [{
1628+
name: 'sys_user_metrics',
1629+
label: 'User Metrics',
1630+
object: 'sys_user',
1631+
dimensions: [{ name: 'is_active', label: 'Active', field: 'is_active', type: 'boolean' }],
1632+
measures: [{ name: 'user_count', label: 'Users', aggregate: 'count' }],
1633+
}],
1634+
dashboards: [{
1635+
name: 'system_overview',
1636+
label: 'System Overview',
1637+
widgets: [{
1638+
id: 'widget_total_users',
1639+
dataset: 'sys_user_metrics', values: ['user_count'],
1640+
title: 'Total Users',
1641+
type: 'metric',
1642+
layout: { x: 0, y: 0, w: 3, h: 2 },
1643+
}],
1644+
}],
1645+
});
1646+
expect(findings).toEqual([]);
1647+
});
1648+
});
1649+
1650+
/**
1651+
* [#15462] Tier, pinned end-to-end rather than inferred from the constant: both
1652+
* ids ride the advisory channel on `validate` AND `build`. Shape 1 was the one
1653+
* the card left open (`error` "looks right — the renderer draws nothing"), and
1654+
* it was ruled warning because an empty selection is a work-in-progress state a
1655+
* build must tolerate — erroring would gate the `sys_metadata` publish path on
1656+
* a half-authored widget. Nothing else in this file would notice a later flip.
1657+
*/
1658+
describe('#15462 acceptance — both ids are advisory on `validate` and `build`', () => {
1659+
const noMeasures = chartStack({ values: [], chartConfig: undefined });
1660+
const noDims = chartStack({ dimensions: [], chartConfig: undefined });
1661+
1662+
for (const command of ['validate', 'build'] as const) {
1663+
it(`chart-measures-missing advises (never gates) \`${command}\``, () => {
1664+
const { errors, advisories } = splitBySeverity(runAuthoringRules(command, { normalized: noMeasures }));
1665+
expect(errors.map((f) => f.rule)).not.toContain(CHART_MEASURES_MISSING);
1666+
expect(advisories.map((f) => f.rule)).toContain(CHART_MEASURES_MISSING);
1667+
});
1668+
1669+
it(`chart-dimensions-missing advises (never gates) \`${command}\``, () => {
1670+
const { errors, advisories } = splitBySeverity(runAuthoringRules(command, { normalized: noDims }));
1671+
expect(errors.map((f) => f.rule)).not.toContain(CHART_DIMENSIONS_MISSING);
1672+
expect(advisories.map((f) => f.rule)).toContain(CHART_DIMENSIONS_MISSING);
1673+
});
1674+
}
1675+
});

0 commit comments

Comments
 (0)