diff --git a/.changeset/widget-measures-missing-every-family.md b/.changeset/widget-measures-missing-every-family.md new file mode 100644 index 0000000000..97d5c88d9d --- /dev/null +++ b/.changeset/widget-measures-missing-every-family.md @@ -0,0 +1,32 @@ +--- +'@objectstack/lint': minor +--- + +`widget-measures-missing` — the empty-measure selection is reported on every widget family, not just charts + +`chart-measures-missing` (#15462) reported the authoring placeholder only for the chart +family, but the return that produces it is type-independent. At the `@object-ui` revision +this repo pins (`.objectui-sha` = `a472b0716`), `packages/plugin-dashboard/src/DatasetWidget.tsx:683` +reads `if (values.length === 0)` and returns *"Pick measures (values) for this dataset +widget."* ABOVE `isMetric` (`:423`, over `METRIC_TYPES` at `:343`), `isTable` (`:424`) and +the chart branch alike. So a `metric`, `kpi`, `gauge`, `solid-gauge`, `bullet`, `table` or +`pivot` widget that selects no measures renders the same placeholder — the KPI number or +the table the author declared is not drawn at all — and nothing reported it: +`table-count-only` requires `values.length > 0` before it looks, and the rules that iterate +`dimensions[]`/`values[]` are silent on an empty array by construction. + +- **New id `widget-measures-missing`** — a NON-chart declared widget type selects no + measures. Warning tier, suppressible per widget with + `suppressWarnings: ['widget-measures-missing']`, exactly as the chart-family id is. The + message states the consequence its family actually has (the single KPI number is not + drawn / no table is rendered) and the hint names the dataset's declared measures. +- **`chart-measures-missing` is unchanged** — same id, same chart-family population, same + message and same suppression. The condition split rather than widened because "chart" + stops naming it once the population is every family, while the old id is reachable from + the package barrel (a public-surface contract) and may already be written into a board's + `suppressWarnings`. +- `chart-dimensions-missing` stays chart-family only: a dimensionless `metric` or `table` + is what those families are for. + +The two never double-report one widget, in the pin's own order: the measures check runs +before the dimensions one, and `table-count-only` already skips an empty selection. diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 2051ba3fe2..6a673ce439 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -24,6 +24,12 @@ export { // number instead of the declared family). CHART_MEASURES_MISSING, CHART_DIMENSIONS_MISSING, + // [#15508] The measures half of that pair, for the families the pin routes + // through the SAME placeholder without drawing a chart: `metric`/`kpi`/ + // `gauge`/`solid-gauge`/`bullet` and `table`/`pivot`. A separate id because + // "chart" does not name their condition; the chart-family id above is + // unchanged, so a board suppressing it keeps working. + WIDGET_MEASURES_MISSING, TABLE_COUNT_ONLY, MEASURE_AGGREGATE_INCOHERENT, WIDGET_LEGACY_ANALYTICS_SHAPE, diff --git a/packages/lint/src/validate-widget-bindings.test.ts b/packages/lint/src/validate-widget-bindings.test.ts index 4db4ca5b80..2672bf9300 100644 --- a/packages/lint/src/validate-widget-bindings.test.ts +++ b/packages/lint/src/validate-widget-bindings.test.ts @@ -9,6 +9,8 @@ import { CHART_FAMILY_WIDGET_TYPES, CHART_MEASURES_MISSING, CHART_DIMENSIONS_MISSING, + WIDGET_MEASURES_MISSING, + NON_CHART_DATASET_WIDGET_TYPES, TABLE_COUNT_ONLY, WIDGET_DATASET_UNKNOWN, WIDGET_DIMENSION_UNKNOWN, @@ -1604,11 +1606,15 @@ describe('chart-measures-missing / chart-dimensions-missing (#15462)', () => { } }); - it('a single-value or tabular family with no measures is NOT the measures finding', () => { + it('[#15508] a single-value or tabular family with no measures reports the FAMILY-NEUTRAL id', () => { + // Was "reports nothing" when this id was chart-family only (#15462). The + // pin's `values.length === 0` return stands above every family branch, so + // these degrade identically — they just do not degrade to a missing CHART, + // which is why the id they report is the other one. for (const type of ['metric', 'kpi', 'gauge', 'solid-gauge', 'bullet', 'table', 'pivot']) { const findings = validateWidgetBindings(chartStack({ type, values: [], chartConfig: undefined })); - expect(rules(findings), `'${type}' must not report a chart-family finding`) - .not.toContain(CHART_MEASURES_MISSING); + expect(rules(findings), `'${type}' selects no measures`).toContain(WIDGET_MEASURES_MISSING); + expect(rules(findings), `'${type}' is not a chart`).not.toContain(CHART_MEASURES_MISSING); } }); @@ -1621,21 +1627,30 @@ describe('chart-measures-missing / chart-dimensions-missing (#15462)', () => { } }); - it('every chart family reports, and no other family does', () => { + it('every declared family reports the MEASURES shape; only the chart family reports the dimensions one', () => { + // [#15508] The two shapes now have different populations, and this sweep is + // where that division is pinned across the WHOLE taxonomy rather than a + // sample of it: every declared type reports a measures id (the `:683` + // return is type-independent), and which of the two ids it is follows the + // chart-family line exactly. for (const type of ChartTypeSchema.options as readonly string[]) { const noMeasures = rules(validateWidgetBindings(chartStack({ type, values: [], chartConfig: undefined }))); const noDims = rules(validateWidgetBindings(chartStack({ type, dimensions: [], chartConfig: undefined }))); + const measuresId = CHART_FAMILY_WIDGET_TYPES.has(type) + ? CHART_MEASURES_MISSING : WIDGET_MEASURES_MISSING; + const otherId = CHART_FAMILY_WIDGET_TYPES.has(type) + ? WIDGET_MEASURES_MISSING : CHART_MEASURES_MISSING; + expect(noMeasures, `'${type}' selects no measures`).toContain(measuresId); + expect(noMeasures, `'${type}' reports one measures id, not both`).not.toContain(otherId); if (CHART_FAMILY_WIDGET_TYPES.has(type)) { - expect(noMeasures, `'${type}' selects no measures`).toContain(CHART_MEASURES_MISSING); expect(noDims, `'${type}' selects no dimensions`).toContain(CHART_DIMENSIONS_MISSING); } else { - expect(noMeasures, `'${type}' is not a chart family`).not.toContain(CHART_MEASURES_MISSING); expect(noDims, `'${type}' is not a chart family`).not.toContain(CHART_DIMENSIONS_MISSING); } } }); - it('a widget type outside the taxonomy is judged by neither id', () => { + it('a widget type outside the taxonomy is judged by none of the three ids', () => { expect(validateWidgetBindings(chartStack({ type: 'barr', values: [], dimensions: [], chartConfig: undefined }))) .toEqual([]); }); @@ -1699,6 +1714,174 @@ describe('chart-measures-missing / chart-dimensions-missing (#15462)', () => { }); }); +/** + * [#15508] The same empty-selection SHAPE on every other declared family. The + * pinned `values.length === 0` return (`DatasetWidget.tsx:683` at + * `.objectui-sha` = `a472b0716`) is type-independent and stands above + * `isMetric` (`:423`), `isTable` (`:424`) and the chart branch alike, so a + * `metric`/`kpi`/`gauge`/`solid-gauge`/`bullet` or `table`/`pivot` widget with + * no measures renders the same authoring placeholder — the KPI number or the + * table is simply not drawn. `chart-measures-missing` keeps the chart family + * (its message, its id and its suppression are untouched here); this id takes + * the rest. + */ +describe('widget-measures-missing (#15508)', () => { + const rules = (findings: { rule: string }[]) => findings.map((f) => f.rule); + + it('warns when a `metric` tile selects no measures, naming the KPI consequence', () => { + const findings = validateWidgetBindings(chartStack({ type: 'metric', values: [], chartConfig: undefined })); + expect(findings).toHaveLength(1); + expect(findings[0].severity).toBe('warning'); + expect(findings[0].rule).toBe(WIDGET_MEASURES_MISSING); + expect(findings[0].where).toContain('spend_by_category'); + expect(findings[0].path).toBe('dashboards[0].widgets[0]'); + // The PINNED placeholder string, quoted — not an inferred consequence. + expect(findings[0].message).toContain('Pick measures (values) for this dataset widget.'); + expect(findings[0].message).toContain('the single KPI number this tile is for is not drawn at all'); + // ...and NOT the chart family's consequence, which would be false here. + expect(findings[0].message).not.toContain('no chart is drawn'); + expect(findings[0].hint).toContain('declared measures: sum_amount, ticket_count'); + expect(findings[0].hint).toContain(`suppressWarnings: ['${WIDGET_MEASURES_MISSING}']`); + }); + + it('warns when a `table` selects no measures, naming the TABLE consequence', () => { + const findings = validateWidgetBindings(chartStack({ type: 'table', values: [], chartConfig: undefined })); + expect(rules(findings)).toEqual([WIDGET_MEASURES_MISSING]); + expect(findings[0].message).toContain('no table is rendered at all'); + expect(findings[0].message).not.toContain('KPI number'); + }); + + it('the `bar` control is unchanged — the chart family keeps its own id and wording', () => { + const findings = validateWidgetBindings(chartStack({ type: 'bar', values: [], chartConfig: undefined })); + expect(rules(findings)).toEqual([CHART_MEASURES_MISSING]); + expect(findings[0].message).toContain('no chart is drawn at all'); + }); + + it('an absent `values` key reports the same shape as an empty array', () => { + const stack = chartStack({ type: 'metric', chartConfig: undefined }); + delete (stack as { dashboards: { widgets: Record[] }[] }) + .dashboards[0].widgets[0].values; + expect(rules(validateWidgetBindings(stack))).toEqual([WIDGET_MEASURES_MISSING]); + }); + + it('is suppressible per widget, and the two ids do not suppress each other', () => { + expect(validateWidgetBindings(chartStack({ + type: 'metric', values: [], chartConfig: undefined, + suppressWarnings: [WIDGET_MEASURES_MISSING], + }))).toHaveLength(0); + // A board carrying the #15462 spelling keeps suppressing the CHART id — the + // reason that id was kept rather than renamed. + expect(validateWidgetBindings(chartStack({ + values: [], chartConfig: undefined, suppressWarnings: [CHART_MEASURES_MISSING], + }))).toHaveLength(0); + // ...and does not reach across the family line in either direction. + expect(rules(validateWidgetBindings(chartStack({ + type: 'metric', values: [], chartConfig: undefined, + suppressWarnings: [CHART_MEASURES_MISSING], + })))).toEqual([WIDGET_MEASURES_MISSING]); + expect(rules(validateWidgetBindings(chartStack({ + values: [], chartConfig: undefined, suppressWarnings: [WIDGET_MEASURES_MISSING], + })))).toEqual([CHART_MEASURES_MISSING]); + }); + + it('a `metric` with NO measures and NO dimensions reports exactly one finding', () => { + // The ordering proof. `values.length === 0` returns at `:683` above the + // `isMetric` test, so the dimensions id cannot also be true of this widget + // — and a dimensionless `metric` is not a defect at all (the shipped + // `system_overview` tiles are that shape). The hint therefore does NOT + // steer toward a dimension the way the chart-family hint does. + const findings = validateWidgetBindings(chartStack({ + type: 'metric', values: [], dimensions: [], chartConfig: undefined, + })); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(WIDGET_MEASURES_MISSING); + expect(findings[0].hint).not.toContain(CHART_DIMENSIONS_MISSING); + expect(findings[0].hint).toContain('needs no `dimensions`'); + }); + + it('a `table` with NO measures and NO dimensions reports once — not also table-count-only', () => { + // Rule (e) `continue`s on `values.length === 0` before it resolves a + // measure, so the two never double-report the same widget. Without that + // ordering this input is exactly `table-count-only`'s other precondition. + const findings = validateWidgetBindings(chartStack({ + type: 'table', values: [], dimensions: [], chartConfig: undefined, + })); + expect(rules(findings)).toEqual([WIDGET_MEASURES_MISSING]); + expect(rules(findings)).not.toContain(TABLE_COUNT_ONLY); + }); + + it('a non-chart family that DOES select a measure is clean', () => { + for (const type of NON_CHART_DATASET_WIDGET_TYPES) { + const findings = validateWidgetBindings(chartStack({ type, dimensions: [], chartConfig: undefined })); + const mine = findings.filter((f) => f.rule === WIDGET_MEASURES_MISSING); + expect(mine, `'${type}' selecting one measure must be clean`).toEqual([]); + } + }); + + it('the population is the taxonomy minus the chart family — a partition, derived', () => { + // Same discipline as `CHART_FAMILY_WIDGET_TYPES`: never hand-listed, so a + // family added to the taxonomy or to either exception set lands on exactly + // one side of the line without a second edit. + expect([...NON_CHART_DATASET_WIDGET_TYPES].sort()) + .toEqual([...METRIC_WIDGET_TYPES, ...TABULAR_WIDGET_TYPES].sort()); + for (const type of NON_CHART_DATASET_WIDGET_TYPES) { + expect(ChartTypeSchema.options, `'${type}' is not a declared chart type`).toContain(type); + expect(CHART_FAMILY_WIDGET_TYPES.has(type), `'${type}' cannot be both`).toBe(false); + } + const union = new Set([...CHART_FAMILY_WIDGET_TYPES, ...NON_CHART_DATASET_WIDGET_TYPES]); + expect([...union].sort()).toEqual([...(ChartTypeSchema.options as readonly string[])].sort()); + }); + + it('a measureless clone of a SHIPPED `system_overview` tile is reported', () => { + // The half #15462 could not see. `widget_total_users` is a `metric` bound + // to `sys_user_metrics`; drop its `values` and the board renders the + // authoring placeholder where the KPI belongs, which is the degradation + // this id exists to name. (The real tile selects `user_count` and stays + // clean — pinned in the #15462 block above.) + const findings = validateWidgetBindings({ + datasets: [{ + name: 'sys_user_metrics', + label: 'User Metrics', + object: 'sys_user', + dimensions: [{ name: 'is_active', label: 'Active', field: 'is_active', type: 'boolean' }], + measures: [{ name: 'user_count', label: 'Users', aggregate: 'count' }], + }], + dashboards: [{ + name: 'system_overview', + label: 'System Overview', + widgets: [{ + id: 'widget_total_users', + dataset: 'sys_user_metrics', values: [], + title: 'Total Users', + type: 'metric', + layout: { x: 0, y: 0, w: 3, h: 2 }, + }], + }], + }); + expect(rules(findings)).toEqual([WIDGET_MEASURES_MISSING]); + expect(findings[0].hint).toContain('declared measures: user_count'); + }); +}); + +/** + * [#15508] The tier the ruling carried over from #15462, pinned the same way: + * a half-authored `metric` or `table` must not gate a build. + */ +describe('#15508 acceptance — the family-neutral id advises, never gates', () => { + const noMeasures = chartStack({ type: 'metric', values: [], chartConfig: undefined }); + const tableNoMeasures = chartStack({ type: 'table', values: [], chartConfig: undefined }); + + for (const command of ['validate', 'build'] as const) { + it(`widget-measures-missing advises (never gates) \`${command}\``, () => { + for (const stack of [noMeasures, tableNoMeasures]) { + const { errors, advisories } = splitBySeverity(runAuthoringRules(command, { normalized: stack })); + expect(errors.map((f) => f.rule)).not.toContain(WIDGET_MEASURES_MISSING); + expect(advisories.map((f) => f.rule)).toContain(WIDGET_MEASURES_MISSING); + } + }); + } +}); + /** * [#15462] Tier, pinned end-to-end rather than inferred from the constant: both * ids ride the advisory channel on `validate` AND `build`. Shape 1 was the one diff --git a/packages/lint/src/validate-widget-bindings.ts b/packages/lint/src/validate-widget-bindings.ts index ba6cb1eaa1..5ef6b84a45 100644 --- a/packages/lint/src/validate-widget-bindings.ts +++ b/packages/lint/src/validate-widget-bindings.ts @@ -96,6 +96,14 @@ import { * rather than error because an empty selection is a legitimate * work-in-progress state a build must tolerate; erroring would gate the * `sys_metadata` publish path on a half-authored widget. + * - `widget-measures-missing` (#15508) — a NON-chart dataset widget — a + * single-value family (`metric`/`kpi`/`gauge`/`solid-gauge`/`bullet`) or a + * tabular one (`table`/`pivot`) — selects NO measures. The placeholder above + * is returned for these families too: the pinned `values.length === 0` return + * stands above every family branch, so the KPI number or the table is not + * drawn either. Same warning tier and suppression as the chart-family + * spelling; a separate id because "chart" does not name this condition and + * because the family keeps one id per class. * - `chart-dimensions-missing` — a chart-family widget selects at least one * measure but NO dimensions. The pinned renderer's * `isMetric = METRIC_TYPES.has(widgetType) || dimensions.length === 0` @@ -308,6 +316,37 @@ import { * state an author passes THROUGH, and the family's errors are reserved for * bindings the analytics service cannot satisfy. * + * **The measures shape is every family's, not the chart family's (#15508).** + * Re-read at the pinned `.objectui-sha` (`a472b0716`, where the four cited + * lines still read as quoted above), `:683` is ABOVE `:423`/`:424` and tests + * nothing but `values.length === 0`. So a `metric`, `kpi`, `gauge`, + * `solid-gauge`, `bullet`, `table` or `pivot` widget that selects no measures + * renders that same placeholder: the KPI number or the table the author + * declared is not drawn either, and a missing tile on a board reads as + * "nothing to report" rather than as a defect. Nothing else reported it — + * `table-count-only` requires `values.length > 0` before it looks, and rules + * (b)/(c) iterate the arrays, so an empty one is silent by construction. + * + * The population therefore widens to every DECLARED widget type + * ({@link NON_CHART_DATASET_WIDGET_TYPES} is the taxonomy minus the chart + * family), carried by TWO ids rather than one: + * {@link CHART_MEASURES_MISSING} keeps the chart family, and + * {@link WIDGET_MEASURES_MISSING} takes the rest. Two, because "chart" stops + * naming the condition once the population is every family — a `metric` tile + * is not a chart — while the old id is depended on from OUTSIDE this file (the + * `packages/lint` barrel re-exports it, which + * `rule-id-barrel-exports.test.ts` makes a public-surface contract, and a + * board may already carry `suppressWarnings: ['chart-measures-missing']`). + * Renaming would have retired a reachable id and falsified those references; + * splitting keeps every one of them true and lets each message state the + * consequence its family actually has (no chart drawn / no KPI number drawn / + * no table rendered), which one id could only do by branching anyway. + * + * The DIMENSIONS shape stays chart-family only, and deliberately: a + * dimensionless `metric` or `table` is what those families are FOR (the + * shipped `system_overview` KPI tiles are exactly that shape), and a + * dimensionless `table` already has its own id in `table-count-only`. + * * ### The three refused binding keys (#15463) * * `chart-field-unknown` shipped at `error` with a message that named a QUERY @@ -374,6 +413,16 @@ export const CHART_MEASURES_MISSING = 'chart-measures-missing'; * chart family. */ export const CHART_DIMENSIONS_MISSING = 'chart-dimensions-missing'; +/** + * [#15508] A NON-chart dataset widget — a `METRIC_WIDGET_TYPES` or a + * `TABULAR_WIDGET_TYPES` one — selects no measures, so the pinned renderer + * draws the same "Pick measures (values)" placeholder in place of the KPI + * number or the table. The chart-family spelling of the same shape keeps its + * own id ({@link CHART_MEASURES_MISSING}); see "The two empty-selection + * shapes" in the module docblock for why the population is split across two + * ids rather than carried by one. + */ +export const WIDGET_MEASURES_MISSING = 'widget-measures-missing'; export const TABLE_COUNT_ONLY = 'table-count-only'; export const MEASURE_AGGREGATE_INCOHERENT = 'measure-aggregate-incoherent'; export const WIDGET_LEGACY_ANALYTICS_SHAPE = 'widget-legacy-analytics-shape'; @@ -506,6 +555,34 @@ export function isChartFamilyWidgetType(type: unknown): boolean { return typeof type === 'string' && CHART_FAMILY_WIDGET_TYPES.has(type); } +/** + * [#15508] The other side of the same taxonomy split: every declared + * `ChartTypeSchema` option the pinned renderer does NOT route to its chart + * branch — i.e. `METRIC_WIDGET_TYPES` ∪ `TABULAR_WIDGET_TYPES`, derived by + * subtraction rather than re-listed, so the three sets stay a partition of the + * taxonomy by construction and a family added to either exception set (or to + * the taxonomy itself) lands on exactly one side without a second edit here. + * + * The population that matters for `widget-measures-missing`: the pin's + * `values.length === 0` return (`DatasetWidget.tsx:683`) is type-independent, + * so these families degrade to the same placeholder as a chart does. A widget + * `type` in NEITHER set is not a declared widget type at all, and stays + * unjudged by both ids — the same silence the taxonomy check already gave it. + */ +export const NON_CHART_DATASET_WIDGET_TYPES: ReadonlySet = new Set( + (ChartTypeSchema.options as readonly string[]).filter( + (t) => !CHART_FAMILY_WIDGET_TYPES.has(t), + ), +); + +/** + * [#15508] Does the pinned renderer draw this DECLARED widget `type` as a + * single value or a table rather than as a chart? + */ +export function isNonChartDatasetWidgetType(type: unknown): boolean { + return typeof type === 'string' && NON_CHART_DATASET_WIDGET_TYPES.has(type); +} + function list(names: Iterable): string { const arr = [...names]; return arr.length > 0 ? arr.join(', ') : '(none)'; @@ -1182,7 +1259,7 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { }); } - // ── (d1) a chart-family widget with an empty selection (#15462) ── + // ── (d1) a dataset widget with an empty selection (#15462, #15508) ── // Neither shape is about `chartConfig` — the renderer degrades before it // is ever consulted — so both are their own ids rather than arms of // `chart-config-missing` (which would then misname its own condition). @@ -1190,13 +1267,23 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { // does not resolve is rules (b)/(c)'s finding, and emptiness is a // different question from resolvability. // - // Mutually exclusive, in the pin's own order: `values.length === 0` - // returns the placeholder at `DatasetWidget.tsx:683`, above every family - // branch, so a measureless widget never reaches the `isMetric` test the - // second id describes. Reporting both would attribute to one widget two - // consequences it cannot have at once. - if (isChartFamilyWidgetType(w.type)) { - if (values.length === 0) { + // Mutually exclusive, in the pin's own order, and the ORDER IS THE + // CONTROL FLOW below: `values.length === 0` returns the placeholder at + // `DatasetWidget.tsx:683`, above every family branch, so a measureless + // widget never reaches the `isMetric` test the dimensions id describes. + // Reporting both would attribute to one widget two consequences it + // cannot have at once. Rule (e) below keeps the same discipline for + // `table`/`pivot` — it `continue`s on `values.length === 0` — so a + // measureless table reports once, here. + // + // [#15508] The measures shape is judged on EVERY declared widget type + // (the `:683` return is type-independent); the dimensions shape stays + // chart-family, because a dimensionless single-value or tabular widget is + // what that family is for. Two ids for the one measures shape, split on + // the same family line: see the module docblock for why the chart-family + // id was kept rather than renamed. + if (values.length === 0) { + if (isChartFamilyWidgetType(w.type)) { push({ severity: 'warning', rule: CHART_MEASURES_MISSING, @@ -1215,7 +1302,31 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { ` If the widget is deliberately still being authored, suppress with: ` + `suppressWarnings: ['${CHART_MEASURES_MISSING}']`, }); - } else if (dims.length === 0) { + } else if (isNonChartDatasetWidgetType(w.type)) { + // The consequence is stated per family, because it differs: the + // single-value families lose the NUMBER the tile exists to show, the + // tabular ones lose the grid. The placeholder is the same one, and + // it is quoted from the pin rather than paraphrased. + const drawn = METRIC_WIDGET_TYPES.has(w.type as string) + ? 'the single KPI number this tile is for is not drawn at all' + : 'no table is rendered at all'; + push({ + severity: 'warning', + rule: WIDGET_MEASURES_MISSING, + message: + `'${w.type}' widget selects no measures (\`values\` is empty), so the ` + + `renderer short-circuits to the authoring placeholder "Pick measures ` + + `(values) for this dataset widget." before any query runs — ${drawn}.`, + hint: + `Select at least one measure of dataset "${dsName}" BY NAME — ` + + `values: [''] (declared measures: ${list(measures.keys())}). ` + + `A '${w.type}' widget needs no \`dimensions\`, so measures are the whole ` + + `fix. If the widget is deliberately still being authored, suppress with: ` + + `suppressWarnings: ['${WIDGET_MEASURES_MISSING}']`, + }); + } + } else if (dims.length === 0) { + if (isChartFamilyWidgetType(w.type)) { push({ severity: 'warning', rule: CHART_DIMENSIONS_MISSING,