Skip to content

Commit cd55558

Browse files
claude[bot]claude
andauthored
fix(lint): report an empty measure selection on every widget family, not only charts (#15668)
The pinned `@object-ui` renderer's `values.length === 0` return (`packages/plugin-dashboard/src/DatasetWidget.tsx:683` at `.objectui-sha` = `a472b07167a39e55491109e864bb5a54027dcfbd`) is type-independent and stands above `isMetric` (`:423`, `METRIC_TYPES` at `:343`), `isTable` (`:424`) and the chart branch alike. A `metric`, `kpi`, `gauge`, `solid-gauge`, `bullet`, `table` or `pivot` widget that selects no measures therefore renders the same authoring placeholder — the KPI number or the table is not drawn at all — and nothing reported it: `chart-measures-missing` was chart-family only, `table-count-only` requires `values.length > 0`, and the rules that iterate `dimensions[]`/`values[]` are silent on an empty array by construction. Adds `widget-measures-missing` for the non-chart declared families, with the same warning tier, the same per-widget `suppressWarnings` suppression and a message that states the consequence its family actually has. `chart-measures-missing` is left exactly as ruled — same id, same population, same wording — because it is reachable from the package barrel (a public-surface contract) and may already be written into a board's `suppressWarnings`. The dimensions arm stays chart-family only: a dimensionless `metric`/`table` is what those families are for. The population is derived, never hand-listed: `NON_CHART_DATASET_WIDGET_TYPES` is the `ChartTypeSchema` taxonomy minus `CHART_FAMILY_WIDGET_TYPES`, so the three sets stay a partition by construction. Mutual exclusion is the pin's own order — the measures check runs before the dimensions one, and rule (e) already skips an empty selection — pinned by tests that assert exactly one finding per widget. Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk Co-authored-by: Claude <noreply@anthropic.com>
1 parent 56fe8c2 commit cd55558

4 files changed

Lines changed: 348 additions & 16 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
`widget-measures-missing` — the empty-measure selection is reported on every widget family, not just charts
6+
7+
`chart-measures-missing` (#15462) reported the authoring placeholder only for the chart
8+
family, but the return that produces it is type-independent. At the `@object-ui` revision
9+
this repo pins (`.objectui-sha` = `a472b0716`), `packages/plugin-dashboard/src/DatasetWidget.tsx:683`
10+
reads `if (values.length === 0)` and returns *"Pick measures (values) for this dataset
11+
widget."* ABOVE `isMetric` (`:423`, over `METRIC_TYPES` at `:343`), `isTable` (`:424`) and
12+
the chart branch alike. So a `metric`, `kpi`, `gauge`, `solid-gauge`, `bullet`, `table` or
13+
`pivot` widget that selects no measures renders the same placeholder — the KPI number or
14+
the table the author declared is not drawn at all — and nothing reported it:
15+
`table-count-only` requires `values.length > 0` before it looks, and the rules that iterate
16+
`dimensions[]`/`values[]` are silent on an empty array by construction.
17+
18+
- **New id `widget-measures-missing`** — a NON-chart declared widget type selects no
19+
measures. Warning tier, suppressible per widget with
20+
`suppressWarnings: ['widget-measures-missing']`, exactly as the chart-family id is. The
21+
message states the consequence its family actually has (the single KPI number is not
22+
drawn / no table is rendered) and the hint names the dataset's declared measures.
23+
- **`chart-measures-missing` is unchanged** — same id, same chart-family population, same
24+
message and same suppression. The condition split rather than widened because "chart"
25+
stops naming it once the population is every family, while the old id is reachable from
26+
the package barrel (a public-surface contract) and may already be written into a board's
27+
`suppressWarnings`.
28+
- `chart-dimensions-missing` stays chart-family only: a dimensionless `metric` or `table`
29+
is what those families are for.
30+
31+
The two never double-report one widget, in the pin's own order: the measures check runs
32+
before the dimensions one, and `table-count-only` already skips an empty selection.

packages/lint/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ export {
2424
// number instead of the declared family).
2525
CHART_MEASURES_MISSING,
2626
CHART_DIMENSIONS_MISSING,
27+
// [#15508] The measures half of that pair, for the families the pin routes
28+
// through the SAME placeholder without drawing a chart: `metric`/`kpi`/
29+
// `gauge`/`solid-gauge`/`bullet` and `table`/`pivot`. A separate id because
30+
// "chart" does not name their condition; the chart-family id above is
31+
// unchanged, so a board suppressing it keeps working.
32+
WIDGET_MEASURES_MISSING,
2733
TABLE_COUNT_ONLY,
2834
MEASURE_AGGREGATE_INCOHERENT,
2935
WIDGET_LEGACY_ANALYTICS_SHAPE,

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

Lines changed: 190 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
CHART_FAMILY_WIDGET_TYPES,
1010
CHART_MEASURES_MISSING,
1111
CHART_DIMENSIONS_MISSING,
12+
WIDGET_MEASURES_MISSING,
13+
NON_CHART_DATASET_WIDGET_TYPES,
1214
TABLE_COUNT_ONLY,
1315
WIDGET_DATASET_UNKNOWN,
1416
WIDGET_DIMENSION_UNKNOWN,
@@ -1604,11 +1606,15 @@ describe('chart-measures-missing / chart-dimensions-missing (#15462)', () => {
16041606
}
16051607
});
16061608

1607-
it('a single-value or tabular family with no measures is NOT the measures finding', () => {
1609+
it('[#15508] a single-value or tabular family with no measures reports the FAMILY-NEUTRAL id', () => {
1610+
// Was "reports nothing" when this id was chart-family only (#15462). The
1611+
// pin's `values.length === 0` return stands above every family branch, so
1612+
// these degrade identically — they just do not degrade to a missing CHART,
1613+
// which is why the id they report is the other one.
16081614
for (const type of ['metric', 'kpi', 'gauge', 'solid-gauge', 'bullet', 'table', 'pivot']) {
16091615
const findings = validateWidgetBindings(chartStack({ type, values: [], chartConfig: undefined }));
1610-
expect(rules(findings), `'${type}' must not report a chart-family finding`)
1611-
.not.toContain(CHART_MEASURES_MISSING);
1616+
expect(rules(findings), `'${type}' selects no measures`).toContain(WIDGET_MEASURES_MISSING);
1617+
expect(rules(findings), `'${type}' is not a chart`).not.toContain(CHART_MEASURES_MISSING);
16121618
}
16131619
});
16141620

@@ -1621,21 +1627,30 @@ describe('chart-measures-missing / chart-dimensions-missing (#15462)', () => {
16211627
}
16221628
});
16231629

1624-
it('every chart family reports, and no other family does', () => {
1630+
it('every declared family reports the MEASURES shape; only the chart family reports the dimensions one', () => {
1631+
// [#15508] The two shapes now have different populations, and this sweep is
1632+
// where that division is pinned across the WHOLE taxonomy rather than a
1633+
// sample of it: every declared type reports a measures id (the `:683`
1634+
// return is type-independent), and which of the two ids it is follows the
1635+
// chart-family line exactly.
16251636
for (const type of ChartTypeSchema.options as readonly string[]) {
16261637
const noMeasures = rules(validateWidgetBindings(chartStack({ type, values: [], chartConfig: undefined })));
16271638
const noDims = rules(validateWidgetBindings(chartStack({ type, dimensions: [], chartConfig: undefined })));
1639+
const measuresId = CHART_FAMILY_WIDGET_TYPES.has(type)
1640+
? CHART_MEASURES_MISSING : WIDGET_MEASURES_MISSING;
1641+
const otherId = CHART_FAMILY_WIDGET_TYPES.has(type)
1642+
? WIDGET_MEASURES_MISSING : CHART_MEASURES_MISSING;
1643+
expect(noMeasures, `'${type}' selects no measures`).toContain(measuresId);
1644+
expect(noMeasures, `'${type}' reports one measures id, not both`).not.toContain(otherId);
16281645
if (CHART_FAMILY_WIDGET_TYPES.has(type)) {
1629-
expect(noMeasures, `'${type}' selects no measures`).toContain(CHART_MEASURES_MISSING);
16301646
expect(noDims, `'${type}' selects no dimensions`).toContain(CHART_DIMENSIONS_MISSING);
16311647
} else {
1632-
expect(noMeasures, `'${type}' is not a chart family`).not.toContain(CHART_MEASURES_MISSING);
16331648
expect(noDims, `'${type}' is not a chart family`).not.toContain(CHART_DIMENSIONS_MISSING);
16341649
}
16351650
}
16361651
});
16371652

1638-
it('a widget type outside the taxonomy is judged by neither id', () => {
1653+
it('a widget type outside the taxonomy is judged by none of the three ids', () => {
16391654
expect(validateWidgetBindings(chartStack({ type: 'barr', values: [], dimensions: [], chartConfig: undefined })))
16401655
.toEqual([]);
16411656
});
@@ -1699,6 +1714,174 @@ describe('chart-measures-missing / chart-dimensions-missing (#15462)', () => {
16991714
});
17001715
});
17011716

1717+
/**
1718+
* [#15508] The same empty-selection SHAPE on every other declared family. The
1719+
* pinned `values.length === 0` return (`DatasetWidget.tsx:683` at
1720+
* `.objectui-sha` = `a472b0716`) is type-independent and stands above
1721+
* `isMetric` (`:423`), `isTable` (`:424`) and the chart branch alike, so a
1722+
* `metric`/`kpi`/`gauge`/`solid-gauge`/`bullet` or `table`/`pivot` widget with
1723+
* no measures renders the same authoring placeholder — the KPI number or the
1724+
* table is simply not drawn. `chart-measures-missing` keeps the chart family
1725+
* (its message, its id and its suppression are untouched here); this id takes
1726+
* the rest.
1727+
*/
1728+
describe('widget-measures-missing (#15508)', () => {
1729+
const rules = (findings: { rule: string }[]) => findings.map((f) => f.rule);
1730+
1731+
it('warns when a `metric` tile selects no measures, naming the KPI consequence', () => {
1732+
const findings = validateWidgetBindings(chartStack({ type: 'metric', values: [], chartConfig: undefined }));
1733+
expect(findings).toHaveLength(1);
1734+
expect(findings[0].severity).toBe('warning');
1735+
expect(findings[0].rule).toBe(WIDGET_MEASURES_MISSING);
1736+
expect(findings[0].where).toContain('spend_by_category');
1737+
expect(findings[0].path).toBe('dashboards[0].widgets[0]');
1738+
// The PINNED placeholder string, quoted — not an inferred consequence.
1739+
expect(findings[0].message).toContain('Pick measures (values) for this dataset widget.');
1740+
expect(findings[0].message).toContain('the single KPI number this tile is for is not drawn at all');
1741+
// ...and NOT the chart family's consequence, which would be false here.
1742+
expect(findings[0].message).not.toContain('no chart is drawn');
1743+
expect(findings[0].hint).toContain('declared measures: sum_amount, ticket_count');
1744+
expect(findings[0].hint).toContain(`suppressWarnings: ['${WIDGET_MEASURES_MISSING}']`);
1745+
});
1746+
1747+
it('warns when a `table` selects no measures, naming the TABLE consequence', () => {
1748+
const findings = validateWidgetBindings(chartStack({ type: 'table', values: [], chartConfig: undefined }));
1749+
expect(rules(findings)).toEqual([WIDGET_MEASURES_MISSING]);
1750+
expect(findings[0].message).toContain('no table is rendered at all');
1751+
expect(findings[0].message).not.toContain('KPI number');
1752+
});
1753+
1754+
it('the `bar` control is unchanged — the chart family keeps its own id and wording', () => {
1755+
const findings = validateWidgetBindings(chartStack({ type: 'bar', values: [], chartConfig: undefined }));
1756+
expect(rules(findings)).toEqual([CHART_MEASURES_MISSING]);
1757+
expect(findings[0].message).toContain('no chart is drawn at all');
1758+
});
1759+
1760+
it('an absent `values` key reports the same shape as an empty array', () => {
1761+
const stack = chartStack({ type: 'metric', chartConfig: undefined });
1762+
delete (stack as { dashboards: { widgets: Record<string, unknown>[] }[] })
1763+
.dashboards[0].widgets[0].values;
1764+
expect(rules(validateWidgetBindings(stack))).toEqual([WIDGET_MEASURES_MISSING]);
1765+
});
1766+
1767+
it('is suppressible per widget, and the two ids do not suppress each other', () => {
1768+
expect(validateWidgetBindings(chartStack({
1769+
type: 'metric', values: [], chartConfig: undefined,
1770+
suppressWarnings: [WIDGET_MEASURES_MISSING],
1771+
}))).toHaveLength(0);
1772+
// A board carrying the #15462 spelling keeps suppressing the CHART id — the
1773+
// reason that id was kept rather than renamed.
1774+
expect(validateWidgetBindings(chartStack({
1775+
values: [], chartConfig: undefined, suppressWarnings: [CHART_MEASURES_MISSING],
1776+
}))).toHaveLength(0);
1777+
// ...and does not reach across the family line in either direction.
1778+
expect(rules(validateWidgetBindings(chartStack({
1779+
type: 'metric', values: [], chartConfig: undefined,
1780+
suppressWarnings: [CHART_MEASURES_MISSING],
1781+
})))).toEqual([WIDGET_MEASURES_MISSING]);
1782+
expect(rules(validateWidgetBindings(chartStack({
1783+
values: [], chartConfig: undefined, suppressWarnings: [WIDGET_MEASURES_MISSING],
1784+
})))).toEqual([CHART_MEASURES_MISSING]);
1785+
});
1786+
1787+
it('a `metric` with NO measures and NO dimensions reports exactly one finding', () => {
1788+
// The ordering proof. `values.length === 0` returns at `:683` above the
1789+
// `isMetric` test, so the dimensions id cannot also be true of this widget
1790+
// — and a dimensionless `metric` is not a defect at all (the shipped
1791+
// `system_overview` tiles are that shape). The hint therefore does NOT
1792+
// steer toward a dimension the way the chart-family hint does.
1793+
const findings = validateWidgetBindings(chartStack({
1794+
type: 'metric', values: [], dimensions: [], chartConfig: undefined,
1795+
}));
1796+
expect(findings).toHaveLength(1);
1797+
expect(findings[0].rule).toBe(WIDGET_MEASURES_MISSING);
1798+
expect(findings[0].hint).not.toContain(CHART_DIMENSIONS_MISSING);
1799+
expect(findings[0].hint).toContain('needs no `dimensions`');
1800+
});
1801+
1802+
it('a `table` with NO measures and NO dimensions reports once — not also table-count-only', () => {
1803+
// Rule (e) `continue`s on `values.length === 0` before it resolves a
1804+
// measure, so the two never double-report the same widget. Without that
1805+
// ordering this input is exactly `table-count-only`'s other precondition.
1806+
const findings = validateWidgetBindings(chartStack({
1807+
type: 'table', values: [], dimensions: [], chartConfig: undefined,
1808+
}));
1809+
expect(rules(findings)).toEqual([WIDGET_MEASURES_MISSING]);
1810+
expect(rules(findings)).not.toContain(TABLE_COUNT_ONLY);
1811+
});
1812+
1813+
it('a non-chart family that DOES select a measure is clean', () => {
1814+
for (const type of NON_CHART_DATASET_WIDGET_TYPES) {
1815+
const findings = validateWidgetBindings(chartStack({ type, dimensions: [], chartConfig: undefined }));
1816+
const mine = findings.filter((f) => f.rule === WIDGET_MEASURES_MISSING);
1817+
expect(mine, `'${type}' selecting one measure must be clean`).toEqual([]);
1818+
}
1819+
});
1820+
1821+
it('the population is the taxonomy minus the chart family — a partition, derived', () => {
1822+
// Same discipline as `CHART_FAMILY_WIDGET_TYPES`: never hand-listed, so a
1823+
// family added to the taxonomy or to either exception set lands on exactly
1824+
// one side of the line without a second edit.
1825+
expect([...NON_CHART_DATASET_WIDGET_TYPES].sort())
1826+
.toEqual([...METRIC_WIDGET_TYPES, ...TABULAR_WIDGET_TYPES].sort());
1827+
for (const type of NON_CHART_DATASET_WIDGET_TYPES) {
1828+
expect(ChartTypeSchema.options, `'${type}' is not a declared chart type`).toContain(type);
1829+
expect(CHART_FAMILY_WIDGET_TYPES.has(type), `'${type}' cannot be both`).toBe(false);
1830+
}
1831+
const union = new Set([...CHART_FAMILY_WIDGET_TYPES, ...NON_CHART_DATASET_WIDGET_TYPES]);
1832+
expect([...union].sort()).toEqual([...(ChartTypeSchema.options as readonly string[])].sort());
1833+
});
1834+
1835+
it('a measureless clone of a SHIPPED `system_overview` tile is reported', () => {
1836+
// The half #15462 could not see. `widget_total_users` is a `metric` bound
1837+
// to `sys_user_metrics`; drop its `values` and the board renders the
1838+
// authoring placeholder where the KPI belongs, which is the degradation
1839+
// this id exists to name. (The real tile selects `user_count` and stays
1840+
// clean — pinned in the #15462 block above.)
1841+
const findings = validateWidgetBindings({
1842+
datasets: [{
1843+
name: 'sys_user_metrics',
1844+
label: 'User Metrics',
1845+
object: 'sys_user',
1846+
dimensions: [{ name: 'is_active', label: 'Active', field: 'is_active', type: 'boolean' }],
1847+
measures: [{ name: 'user_count', label: 'Users', aggregate: 'count' }],
1848+
}],
1849+
dashboards: [{
1850+
name: 'system_overview',
1851+
label: 'System Overview',
1852+
widgets: [{
1853+
id: 'widget_total_users',
1854+
dataset: 'sys_user_metrics', values: [],
1855+
title: 'Total Users',
1856+
type: 'metric',
1857+
layout: { x: 0, y: 0, w: 3, h: 2 },
1858+
}],
1859+
}],
1860+
});
1861+
expect(rules(findings)).toEqual([WIDGET_MEASURES_MISSING]);
1862+
expect(findings[0].hint).toContain('declared measures: user_count');
1863+
});
1864+
});
1865+
1866+
/**
1867+
* [#15508] The tier the ruling carried over from #15462, pinned the same way:
1868+
* a half-authored `metric` or `table` must not gate a build.
1869+
*/
1870+
describe('#15508 acceptance — the family-neutral id advises, never gates', () => {
1871+
const noMeasures = chartStack({ type: 'metric', values: [], chartConfig: undefined });
1872+
const tableNoMeasures = chartStack({ type: 'table', values: [], chartConfig: undefined });
1873+
1874+
for (const command of ['validate', 'build'] as const) {
1875+
it(`widget-measures-missing advises (never gates) \`${command}\``, () => {
1876+
for (const stack of [noMeasures, tableNoMeasures]) {
1877+
const { errors, advisories } = splitBySeverity(runAuthoringRules(command, { normalized: stack }));
1878+
expect(errors.map((f) => f.rule)).not.toContain(WIDGET_MEASURES_MISSING);
1879+
expect(advisories.map((f) => f.rule)).toContain(WIDGET_MEASURES_MISSING);
1880+
}
1881+
});
1882+
}
1883+
});
1884+
17021885
/**
17031886
* [#15462] Tier, pinned end-to-end rather than inferred from the constant: both
17041887
* ids ride the advisory channel on `validate` AND `build`. Shape 1 was the one

0 commit comments

Comments
 (0)