Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/chart-measure-unknown-presentation-positions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@objectstack/lint": patch
---

`chart-measure-unknown` no longer blocks a build over a chart `series[].name` (or a page chart's `yAxis[].field`) that names nothing — those positions are presentation, and the message now says so.

The rule fired at `error` on every measure position of the three chart surfaces it covers, with one consequence sentence: *"result rows are keyed by MEASURE NAME … so this series comes back empty"*. Read at the `@object-ui` revision this repo pins (`.objectui-sha`), that is true only where the position feeds the dataset query, and the three surfaces do not agree:

- **Report charts** run the chart's own query out of the two axis strings (`useDatasetRows(dataset, [xAxis], [yAxis], …)` — *"the embedded chart queries only `chart.xAxis` × `chart.yAxis`"*), so `chart.xAxis`/`chart.yAxis` are the binding. `chart.series[]` is *"the author's per-chart override for ONE measure's display name"*, lowered through `mergeAuthoredSeries`, where *"an authored entry naming a measure that is NOT in the dataset selection is **ignored** — membership belongs to the dataset"*.
- **List-view charts** have no presentation position at all: `ListChartConfigSchema` is a strict object of `chartType`/`dataset`/`dimensions`/`values`, and `values[]` is handed to the chart as the dataset measures.
- **Dataset-bound page chart components** query `{ dimensions, measures: values }` and then replace the authored series wholesale with one derived entry per selected measure, so `properties.series[].name` reaches the renderer not at all and `properties.yAxis[].field` re-points nothing.

**Behaviour change users see:** the three presentation positions — report `chart.series[].name`, page-component `properties.series[].name` and `properties.yAxis[].field` — drop from `error` to `warning`. A build or a metadata publish that used to be refused because of one of them now succeeds, with the finding on the advisory channel. The finding is KEPT, not deleted: the metadata really is wrong — the author wrote a key and believes it is in force. Every query position (report `chart.yAxis`, and `values[]` on all three surfaces) keeps `error` and its existing message verbatim.

Two smaller corrections ride along, both from the same read:

- The page surface's `yAxis[].field` refs are no longer concatenated into the `series[]` limb before the measure walk, so an axis position no longer takes the series message. Reading both shapes on that surface stays deliberate; giving them one sentence was not.
- `chart-axis-not-selected` (a declared measure outside the selection) took the same one-size consequence, *"the query does not return it, so the series plots nothing"*. It keeps that wording at a query position and states the real one at a presentation position, where no series is derived for the name in the first place.

Note that none of these three surfaces declares `suppressWarnings` — it is a dashboard-widget key — so the new advisories cannot be individually silenced; the hint says so instead of pointing at a key that does not exist.
299 changes: 299 additions & 0 deletions packages/lint/src/validate-chart-bindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,260 @@ describe('validateChartBindings — report charts', () => {
});
expect(findings).toHaveLength(1);
expect(findings[0].path).toBe('reports[0].chart.series[0].name');
// #15575 — still reported, one tier down: see the block below for why.
expect(findings[0].severity).toBe('warning');
});
});

/**
* #15575 — the per-position tier and consequence, pinned per surface against
* the `@object-ui` revision `.objectui-sha` names. Each title names the
* renderer line that decides it, as the `chart-field-unknown` tests do: the
* tier follows that measurement, and a test that does not name it cannot be
* re-checked when the pin moves.
*/
describe('validateChartBindings — binding vs presentation positions (#15575)', () => {
const reportWith = (chart: Record<string, unknown>) => ({
...baseStack(),
reports: [
{ name: 'r', dataset: 'task_metrics', values: ['task_count'], chart },
],
});

const pageWith = (properties: Record<string, unknown>) => ({
...baseStack(),
pages: [
{
name: 'p',
regions: [
{ name: 'main', components: [{ type: 'object-chart', properties }] },
],
},
],
});

// ── report charts ──────────────────────────────────────────────────────
it('report chart.yAxis stays ERROR — DatasetReportRenderer runs `useDatasetRows(dataset, [xAxis], [yAxis], …)`, i.e. "the embedded chart queries only `chart.xAxis` × `chart.yAxis`"', () => {
const findings = validateChartBindings(
reportWith({ type: 'bar', xAxis: 'status', yAxis: 'estimate_hours' }),
);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(CHART_MEASURE_UNKNOWN);
expect(findings[0].severity).toBe('error');
expect(findings[0].path).toBe('reports[0].chart.yAxis');
expect(findings[0].message).toContain('this series comes back empty');
});

it('report chart.series[].name is WARNING — `mergeAuthoredSeries` pairs an authored entry with the derived series whose key it EQUALS, so "an authored entry naming a measure that is NOT in the dataset selection is ignored"', () => {
const findings = validateChartBindings(
reportWith({
type: 'bar',
xAxis: 'status',
yAxis: 'task_count',
series: [{ name: 'estimate_hours', color: '#f00' }],
}),
);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(CHART_MEASURE_UNKNOWN);
expect(findings[0].severity).toBe('warning');
expect(findings[0].path).toBe('reports[0].chart.series[0].name');
// The consequence the pin actually has — and NOT the one it refutes.
expect(findings[0].message).toContain('DISPLAY-NAME override');
expect(findings[0].message).toContain('lands on nothing');
expect(findings[0].message).not.toContain('comes back empty');
// No surface here carries `suppressWarnings` (dashboard widgets only), and
// the hint says so rather than advertising a key that does not exist.
expect(findings[0].hint).toContain('no `suppressWarnings` key');
});

it('one report chart reports BOTH tiers — the query position gates, the presentation position advises', () => {
const findings = validateChartBindings(
reportWith({
type: 'bar',
xAxis: 'status',
yAxis: 'estimate_hours',
series: [{ name: 'ghost' }],
}),
);
expect(findings.map((f) => [f.path, f.severity])).toEqual([
['reports[0].chart.yAxis', 'error'],
['reports[0].chart.series[0].name', 'warning'],
]);
});

it('chart-axis-not-selected at a report series position drops the query sentence — the chart derives ONE series, from its own `chart.yAxis`', () => {
const findings = validateChartBindings(
reportWith({
type: 'bar',
xAxis: 'status',
yAxis: 'task_count',
series: [{ name: 'est_hours' }],
}),
);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(CHART_AXIS_NOT_SELECTED);
expect(findings[0].severity).toBe('warning');
expect(findings[0].message).toContain('display-name override');
expect(findings[0].message).not.toContain('the query does not return it');
expect(findings[0].hint).toContain('chart.yAxis');
});

it('chart-axis-not-selected at the report yAxis position keeps its wording — that position IS the query', () => {
const findings = validateChartBindings(
reportWith({ type: 'bar', xAxis: 'status', yAxis: 'est_hours' }),
);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(CHART_AXIS_NOT_SELECTED);
expect(findings[0].message).toContain('the query does not return it');
});

// ── list-view charts ───────────────────────────────────────────────────
it('list chart values[] stays ERROR — ObjectView hands `values` to the chart as the dataset measures (`values: vals`), and the series list is synthesised FROM it', () => {
const findings = validateChartBindings({
...baseStack(),
views: [
{
name: 'v',
list: {
chart: {
chartType: 'bar',
dataset: 'task_metrics',
dimensions: ['status'],
values: ['estimate_hours'],
},
},
},
],
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(CHART_MEASURE_UNKNOWN);
expect(findings[0].severity).toBe('error');
expect(findings[0].path).toBe('views[0].list.chart.values[0]');
});

it('a list chart has NO presentation position at all — `ListChartConfigSchema` is a strict object of chartType/dataset/dimensions/values, so a stray `series` key is not this rule to report', () => {
const findings = validateChartBindings({
...baseStack(),
views: [
{
name: 'v',
list: {
chart: {
chartType: 'bar',
dataset: 'task_metrics',
dimensions: ['status'],
values: ['task_count'],
// Not declared by the schema — the strict parse refuses it, and
// this rule stays silent rather than inventing a second verdict.
series: [{ name: 'ghost_measure' }],
},
},
},
],
});
expect(findings).toEqual([]);
});

// ── dataset-bound page chart components ────────────────────────────────
it('page component series[].name is WARNING — ObjectChart REPLACES the authored array (`series: datasetChart.series`, one entry per selected measure)', () => {
const findings = validateChartBindings(
pageWith({
dataset: 'task_metrics',
dimensions: ['status'],
values: ['task_count'],
series: [{ name: 'ghost_measure', stack: 'a' }],
}),
);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(CHART_MEASURE_UNKNOWN);
expect(findings[0].severity).toBe('warning');
expect(findings[0].path).toBe('pages[0].regions[0].components[0].properties.series[0].name');
expect(findings[0].message).toContain('REPLACES the authored array');
expect(findings[0].message).not.toContain('comes back empty');
});

it('page component yAxis[].field takes the AXIS sentence, not the series one — the two limbs no longer share a message', () => {
const findings = validateChartBindings(
pageWith({
dataset: 'task_metrics',
dimensions: ['status'],
values: ['task_count'],
yAxis: [{ field: 'ghost_measure', stepSize: 1 }],
}),
);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(CHART_MEASURE_UNKNOWN);
expect(findings[0].severity).toBe('warning');
expect(findings[0].path).toBe('pages[0].regions[0].components[0].properties.yAxis[0].field');
expect(findings[0].message).toContain('axis PRESENTATION');
expect(findings[0].message).toContain('the plotted columns come from `values`');
expect(findings[0].message).not.toContain('REPLACES the authored array');
});

it('page component values[] stays ERROR — ObjectChart queries `{ dimensions: schema.dimensions, measures: schema.values }`', () => {
const findings = validateChartBindings(
pageWith({
dataset: 'task_metrics',
dimensions: ['status'],
values: ['estimate_hours'],
}),
);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(CHART_MEASURE_UNKNOWN);
expect(findings[0].severity).toBe('error');
expect(findings[0].path).toBe('pages[0].regions[0].components[0].properties.values[0]');
expect(findings[0].message).toContain('this series comes back empty');
});

it('chart-axis-not-selected on the page surface names the derivation, per limb', () => {
const findings = validateChartBindings(
pageWith({
dataset: 'task_metrics',
dimensions: ['status'],
values: ['task_count'],
yAxis: [{ field: 'est_hours' }],
series: [{ name: 'est_hours' }],
}),
);
expect(findings.map((f) => [f.path, f.rule, f.severity])).toEqual([
[
'pages[0].regions[0].components[0].properties.yAxis[0].field',
CHART_AXIS_NOT_SELECTED,
'warning',
],
[
'pages[0].regions[0].components[0].properties.series[0].name',
CHART_AXIS_NOT_SELECTED,
'warning',
],
]);
expect(findings[0].message).toContain('the axis entry re-points nothing');
expect(findings[1].message).toContain('the series are derived from `values`');
expect(findings[0].message).not.toContain('the query does not return it');
expect(findings[1].message).not.toContain('the query does not return it');
});

it('the advisory positions never gate — every presentation finding this rule can raise is below `error`', () => {
const findings = validateChartBindings(
pageWith({
dataset: 'task_metrics',
dimensions: ['status'],
values: ['task_count'],
yAxis: [{ field: 'ghost_a' }],
series: [{ name: 'ghost_b' }],
}),
).concat(
validateChartBindings(
reportWith({
type: 'bar',
xAxis: 'status',
yAxis: 'task_count',
series: [{ name: 'ghost_c' }],
}),
),
);
expect(findings).toHaveLength(3);
expect(findings.every((f) => f.severity === 'warning')).toBe(true);
});
});

Expand Down Expand Up @@ -281,6 +535,8 @@ describe('validateChartBindings — dataset-bound page chart components', () =>
expect(findings[0].path).toBe(
'pages[0].regions[0].components[0].properties.yAxis[0].field',
);
// #15575 — presentation on this surface, so advisory rather than gating.
expect(findings[0].severity).toBe('warning');
});

it('accepts a resolved page chart', () => {
Expand Down Expand Up @@ -353,6 +609,49 @@ describe('validateChartBindings — floor', () => {
expect(findings).toEqual([]);
});

// The #15636 seam this file carries: its collection reader was a hand-copied
// `asArray` whose array branch was an unchecked cast, so a junk member was
// DEREFERENCED (`strName(entry.name)` on `null`) rather than skipped and the
// rule threw where it should have reported. Now `recordsOf`, which filters.
it('survives a null member in every collection it reads — skipped, not dereferenced', () => {
const stack = {
datasets: [
null,
{
name: 'task_metrics',
object: 'showcase_task',
dimensions: [null, { name: 'status', field: 'status' }],
measures: [null, { name: 'task_count', aggregate: 'count' }],
},
],
reports: [
null,
{
name: 'r',
dataset: 'task_metrics',
values: ['task_count'],
chart: {
type: 'bar',
xAxis: 'status',
yAxis: 'task_count',
series: [null, { name: 'ghost_measure' }],
},
},
],
} as unknown as Record<string, unknown>;

const findings = validateChartBindings(stack);
// The junk members are gone rather than fatal, and the real declarations
// around them still resolve: `status`/`task_count` are found (no unknown-
// ref finding for either), and the one genuine defect is still reported.
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(CHART_MEASURE_UNKNOWN);
expect(findings[0].severity).toBe('warning');
// Positions are indexes into the FILTERED collection — a dropped member
// shifts them, which is the honest price of not crashing on junk.
expect(findings[0].path).toBe('reports[0].chart.series[0].name');
});

it('does not mistake a tree view named "org_chart" for a chart', () => {
const findings = validateChartBindings({
...baseStack(),
Expand Down
Loading
Loading