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
37 changes: 37 additions & 0 deletions .changeset/7402-scatter-ignores-compareto.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
'@object-ui/plugin-charts': minor
'@object-ui/plugin-dashboard': minor
---

`compareTo` on a `scatter` chart is no longer supported — scatter joins pie / donut /
funnel on the list of chart families that ignore it (objectui#7402, maintainer ruling
2026-09-03).

**This removes a published capability, deliberately.** Until now a `chartType: 'scatter'`
chart (and the dashboard widget types `scatter` and `bubble`, which both render as one)
with `compareTo` set synthesised a muted "previous period" overlay series. It drew the
wrong picture: a scatter binds ONE measure, and the renderer reads y through the single
`YAxis dataKey={series[0].dataKey}`, so the overlay was plotted on the PRIMARY series' y
— "previous period" painted exactly on top of "current" (objectui#7194).

Enforce-or-remove: rather than keep drawing that, the capability is removed until it can
be drawn honestly. Drawing a real second measure on a scatter needs the multi-measure
projection recorded as option A of objectui#7194, which is not built (zero authored
callers). **If and when that projection lands, `compareTo` on a scatter returns with
it** — it is the same missing mechanism, one payment.

What changes for authors:

- A `compareTo` on a scatter is now IGNORED rather than drawn. The primary series still
renders exactly as before — nothing refuses, nothing goes blank, and no comparison
query is issued on the inline chart path.
- No `<measure>__comparison` (inline chart) / `<measure>__compare` (dashboard) series is
appended for a scatter, so a compare-to scatter document also never reaches the
two-or-more-series scatter refusal being added under objectui#7194.
- Charts that keep the overlay: line, area, bar, horizontal-bar, combo. Charts that
ignore `compareTo`: pie, donut, funnel and — as of this change — scatter (and the
`bubble` widget type that renders as a scatter).

Reachability at the time of the change: **0** authored scatter/bubble instances in-repo
across both spellings (control `"type": "bar"` fires at 5 example files); incidence in
deployed tenant metadata is not measurable from this repo.
20 changes: 17 additions & 3 deletions packages/plugin-charts/src/ObjectChart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -333,9 +333,23 @@ export const ObjectChart = (props: any) => {
[schema.dataset, schema.dimensions, schema.values],
);

// Pie / donut / funnel are single-distribution charts where a comparison
// overlay would be meaningless — we skip the comparison fetch entirely.
const supportsCompareTo = (ct?: string) => ct !== 'pie' && ct !== 'donut' && ct !== 'funnel';
// Chart families that IGNORE `compareTo`: the comparison fetch is skipped
// entirely, so no `<valueKey>__comparison` column is produced and no overlay
// series is ever synthesised.
//
// - pie / donut / funnel — single-distribution charts where a comparison
// overlay would be meaningless.
// - scatter (objectui#7402) — a scatter binds ONE measure: the renderer
// reads y through the single `YAxis dataKey={series[0].dataKey}`, so the
// synthesised overlay was painted on the PRIMARY's y and "previous
// period" landed exactly on top of "current". Drawing it honestly needs
// the multi-measure projection declined as option A of objectui#7194;
// `compareTo` on a scatter returns WITH that projection. Until then the
// published capability is removed rather than left drawing a wrong
// picture — and because the overlay is never synthesised, a compare-to
// document never reaches #7194's two-or-more-series scatter refusal.
const supportsCompareTo = (ct?: string) =>
ct !== 'pie' && ct !== 'donut' && ct !== 'funnel' && ct !== 'scatter';

// Resolve the category dimension's option colors (P3). Best-effort: any
// failure leaves categoryColors null and the chart keeps the theme palette.
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7402 — a `scatter` IGNORES `compareTo`, exactly as pie / donut /
* funnel do.
*
* The overlay used to be synthesised for a scatter too, and the renderer reads
* y through the single `YAxis dataKey={series[0].dataKey}`: "previous period"
* was therefore painted on the PRIMARY's y, exactly on top of "current". The
* ruling on #7402 removes the published capability rather than keep drawing
* that picture; it returns with the multi-measure projection declined as
* option A of #7194.
*
* What is pinned here is the POSITIVE half of "ignored": the primary series
* (and its data) still reach ChartRenderer, and NO series whose `dataKey` ends
* `__comparison` is synthesised. Asserting only "no refusal" would pass for a
* chart that drew nothing at all.
*
* The `bar` control at the bottom is what makes those absences mean anything:
* a change that disabled `compareTo` everywhere would satisfy every scatter
* assertion above it and fail the control.
*
* Asserted at the schema handed to ChartRenderer — the seam the overlay is
* expressed in — as in `ObjectChart.compareTo.test.tsx`, because Recharts
* draws nothing at jsdom's zero-size container.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';

let lastSchema: any = null;

vi.mock('../ChartRenderer', () => ({
ChartRenderer: (props: any) => {
lastSchema = props.schema;
return null;
},
}));

import { ObjectChart, COMPARISON_SUFFIX } from '../ObjectChart';

/** ObjectChart probes `/api/v1/meta/object/deal` for option colors; answer it. */
function installMetaFetchDouble() {
const calls: string[] = [];
vi.stubGlobal(
'fetch',
vi.fn(async (input: unknown) => {
const url = String(
input && typeof input === 'object' && 'url' in input ? (input as { url: unknown }).url : input,
);
calls.push(url);
return { ok: true, json: async () => ({}) };
}),
);
return calls;
}

let metaCalls: string[] = [];

beforeEach(() => {
metaCalls = installMetaFetchDouble();
});

afterEach(() => {
expect(metaCalls.filter((u) => u !== '/api/v1/meta/object/deal')).toEqual([]);
vi.unstubAllGlobals();
cleanup();
lastSchema = null;
});

const quarterStart = (d: Date) => new Date(d.getFullYear(), Math.floor(d.getMonth() / 3) * 3, 1);
const iso = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const CURRENT_FROM = iso(quarterStart(new Date()));

/** 120 for the current window, 100 for any other window asked for. */
const makeSource = () => ({
aggregate: vi.fn(async (_object: string, q: any) => [
{ stage: 'won', amount: String(q?.filter?.close_date?.$gte) === CURRENT_FROM ? 120 : 100 },
]),
});

const renderChart = (chartType: string, dataSource: unknown, series?: unknown) =>
render(
<ObjectChart
schema={{
objectName: 'deal',
chartType,
aggregate: { field: 'amount', function: 'sum', groupBy: 'stage' },
filter: { close_date: { $gte: '{current_quarter_start}', $lte: '{current_quarter_end}' } },
xAxisKey: 'stage',
compareTo: { kind: 'previousYear' },
...(series ? { series } : {}),
}}
dataSource={dataSource}
/>,
);

/** Every series the chart was handed whose key is a synthesised overlay. */
const overlaySeriesOf = (schema: any) =>
(schema?.series ?? []).filter((s: any) => String(s?.dataKey).endsWith(COMPARISON_SUFFIX));

describe('ObjectChart — a scatter ignores compareTo (#7402)', () => {
it('draws the authored primary series and synthesises NO comparison overlay', async () => {
const src = makeSource();
renderChart('scatter', src, [{ dataKey: 'amount' }]);

await waitFor(() => expect(lastSchema).not.toBeNull());
// The comparison window is never even fetched — the same short-circuit
// pie / donut / funnel take.
expect(src.aggregate).toHaveBeenCalledTimes(1);

// Primary: present, intact, carrying its data.
expect(lastSchema.chartType).toBe('scatter');
expect(lastSchema.series).toHaveLength(1);
expect(lastSchema.series[0]).toMatchObject({ dataKey: 'amount' });
expect(lastSchema.data[0]).toMatchObject({ stage: 'won', amount: 120 });

// Overlay: positively absent — no series, and no column for one to read.
expect(overlaySeriesOf(lastSchema)).toEqual([]);
expect(lastSchema.data[0]).not.toHaveProperty(`amount${COMPARISON_SUFFIX}`);
});

it('adds no overlay when the author wrote no `series` at all', async () => {
// The authored spec for a compare-to scatter has ONE measure and no
// `series` key; the overlay was the only thing that ever put a second
// entry there. So the pin is that `series` stays UNsynthesised.
const src = makeSource();
renderChart('scatter', src);

await waitFor(() => expect(lastSchema).not.toBeNull());
expect(src.aggregate).toHaveBeenCalledTimes(1);
expect(overlaySeriesOf(lastSchema)).toEqual([]);
expect(lastSchema.data[0]).toMatchObject({ stage: 'won', amount: 120 });
expect(lastSchema.data[0]).not.toHaveProperty(`amount${COMPARISON_SUFFIX}`);
});

it('CONTROL: a bar with the same compareTo still synthesises the overlay', async () => {
// Without this, a regression that switched `compareTo` off for every chart
// type would pass both assertions above.
const src = makeSource();
renderChart('bar', src);

await waitFor(() => expect(src.aggregate).toHaveBeenCalledTimes(2));
await waitFor(() => expect(lastSchema?.series?.length).toBe(2));
expect(lastSchema.series[0]).toMatchObject({ dataKey: 'amount', variant: 'current' });
expect(overlaySeriesOf(lastSchema)).toHaveLength(1);
expect(overlaySeriesOf(lastSchema)[0]).toMatchObject({
dataKey: `amount${COMPARISON_SUFFIX}`,
variant: 'comparison',
});
expect(lastSchema.data[0]).toMatchObject({ amount: 120, [`amount${COMPARISON_SUFFIX}`]: 100 });
});
});
10 changes: 7 additions & 3 deletions packages/plugin-dashboard/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,9 +21,9 @@ executor re-runs the same selection over the shifted window and attaches a

- For **metric** & **gauge** widgets, a delta percentage surfaced as a `trend`
indicator (overrides any static `trend` prop).
- For **chart** widgets (line / area / bar / horizontal-bar / scatter / combo),
a muted second series (dashed line, lower fill opacity). Pie, donut, and
funnel charts ignore `compareTo`.
- For **chart** widgets (line / area / bar / horizontal-bar / combo), a muted
second series (dashed line, lower fill opacity). Pie, donut, funnel and
scatter charts ignore `compareTo`.
- For **table** widgets, a comparison column beside each compared measure.
- For a **pivot** cross-tab (`type: 'pivot'` with ≥2 dimensions), the comparison
is stacked **inside** the cell — current value on top, comparison value and
Expand DownExpand Up@@ -159,6 +159,10 @@ running over a window nobody asked for.
or omit `compareTo` entirely.
- Pie / donut / funnel charts — comparison overlays are not visually
meaningful and are silently ignored.
- Scatter charts, and the `bubble` widget type that renders as one — a scatter
binds ONE measure to its y axis, so an overlay could only be drawn on the
primary's own y. `compareTo` is ignored until scatter can project a second
measure (objectui#7194 option A); ruled in objectui#7402.

## Related

Expand Down
17 changes: 16 additions & 1 deletion packages/plugin-dashboard/src/DatasetWidget.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1401,8 +1401,23 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource:
// those series are dimension VALUES, not measures, so there is no per-measure
// series a comparison could pair with (the `__compare` columns are still in
// the rows — nothing is lost, it just isn't drawn as an overlay).
//
// Skipped, too, for a chart family that IGNORES `compareTo` — today just
// `scatter`, which both the `scatter` and `bubble` widget types map to
// (CHART_TYPE_MAP above). A scatter binds ONE measure to its y axis, so the
// overlay was drawn through the PRIMARY's `YAxis dataKey` and painted
// "previous period" exactly on top of "current" (objectui#7402). It returns
// with the multi-measure projection declined as option A of objectui#7194.
// The sibling declaration for the inline chart path is `supportsCompareTo`
// in `@object-ui/plugin-charts`' ObjectChart; this is a second, deliberately
// narrow copy because plugin-charts is a devDependency here, not a runtime
// one. ⚠️ That list also excludes pie / donut / funnel and this one does
// not — a divergence older than this line, filed as objectui#7495 (the
// renderer drops the extra series for those families, so nothing is
// mis-drawn; the comparison query still runs).
const chartIgnoresCompareTo = chartType === 'scatter';
const pivotedSeries = dimensions.length >= 2 && values.length === 1;
const comparisonSeries = pivotedSeries
const comparisonSeries = pivotedSeries || chartIgnoresCompareTo
? []
: comparedValues.map((m) => {
// An overlay is the SAME measure one period back, so it takes its
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7402 — the DASHBOARD half of "a scatter ignores `compareTo`".
*
* `CHART_TYPE_MAP` maps BOTH widget types `scatter` and `bubble` onto
* `chartType: 'scatter'`, and this path had no chart-family exclusion at all:
* a compare-to scatter widget got a `revenue__compare` series appended, which
* the renderer then drew through the primary's single y axis — "previous
* period" painted exactly on top of "current".
*
* Both widget-type spellings are pinned, because an exclusion written against
* the widget type instead of the chart type would cover one and miss the other.
*
* The assertions are POSITIVE about the primary (it is still there, with its
* numbers) and positive about the absence of the overlay (no series keyed
* `<measure>__compare`) — "nothing refused" would also be true of a widget
* that rendered nothing.
*
* The `bar` control at the bottom is mandatory: without it, a regression that
* suppressed every comparison series would pass every assertion above.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';

let lastChartSchema: any = null;

vi.mock('@object-ui/react', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
SchemaRenderer: (props: any) => {
lastChartSchema = props.schema;
return null;
},
}));

import { DatasetWidget } from '../DatasetWidget';

afterEach(() => {
cleanup();
lastChartSchema = null;
});

const Q2 = { close_date: { $gte: '2026-04-01', $lte: '2026-06-30' } };

/** The executor answers with the `__compare` columns already attached. */
const makeSource = () => ({
queryDataset: vi.fn(async () => ({
rows: [
{ stage: 'won', revenue: 120, revenue__compare: 100 },
{ stage: 'lost', revenue: 20, revenue__compare: 40 },
],
fields: [{ name: 'revenue', type: 'number', label: 'Revenue' }],
})),
});

const renderWidget = (type: string, dataSource: unknown) =>
render(
<DatasetWidget
widget={{
type, dataset: 'sales', dimensions: ['stage'], values: ['revenue'],
filter: { ...Q2 }, compareTo: { kind: 'previousYear' },
}}
dataSource={dataSource}
/>,
);

/** Every series the chart was handed that reads a comparison column. */
const overlaySeriesOf = (schema: any) =>
(schema?.series ?? []).filter((s: any) => String(s?.dataKey).endsWith('__compare'));

describe.each(['scatter', 'bubble'])('DatasetWidget — widget type %s ignores compareTo (#7402)', (type) => {
it('charts the primary measure and appends NO comparison series', async () => {
const src = makeSource();
renderWidget(type, src);

await waitFor(() => expect(lastChartSchema).not.toBeNull());
// Both spellings reach the renderer as the SAME chart family — which is
// why one chart-type exclusion covers both.
expect(lastChartSchema.chartType).toBe('scatter');

// Primary: drawn, with its own numbers, untouched by the overlay's absence.
expect(lastChartSchema.series).toHaveLength(1);
expect(lastChartSchema.series[0]).toMatchObject({ dataKey: 'revenue' });
expect(lastChartSchema.series[0].variant).toBeUndefined();
expect(lastChartSchema.data[0]).toMatchObject({ stage: 'won', revenue: 120 });

// Overlay: positively absent, even though the executor DID return the
// `revenue__compare` column — the widget declines to draw it.
expect(overlaySeriesOf(lastChartSchema)).toEqual([]);
});
});

describe('DatasetWidget — the compareTo overlay control (#7402)', () => {
it('CONTROL: a bar widget with the same compareTo still gets its overlay', async () => {
const src = makeSource();
renderWidget('bar', src);

await waitFor(() => expect(lastChartSchema).not.toBeNull());
expect(lastChartSchema.chartType).toBe('bar');
expect(lastChartSchema.series).toHaveLength(2);
expect(lastChartSchema.series[0]).toMatchObject({ dataKey: 'revenue', variant: 'current' });
expect(overlaySeriesOf(lastChartSchema)).toHaveLength(1);
expect(overlaySeriesOf(lastChartSchema)[0]).toMatchObject({
dataKey: 'revenue__compare',
variant: 'comparison',
});
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
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
37 changes: 37 additions & 0 deletions .changeset/7402-scatter-ignores-compareto.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
'@object-ui/plugin-charts': minor
'@object-ui/plugin-dashboard': minor
---

`compareTo` on a `scatter` chart is no longer supported — scatter joins pie / donut /
funnel on the list of chart families that ignore it (objectui#7402, maintainer ruling
2026-09-03).

**This removes a published capability, deliberately.** Until now a `chartType: 'scatter'`
chart (and the dashboard widget types `scatter` and `bubble`, which both render as one)
with `compareTo` set synthesised a muted "previous period" overlay series. It drew the
wrong picture: a scatter binds ONE measure, and the renderer reads y through the single
`YAxis dataKey={series[0].dataKey}`, so the overlay was plotted on the PRIMARY series' y
— "previous period" painted exactly on top of "current" (objectui#7194).

Enforce-or-remove: rather than keep drawing that, the capability is removed until it can
be drawn honestly. Drawing a real second measure on a scatter needs the multi-measure
projection recorded as option A of objectui#7194, which is not built (zero authored
callers). **If and when that projection lands, `compareTo` on a scatter returns with
it** — it is the same missing mechanism, one payment.

What changes for authors:

- A `compareTo` on a scatter is now IGNORED rather than drawn. The primary series still
renders exactly as before — nothing refuses, nothing goes blank, and no comparison
query is issued on the inline chart path.
- No `<measure>__comparison` (inline chart) / `<measure>__compare` (dashboard) series is
appended for a scatter, so a compare-to scatter document also never reaches the
two-or-more-series scatter refusal being added under objectui#7194.
- Charts that keep the overlay: line, area, bar, horizontal-bar, combo. Charts that
ignore `compareTo`: pie, donut, funnel and — as of this change — scatter (and the
`bubble` widget type that renders as a scatter).

Reachability at the time of the change: **0** authored scatter/bubble instances in-repo
across both spellings (control `"type": "bar"` fires at 5 example files); incidence in
deployed tenant metadata is not measurable from this repo.
20 changes: 17 additions & 3 deletions packages/plugin-charts/src/ObjectChart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -333,9 +333,23 @@ export const ObjectChart = (props: any) => {
[schema.dataset, schema.dimensions, schema.values],
);

// Pie / donut / funnel are single-distribution charts where a comparison
// overlay would be meaningless — we skip the comparison fetch entirely.
const supportsCompareTo = (ct?: string) => ct !== 'pie' && ct !== 'donut' && ct !== 'funnel';
// Chart families that IGNORE `compareTo`: the comparison fetch is skipped
// entirely, so no `<valueKey>__comparison` column is produced and no overlay
// series is ever synthesised.
//
// - pie / donut / funnel — single-distribution charts where a comparison
// overlay would be meaningless.
// - scatter (objectui#7402) — a scatter binds ONE measure: the renderer
// reads y through the single `YAxis dataKey={series[0].dataKey}`, so the
// synthesised overlay was painted on the PRIMARY's y and "previous
// period" landed exactly on top of "current". Drawing it honestly needs
// the multi-measure projection declined as option A of objectui#7194;
// `compareTo` on a scatter returns WITH that projection. Until then the
// published capability is removed rather than left drawing a wrong
// picture — and because the overlay is never synthesised, a compare-to
// document never reaches #7194's two-or-more-series scatter refusal.
const supportsCompareTo = (ct?: string) =>
ct !== 'pie' && ct !== 'donut' && ct !== 'funnel' && ct !== 'scatter';

// Resolve the category dimension's option colors (P3). Best-effort: any
// failure leaves categoryColors null and the chart keeps the theme palette.
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7402 — a `scatter` IGNORES `compareTo`, exactly as pie / donut /
* funnel do.
*
* The overlay used to be synthesised for a scatter too, and the renderer reads
* y through the single `YAxis dataKey={series[0].dataKey}`: "previous period"
* was therefore painted on the PRIMARY's y, exactly on top of "current". The
* ruling on #7402 removes the published capability rather than keep drawing
* that picture; it returns with the multi-measure projection declined as
* option A of #7194.
*
* What is pinned here is the POSITIVE half of "ignored": the primary series
* (and its data) still reach ChartRenderer, and NO series whose `dataKey` ends
* `__comparison` is synthesised. Asserting only "no refusal" would pass for a
* chart that drew nothing at all.
*
* The `bar` control at the bottom is what makes those absences mean anything:
* a change that disabled `compareTo` everywhere would satisfy every scatter
* assertion above it and fail the control.
*
* Asserted at the schema handed to ChartRenderer — the seam the overlay is
* expressed in — as in `ObjectChart.compareTo.test.tsx`, because Recharts
* draws nothing at jsdom's zero-size container.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';

let lastSchema: any = null;

vi.mock('../ChartRenderer', () => ({
ChartRenderer: (props: any) => {
lastSchema = props.schema;
return null;
},
}));

import { ObjectChart, COMPARISON_SUFFIX } from '../ObjectChart';

/** ObjectChart probes `/api/v1/meta/object/deal` for option colors; answer it. */
function installMetaFetchDouble() {
const calls: string[] = [];
vi.stubGlobal(
'fetch',
vi.fn(async (input: unknown) => {
const url = String(
input && typeof input === 'object' && 'url' in input ? (input as { url: unknown }).url : input,
);
calls.push(url);
return { ok: true, json: async () => ({}) };
}),
);
return calls;
}

let metaCalls: string[] = [];

beforeEach(() => {
metaCalls = installMetaFetchDouble();
});

afterEach(() => {
expect(metaCalls.filter((u) => u !== '/api/v1/meta/object/deal')).toEqual([]);
vi.unstubAllGlobals();
cleanup();
lastSchema = null;
});

const quarterStart = (d: Date) => new Date(d.getFullYear(), Math.floor(d.getMonth() / 3) * 3, 1);
const iso = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const CURRENT_FROM = iso(quarterStart(new Date()));

/** 120 for the current window, 100 for any other window asked for. */
const makeSource = () => ({
aggregate: vi.fn(async (_object: string, q: any) => [
{ stage: 'won', amount: String(q?.filter?.close_date?.$gte) === CURRENT_FROM ? 120 : 100 },
]),
});

const renderChart = (chartType: string, dataSource: unknown, series?: unknown) =>
render(
<ObjectChart
schema={{
objectName: 'deal',
chartType,
aggregate: { field: 'amount', function: 'sum', groupBy: 'stage' },
filter: { close_date: { $gte: '{current_quarter_start}', $lte: '{current_quarter_end}' } },
xAxisKey: 'stage',
compareTo: { kind: 'previousYear' },
...(series ? { series } : {}),
}}
dataSource={dataSource}
/>,
);

/** Every series the chart was handed whose key is a synthesised overlay. */
const overlaySeriesOf = (schema: any) =>
(schema?.series ?? []).filter((s: any) => String(s?.dataKey).endsWith(COMPARISON_SUFFIX));

describe('ObjectChart — a scatter ignores compareTo (#7402)', () => {
it('draws the authored primary series and synthesises NO comparison overlay', async () => {
const src = makeSource();
renderChart('scatter', src, [{ dataKey: 'amount' }]);

await waitFor(() => expect(lastSchema).not.toBeNull());
// The comparison window is never even fetched — the same short-circuit
// pie / donut / funnel take.
expect(src.aggregate).toHaveBeenCalledTimes(1);

// Primary: present, intact, carrying its data.
expect(lastSchema.chartType).toBe('scatter');
expect(lastSchema.series).toHaveLength(1);
expect(lastSchema.series[0]).toMatchObject({ dataKey: 'amount' });
expect(lastSchema.data[0]).toMatchObject({ stage: 'won', amount: 120 });

// Overlay: positively absent — no series, and no column for one to read.
expect(overlaySeriesOf(lastSchema)).toEqual([]);
expect(lastSchema.data[0]).not.toHaveProperty(`amount${COMPARISON_SUFFIX}`);
});

it('adds no overlay when the author wrote no `series` at all', async () => {
// The authored spec for a compare-to scatter has ONE measure and no
// `series` key; the overlay was the only thing that ever put a second
// entry there. So the pin is that `series` stays UNsynthesised.
const src = makeSource();
renderChart('scatter', src);

await waitFor(() => expect(lastSchema).not.toBeNull());
expect(src.aggregate).toHaveBeenCalledTimes(1);
expect(overlaySeriesOf(lastSchema)).toEqual([]);
expect(lastSchema.data[0]).toMatchObject({ stage: 'won', amount: 120 });
expect(lastSchema.data[0]).not.toHaveProperty(`amount${COMPARISON_SUFFIX}`);
});

it('CONTROL: a bar with the same compareTo still synthesises the overlay', async () => {
// Without this, a regression that switched `compareTo` off for every chart
// type would pass both assertions above.
const src = makeSource();
renderChart('bar', src);

await waitFor(() => expect(src.aggregate).toHaveBeenCalledTimes(2));
await waitFor(() => expect(lastSchema?.series?.length).toBe(2));
expect(lastSchema.series[0]).toMatchObject({ dataKey: 'amount', variant: 'current' });
expect(overlaySeriesOf(lastSchema)).toHaveLength(1);
expect(overlaySeriesOf(lastSchema)[0]).toMatchObject({
dataKey: `amount${COMPARISON_SUFFIX}`,
variant: 'comparison',
});
expect(lastSchema.data[0]).toMatchObject({ amount: 120, [`amount${COMPARISON_SUFFIX}`]: 100 });
});
});
10 changes: 7 additions & 3 deletions packages/plugin-dashboard/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,9 +21,9 @@ executor re-runs the same selection over the shifted window and attaches a

- For **metric** & **gauge** widgets, a delta percentage surfaced as a `trend`
indicator (overrides any static `trend` prop).
- For **chart** widgets (line / area / bar / horizontal-bar / scatter / combo),
a muted second series (dashed line, lower fill opacity). Pie, donut, and
funnel charts ignore `compareTo`.
- For **chart** widgets (line / area / bar / horizontal-bar / combo), a muted
second series (dashed line, lower fill opacity). Pie, donut, funnel and
scatter charts ignore `compareTo`.
- For **table** widgets, a comparison column beside each compared measure.
- For a **pivot** cross-tab (`type: 'pivot'` with ≥2 dimensions), the comparison
is stacked **inside** the cell — current value on top, comparison value and
Expand DownExpand Up@@ -159,6 +159,10 @@ running over a window nobody asked for.
or omit `compareTo` entirely.
- Pie / donut / funnel charts — comparison overlays are not visually
meaningful and are silently ignored.
- Scatter charts, and the `bubble` widget type that renders as one — a scatter
binds ONE measure to its y axis, so an overlay could only be drawn on the
primary's own y. `compareTo` is ignored until scatter can project a second
measure (objectui#7194 option A); ruled in objectui#7402.

## Related

Expand Down
17 changes: 16 additions & 1 deletion packages/plugin-dashboard/src/DatasetWidget.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1401,8 +1401,23 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource:
// those series are dimension VALUES, not measures, so there is no per-measure
// series a comparison could pair with (the `__compare` columns are still in
// the rows — nothing is lost, it just isn't drawn as an overlay).
//
// Skipped, too, for a chart family that IGNORES `compareTo` — today just
// `scatter`, which both the `scatter` and `bubble` widget types map to
// (CHART_TYPE_MAP above). A scatter binds ONE measure to its y axis, so the
// overlay was drawn through the PRIMARY's `YAxis dataKey` and painted
// "previous period" exactly on top of "current" (objectui#7402). It returns
// with the multi-measure projection declined as option A of objectui#7194.
// The sibling declaration for the inline chart path is `supportsCompareTo`
// in `@object-ui/plugin-charts`' ObjectChart; this is a second, deliberately
// narrow copy because plugin-charts is a devDependency here, not a runtime
// one. ⚠️ That list also excludes pie / donut / funnel and this one does
// not — a divergence older than this line, filed as objectui#7495 (the
// renderer drops the extra series for those families, so nothing is
// mis-drawn; the comparison query still runs).
const chartIgnoresCompareTo = chartType === 'scatter';
const pivotedSeries = dimensions.length >= 2 && values.length === 1;
const comparisonSeries = pivotedSeries
const comparisonSeries = pivotedSeries || chartIgnoresCompareTo
? []
: comparedValues.map((m) => {
// An overlay is the SAME measure one period back, so it takes its
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7402 — the DASHBOARD half of "a scatter ignores `compareTo`".
*
* `CHART_TYPE_MAP` maps BOTH widget types `scatter` and `bubble` onto
* `chartType: 'scatter'`, and this path had no chart-family exclusion at all:
* a compare-to scatter widget got a `revenue__compare` series appended, which
* the renderer then drew through the primary's single y axis — "previous
* period" painted exactly on top of "current".
*
* Both widget-type spellings are pinned, because an exclusion written against
* the widget type instead of the chart type would cover one and miss the other.
*
* The assertions are POSITIVE about the primary (it is still there, with its
* numbers) and positive about the absence of the overlay (no series keyed
* `<measure>__compare`) — "nothing refused" would also be true of a widget
* that rendered nothing.
*
* The `bar` control at the bottom is mandatory: without it, a regression that
* suppressed every comparison series would pass every assertion above.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';

let lastChartSchema: any = null;

vi.mock('@object-ui/react', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
SchemaRenderer: (props: any) => {
lastChartSchema = props.schema;
return null;
},
}));

import { DatasetWidget } from '../DatasetWidget';

afterEach(() => {
cleanup();
lastChartSchema = null;
});

const Q2 = { close_date: { $gte: '2026-04-01', $lte: '2026-06-30' } };

/** The executor answers with the `__compare` columns already attached. */
const makeSource = () => ({
queryDataset: vi.fn(async () => ({
rows: [
{ stage: 'won', revenue: 120, revenue__compare: 100 },
{ stage: 'lost', revenue: 20, revenue__compare: 40 },
],
fields: [{ name: 'revenue', type: 'number', label: 'Revenue' }],
})),
});

const renderWidget = (type: string, dataSource: unknown) =>
render(
<DatasetWidget
widget={{
type, dataset: 'sales', dimensions: ['stage'], values: ['revenue'],
filter: { ...Q2 }, compareTo: { kind: 'previousYear' },
}}
dataSource={dataSource}
/>,
);

/** Every series the chart was handed that reads a comparison column. */
const overlaySeriesOf = (schema: any) =>
(schema?.series ?? []).filter((s: any) => String(s?.dataKey).endsWith('__compare'));

describe.each(['scatter', 'bubble'])('DatasetWidget — widget type %s ignores compareTo (#7402)', (type) => {
it('charts the primary measure and appends NO comparison series', async () => {
const src = makeSource();
renderWidget(type, src);

await waitFor(() => expect(lastChartSchema).not.toBeNull());
// Both spellings reach the renderer as the SAME chart family — which is
// why one chart-type exclusion covers both.
expect(lastChartSchema.chartType).toBe('scatter');

// Primary: drawn, with its own numbers, untouched by the overlay's absence.
expect(lastChartSchema.series).toHaveLength(1);
expect(lastChartSchema.series[0]).toMatchObject({ dataKey: 'revenue' });
expect(lastChartSchema.series[0].variant).toBeUndefined();
expect(lastChartSchema.data[0]).toMatchObject({ stage: 'won', revenue: 120 });

// Overlay: positively absent, even though the executor DID return the
// `revenue__compare` column — the widget declines to draw it.
expect(overlaySeriesOf(lastChartSchema)).toEqual([]);
});
});

describe('DatasetWidget — the compareTo overlay control (#7402)', () => {
it('CONTROL: a bar widget with the same compareTo still gets its overlay', async () => {
const src = makeSource();
renderWidget('bar', src);

await waitFor(() => expect(lastChartSchema).not.toBeNull());
expect(lastChartSchema.chartType).toBe('bar');
expect(lastChartSchema.series).toHaveLength(2);
expect(lastChartSchema.series[0]).toMatchObject({ dataKey: 'revenue', variant: 'current' });
expect(overlaySeriesOf(lastChartSchema)).toHaveLength(1);
expect(overlaySeriesOf(lastChartSchema)[0]).toMatchObject({
dataKey: 'revenue__compare',
variant: 'comparison',
});
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
37 changes: 37 additions & 0 deletions .changeset/7402-scatter-ignores-compareto.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
'@object-ui/plugin-charts': minor
'@object-ui/plugin-dashboard': minor
---

`compareTo` on a `scatter` chart is no longer supported — scatter joins pie / donut /
funnel on the list of chart families that ignore it (objectui#7402, maintainer ruling
2026-09-03).

**This removes a published capability, deliberately.** Until now a `chartType: 'scatter'`
chart (and the dashboard widget types `scatter` and `bubble`, which both render as one)
with `compareTo` set synthesised a muted "previous period" overlay series. It drew the
wrong picture: a scatter binds ONE measure, and the renderer reads y through the single
`YAxis dataKey={series[0].dataKey}`, so the overlay was plotted on the PRIMARY series' y
— "previous period" painted exactly on top of "current" (objectui#7194).

Enforce-or-remove: rather than keep drawing that, the capability is removed until it can
be drawn honestly. Drawing a real second measure on a scatter needs the multi-measure
projection recorded as option A of objectui#7194, which is not built (zero authored
callers). **If and when that projection lands, `compareTo` on a scatter returns with
it** — it is the same missing mechanism, one payment.

What changes for authors:

- A `compareTo` on a scatter is now IGNORED rather than drawn. The primary series still
renders exactly as before — nothing refuses, nothing goes blank, and no comparison
query is issued on the inline chart path.
- No `<measure>__comparison` (inline chart) / `<measure>__compare` (dashboard) series is
appended for a scatter, so a compare-to scatter document also never reaches the
two-or-more-series scatter refusal being added under objectui#7194.
- Charts that keep the overlay: line, area, bar, horizontal-bar, combo. Charts that
ignore `compareTo`: pie, donut, funnel and — as of this change — scatter (and the
`bubble` widget type that renders as a scatter).

Reachability at the time of the change: **0** authored scatter/bubble instances in-repo
across both spellings (control `"type": "bar"` fires at 5 example files); incidence in
deployed tenant metadata is not measurable from this repo.
20 changes: 17 additions & 3 deletions packages/plugin-charts/src/ObjectChart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -333,9 +333,23 @@ export const ObjectChart = (props: any) => {
[schema.dataset, schema.dimensions, schema.values],
);

// Pie / donut / funnel are single-distribution charts where a comparison
// overlay would be meaningless — we skip the comparison fetch entirely.
const supportsCompareTo = (ct?: string) => ct !== 'pie' && ct !== 'donut' && ct !== 'funnel';
// Chart families that IGNORE `compareTo`: the comparison fetch is skipped
// entirely, so no `<valueKey>__comparison` column is produced and no overlay
// series is ever synthesised.
//
// - pie / donut / funnel — single-distribution charts where a comparison
// overlay would be meaningless.
// - scatter (objectui#7402) — a scatter binds ONE measure: the renderer
// reads y through the single `YAxis dataKey={series[0].dataKey}`, so the
// synthesised overlay was painted on the PRIMARY's y and "previous
// period" landed exactly on top of "current". Drawing it honestly needs
// the multi-measure projection declined as option A of objectui#7194;
// `compareTo` on a scatter returns WITH that projection. Until then the
// published capability is removed rather than left drawing a wrong
// picture — and because the overlay is never synthesised, a compare-to
// document never reaches #7194's two-or-more-series scatter refusal.
const supportsCompareTo = (ct?: string) =>
ct !== 'pie' && ct !== 'donut' && ct !== 'funnel' && ct !== 'scatter';

// Resolve the category dimension's option colors (P3). Best-effort: any
// failure leaves categoryColors null and the chart keeps the theme palette.
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7402 — a `scatter` IGNORES `compareTo`, exactly as pie / donut /
* funnel do.
*
* The overlay used to be synthesised for a scatter too, and the renderer reads
* y through the single `YAxis dataKey={series[0].dataKey}`: "previous period"
* was therefore painted on the PRIMARY's y, exactly on top of "current". The
* ruling on #7402 removes the published capability rather than keep drawing
* that picture; it returns with the multi-measure projection declined as
* option A of #7194.
*
* What is pinned here is the POSITIVE half of "ignored": the primary series
* (and its data) still reach ChartRenderer, and NO series whose `dataKey` ends
* `__comparison` is synthesised. Asserting only "no refusal" would pass for a
* chart that drew nothing at all.
*
* The `bar` control at the bottom is what makes those absences mean anything:
* a change that disabled `compareTo` everywhere would satisfy every scatter
* assertion above it and fail the control.
*
* Asserted at the schema handed to ChartRenderer — the seam the overlay is
* expressed in — as in `ObjectChart.compareTo.test.tsx`, because Recharts
* draws nothing at jsdom's zero-size container.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';

let lastSchema: any = null;

vi.mock('../ChartRenderer', () => ({
ChartRenderer: (props: any) => {
lastSchema = props.schema;
return null;
},
}));

import { ObjectChart, COMPARISON_SUFFIX } from '../ObjectChart';

/** ObjectChart probes `/api/v1/meta/object/deal` for option colors; answer it. */
function installMetaFetchDouble() {
const calls: string[] = [];
vi.stubGlobal(
'fetch',
vi.fn(async (input: unknown) => {
const url = String(
input && typeof input === 'object' && 'url' in input ? (input as { url: unknown }).url : input,
);
calls.push(url);
return { ok: true, json: async () => ({}) };
}),
);
return calls;
}

let metaCalls: string[] = [];

beforeEach(() => {
metaCalls = installMetaFetchDouble();
});

afterEach(() => {
expect(metaCalls.filter((u) => u !== '/api/v1/meta/object/deal')).toEqual([]);
vi.unstubAllGlobals();
cleanup();
lastSchema = null;
});

const quarterStart = (d: Date) => new Date(d.getFullYear(), Math.floor(d.getMonth() / 3) * 3, 1);
const iso = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const CURRENT_FROM = iso(quarterStart(new Date()));

/** 120 for the current window, 100 for any other window asked for. */
const makeSource = () => ({
aggregate: vi.fn(async (_object: string, q: any) => [
{ stage: 'won', amount: String(q?.filter?.close_date?.$gte) === CURRENT_FROM ? 120 : 100 },
]),
});

const renderChart = (chartType: string, dataSource: unknown, series?: unknown) =>
render(
<ObjectChart
schema={{
objectName: 'deal',
chartType,
aggregate: { field: 'amount', function: 'sum', groupBy: 'stage' },
filter: { close_date: { $gte: '{current_quarter_start}', $lte: '{current_quarter_end}' } },
xAxisKey: 'stage',
compareTo: { kind: 'previousYear' },
...(series ? { series } : {}),
}}
dataSource={dataSource}
/>,
);

/** Every series the chart was handed whose key is a synthesised overlay. */
const overlaySeriesOf = (schema: any) =>
(schema?.series ?? []).filter((s: any) => String(s?.dataKey).endsWith(COMPARISON_SUFFIX));

describe('ObjectChart — a scatter ignores compareTo (#7402)', () => {
it('draws the authored primary series and synthesises NO comparison overlay', async () => {
const src = makeSource();
renderChart('scatter', src, [{ dataKey: 'amount' }]);

await waitFor(() => expect(lastSchema).not.toBeNull());
// The comparison window is never even fetched — the same short-circuit
// pie / donut / funnel take.
expect(src.aggregate).toHaveBeenCalledTimes(1);

// Primary: present, intact, carrying its data.
expect(lastSchema.chartType).toBe('scatter');
expect(lastSchema.series).toHaveLength(1);
expect(lastSchema.series[0]).toMatchObject({ dataKey: 'amount' });
expect(lastSchema.data[0]).toMatchObject({ stage: 'won', amount: 120 });

// Overlay: positively absent — no series, and no column for one to read.
expect(overlaySeriesOf(lastSchema)).toEqual([]);
expect(lastSchema.data[0]).not.toHaveProperty(`amount${COMPARISON_SUFFIX}`);
});

it('adds no overlay when the author wrote no `series` at all', async () => {
// The authored spec for a compare-to scatter has ONE measure and no
// `series` key; the overlay was the only thing that ever put a second
// entry there. So the pin is that `series` stays UNsynthesised.
const src = makeSource();
renderChart('scatter', src);

await waitFor(() => expect(lastSchema).not.toBeNull());
expect(src.aggregate).toHaveBeenCalledTimes(1);
expect(overlaySeriesOf(lastSchema)).toEqual([]);
expect(lastSchema.data[0]).toMatchObject({ stage: 'won', amount: 120 });
expect(lastSchema.data[0]).not.toHaveProperty(`amount${COMPARISON_SUFFIX}`);
});

it('CONTROL: a bar with the same compareTo still synthesises the overlay', async () => {
// Without this, a regression that switched `compareTo` off for every chart
// type would pass both assertions above.
const src = makeSource();
renderChart('bar', src);

await waitFor(() => expect(src.aggregate).toHaveBeenCalledTimes(2));
await waitFor(() => expect(lastSchema?.series?.length).toBe(2));
expect(lastSchema.series[0]).toMatchObject({ dataKey: 'amount', variant: 'current' });
expect(overlaySeriesOf(lastSchema)).toHaveLength(1);
expect(overlaySeriesOf(lastSchema)[0]).toMatchObject({
dataKey: `amount${COMPARISON_SUFFIX}`,
variant: 'comparison',
});
expect(lastSchema.data[0]).toMatchObject({ amount: 120, [`amount${COMPARISON_SUFFIX}`]: 100 });
});
});
10 changes: 7 additions & 3 deletions packages/plugin-dashboard/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,9 +21,9 @@ executor re-runs the same selection over the shifted window and attaches a

- For **metric** & **gauge** widgets, a delta percentage surfaced as a `trend`
indicator (overrides any static `trend` prop).
- For **chart** widgets (line / area / bar / horizontal-bar / scatter / combo),
a muted second series (dashed line, lower fill opacity). Pie, donut, and
funnel charts ignore `compareTo`.
- For **chart** widgets (line / area / bar / horizontal-bar / combo), a muted
second series (dashed line, lower fill opacity). Pie, donut, funnel and
scatter charts ignore `compareTo`.
- For **table** widgets, a comparison column beside each compared measure.
- For a **pivot** cross-tab (`type: 'pivot'` with ≥2 dimensions), the comparison
is stacked **inside** the cell — current value on top, comparison value and
Expand DownExpand Up@@ -159,6 +159,10 @@ running over a window nobody asked for.
or omit `compareTo` entirely.
- Pie / donut / funnel charts — comparison overlays are not visually
meaningful and are silently ignored.
- Scatter charts, and the `bubble` widget type that renders as one — a scatter
binds ONE measure to its y axis, so an overlay could only be drawn on the
primary's own y. `compareTo` is ignored until scatter can project a second
measure (objectui#7194 option A); ruled in objectui#7402.

## Related

Expand Down
17 changes: 16 additions & 1 deletion packages/plugin-dashboard/src/DatasetWidget.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1401,8 +1401,23 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource:
// those series are dimension VALUES, not measures, so there is no per-measure
// series a comparison could pair with (the `__compare` columns are still in
// the rows — nothing is lost, it just isn't drawn as an overlay).
//
// Skipped, too, for a chart family that IGNORES `compareTo` — today just
// `scatter`, which both the `scatter` and `bubble` widget types map to
// (CHART_TYPE_MAP above). A scatter binds ONE measure to its y axis, so the
// overlay was drawn through the PRIMARY's `YAxis dataKey` and painted
// "previous period" exactly on top of "current" (objectui#7402). It returns
// with the multi-measure projection declined as option A of objectui#7194.
// The sibling declaration for the inline chart path is `supportsCompareTo`
// in `@object-ui/plugin-charts`' ObjectChart; this is a second, deliberately
// narrow copy because plugin-charts is a devDependency here, not a runtime
// one. ⚠️ That list also excludes pie / donut / funnel and this one does
// not — a divergence older than this line, filed as objectui#7495 (the
// renderer drops the extra series for those families, so nothing is
// mis-drawn; the comparison query still runs).
const chartIgnoresCompareTo = chartType === 'scatter';
const pivotedSeries = dimensions.length >= 2 && values.length === 1;
const comparisonSeries = pivotedSeries
const comparisonSeries = pivotedSeries || chartIgnoresCompareTo
? []
: comparedValues.map((m) => {
// An overlay is the SAME measure one period back, so it takes its
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7402 — the DASHBOARD half of "a scatter ignores `compareTo`".
*
* `CHART_TYPE_MAP` maps BOTH widget types `scatter` and `bubble` onto
* `chartType: 'scatter'`, and this path had no chart-family exclusion at all:
* a compare-to scatter widget got a `revenue__compare` series appended, which
* the renderer then drew through the primary's single y axis — "previous
* period" painted exactly on top of "current".
*
* Both widget-type spellings are pinned, because an exclusion written against
* the widget type instead of the chart type would cover one and miss the other.
*
* The assertions are POSITIVE about the primary (it is still there, with its
* numbers) and positive about the absence of the overlay (no series keyed
* `<measure>__compare`) — "nothing refused" would also be true of a widget
* that rendered nothing.
*
* The `bar` control at the bottom is mandatory: without it, a regression that
* suppressed every comparison series would pass every assertion above.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';

let lastChartSchema: any = null;

vi.mock('@object-ui/react', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
SchemaRenderer: (props: any) => {
lastChartSchema = props.schema;
return null;
},
}));

import { DatasetWidget } from '../DatasetWidget';

afterEach(() => {
cleanup();
lastChartSchema = null;
});

const Q2 = { close_date: { $gte: '2026-04-01', $lte: '2026-06-30' } };

/** The executor answers with the `__compare` columns already attached. */
const makeSource = () => ({
queryDataset: vi.fn(async () => ({
rows: [
{ stage: 'won', revenue: 120, revenue__compare: 100 },
{ stage: 'lost', revenue: 20, revenue__compare: 40 },
],
fields: [{ name: 'revenue', type: 'number', label: 'Revenue' }],
})),
});

const renderWidget = (type: string, dataSource: unknown) =>
render(
<DatasetWidget
widget={{
type, dataset: 'sales', dimensions: ['stage'], values: ['revenue'],
filter: { ...Q2 }, compareTo: { kind: 'previousYear' },
}}
dataSource={dataSource}
/>,
);

/** Every series the chart was handed that reads a comparison column. */
const overlaySeriesOf = (schema: any) =>
(schema?.series ?? []).filter((s: any) => String(s?.dataKey).endsWith('__compare'));

describe.each(['scatter', 'bubble'])('DatasetWidget — widget type %s ignores compareTo (#7402)', (type) => {
it('charts the primary measure and appends NO comparison series', async () => {
const src = makeSource();
renderWidget(type, src);

await waitFor(() => expect(lastChartSchema).not.toBeNull());
// Both spellings reach the renderer as the SAME chart family — which is
// why one chart-type exclusion covers both.
expect(lastChartSchema.chartType).toBe('scatter');

// Primary: drawn, with its own numbers, untouched by the overlay's absence.
expect(lastChartSchema.series).toHaveLength(1);
expect(lastChartSchema.series[0]).toMatchObject({ dataKey: 'revenue' });
expect(lastChartSchema.series[0].variant).toBeUndefined();
expect(lastChartSchema.data[0]).toMatchObject({ stage: 'won', revenue: 120 });

// Overlay: positively absent, even though the executor DID return the
// `revenue__compare` column — the widget declines to draw it.
expect(overlaySeriesOf(lastChartSchema)).toEqual([]);
});
});

describe('DatasetWidget — the compareTo overlay control (#7402)', () => {
it('CONTROL: a bar widget with the same compareTo still gets its overlay', async () => {
const src = makeSource();
renderWidget('bar', src);

await waitFor(() => expect(lastChartSchema).not.toBeNull());
expect(lastChartSchema.chartType).toBe('bar');
expect(lastChartSchema.series).toHaveLength(2);
expect(lastChartSchema.series[0]).toMatchObject({ dataKey: 'revenue', variant: 'current' });
expect(overlaySeriesOf(lastChartSchema)).toHaveLength(1);
expect(overlaySeriesOf(lastChartSchema)[0]).toMatchObject({
dataKey: 'revenue__compare',
variant: 'comparison',
});
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
37 changes: 37 additions & 0 deletions .changeset/7402-scatter-ignores-compareto.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
'@object-ui/plugin-charts': minor
'@object-ui/plugin-dashboard': minor
---

`compareTo` on a `scatter` chart is no longer supported — scatter joins pie / donut /
funnel on the list of chart families that ignore it (objectui#7402, maintainer ruling
2026-09-03).

**This removes a published capability, deliberately.** Until now a `chartType: 'scatter'`
chart (and the dashboard widget types `scatter` and `bubble`, which both render as one)
with `compareTo` set synthesised a muted "previous period" overlay series. It drew the
wrong picture: a scatter binds ONE measure, and the renderer reads y through the single
`YAxis dataKey={series[0].dataKey}`, so the overlay was plotted on the PRIMARY series' y
— "previous period" painted exactly on top of "current" (objectui#7194).

Enforce-or-remove: rather than keep drawing that, the capability is removed until it can
be drawn honestly. Drawing a real second measure on a scatter needs the multi-measure
projection recorded as option A of objectui#7194, which is not built (zero authored
callers). **If and when that projection lands, `compareTo` on a scatter returns with
it** — it is the same missing mechanism, one payment.

What changes for authors:

- A `compareTo` on a scatter is now IGNORED rather than drawn. The primary series still
renders exactly as before — nothing refuses, nothing goes blank, and no comparison
query is issued on the inline chart path.
- No `<measure>__comparison` (inline chart) / `<measure>__compare` (dashboard) series is
appended for a scatter, so a compare-to scatter document also never reaches the
two-or-more-series scatter refusal being added under objectui#7194.
- Charts that keep the overlay: line, area, bar, horizontal-bar, combo. Charts that
ignore `compareTo`: pie, donut, funnel and — as of this change — scatter (and the
`bubble` widget type that renders as a scatter).

Reachability at the time of the change: **0** authored scatter/bubble instances in-repo
across both spellings (control `"type": "bar"` fires at 5 example files); incidence in
deployed tenant metadata is not measurable from this repo.
20 changes: 17 additions & 3 deletions packages/plugin-charts/src/ObjectChart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -333,9 +333,23 @@ export const ObjectChart = (props: any) => {
[schema.dataset, schema.dimensions, schema.values],
);

// Pie / donut / funnel are single-distribution charts where a comparison
// overlay would be meaningless — we skip the comparison fetch entirely.
const supportsCompareTo = (ct?: string) => ct !== 'pie' && ct !== 'donut' && ct !== 'funnel';
// Chart families that IGNORE `compareTo`: the comparison fetch is skipped
// entirely, so no `<valueKey>__comparison` column is produced and no overlay
// series is ever synthesised.
//
// - pie / donut / funnel — single-distribution charts where a comparison
// overlay would be meaningless.
// - scatter (objectui#7402) — a scatter binds ONE measure: the renderer
// reads y through the single `YAxis dataKey={series[0].dataKey}`, so the
// synthesised overlay was painted on the PRIMARY's y and "previous
// period" landed exactly on top of "current". Drawing it honestly needs
// the multi-measure projection declined as option A of objectui#7194;
// `compareTo` on a scatter returns WITH that projection. Until then the
// published capability is removed rather than left drawing a wrong
// picture — and because the overlay is never synthesised, a compare-to
// document never reaches #7194's two-or-more-series scatter refusal.
const supportsCompareTo = (ct?: string) =>
ct !== 'pie' && ct !== 'donut' && ct !== 'funnel' && ct !== 'scatter';

// Resolve the category dimension's option colors (P3). Best-effort: any
// failure leaves categoryColors null and the chart keeps the theme palette.
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7402 — a `scatter` IGNORES `compareTo`, exactly as pie / donut /
* funnel do.
*
* The overlay used to be synthesised for a scatter too, and the renderer reads
* y through the single `YAxis dataKey={series[0].dataKey}`: "previous period"
* was therefore painted on the PRIMARY's y, exactly on top of "current". The
* ruling on #7402 removes the published capability rather than keep drawing
* that picture; it returns with the multi-measure projection declined as
* option A of #7194.
*
* What is pinned here is the POSITIVE half of "ignored": the primary series
* (and its data) still reach ChartRenderer, and NO series whose `dataKey` ends
* `__comparison` is synthesised. Asserting only "no refusal" would pass for a
* chart that drew nothing at all.
*
* The `bar` control at the bottom is what makes those absences mean anything:
* a change that disabled `compareTo` everywhere would satisfy every scatter
* assertion above it and fail the control.
*
* Asserted at the schema handed to ChartRenderer — the seam the overlay is
* expressed in — as in `ObjectChart.compareTo.test.tsx`, because Recharts
* draws nothing at jsdom's zero-size container.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';

let lastSchema: any = null;

vi.mock('../ChartRenderer', () => ({
ChartRenderer: (props: any) => {
lastSchema = props.schema;
return null;
},
}));

import { ObjectChart, COMPARISON_SUFFIX } from '../ObjectChart';

/** ObjectChart probes `/api/v1/meta/object/deal` for option colors; answer it. */
function installMetaFetchDouble() {
const calls: string[] = [];
vi.stubGlobal(
'fetch',
vi.fn(async (input: unknown) => {
const url = String(
input && typeof input === 'object' && 'url' in input ? (input as { url: unknown }).url : input,
);
calls.push(url);
return { ok: true, json: async () => ({}) };
}),
);
return calls;
}

let metaCalls: string[] = [];

beforeEach(() => {
metaCalls = installMetaFetchDouble();
});

afterEach(() => {
expect(metaCalls.filter((u) => u !== '/api/v1/meta/object/deal')).toEqual([]);
vi.unstubAllGlobals();
cleanup();
lastSchema = null;
});

const quarterStart = (d: Date) => new Date(d.getFullYear(), Math.floor(d.getMonth() / 3) * 3, 1);
const iso = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const CURRENT_FROM = iso(quarterStart(new Date()));

/** 120 for the current window, 100 for any other window asked for. */
const makeSource = () => ({
aggregate: vi.fn(async (_object: string, q: any) => [
{ stage: 'won', amount: String(q?.filter?.close_date?.$gte) === CURRENT_FROM ? 120 : 100 },
]),
});

const renderChart = (chartType: string, dataSource: unknown, series?: unknown) =>
render(
<ObjectChart
schema={{
objectName: 'deal',
chartType,
aggregate: { field: 'amount', function: 'sum', groupBy: 'stage' },
filter: { close_date: { $gte: '{current_quarter_start}', $lte: '{current_quarter_end}' } },
xAxisKey: 'stage',
compareTo: { kind: 'previousYear' },
...(series ? { series } : {}),
}}
dataSource={dataSource}
/>,
);

/** Every series the chart was handed whose key is a synthesised overlay. */
const overlaySeriesOf = (schema: any) =>
(schema?.series ?? []).filter((s: any) => String(s?.dataKey).endsWith(COMPARISON_SUFFIX));

describe('ObjectChart — a scatter ignores compareTo (#7402)', () => {
it('draws the authored primary series and synthesises NO comparison overlay', async () => {
const src = makeSource();
renderChart('scatter', src, [{ dataKey: 'amount' }]);

await waitFor(() => expect(lastSchema).not.toBeNull());
// The comparison window is never even fetched — the same short-circuit
// pie / donut / funnel take.
expect(src.aggregate).toHaveBeenCalledTimes(1);

// Primary: present, intact, carrying its data.
expect(lastSchema.chartType).toBe('scatter');
expect(lastSchema.series).toHaveLength(1);
expect(lastSchema.series[0]).toMatchObject({ dataKey: 'amount' });
expect(lastSchema.data[0]).toMatchObject({ stage: 'won', amount: 120 });

// Overlay: positively absent — no series, and no column for one to read.
expect(overlaySeriesOf(lastSchema)).toEqual([]);
expect(lastSchema.data[0]).not.toHaveProperty(`amount${COMPARISON_SUFFIX}`);
});

it('adds no overlay when the author wrote no `series` at all', async () => {
// The authored spec for a compare-to scatter has ONE measure and no
// `series` key; the overlay was the only thing that ever put a second
// entry there. So the pin is that `series` stays UNsynthesised.
const src = makeSource();
renderChart('scatter', src);

await waitFor(() => expect(lastSchema).not.toBeNull());
expect(src.aggregate).toHaveBeenCalledTimes(1);
expect(overlaySeriesOf(lastSchema)).toEqual([]);
expect(lastSchema.data[0]).toMatchObject({ stage: 'won', amount: 120 });
expect(lastSchema.data[0]).not.toHaveProperty(`amount${COMPARISON_SUFFIX}`);
});

it('CONTROL: a bar with the same compareTo still synthesises the overlay', async () => {
// Without this, a regression that switched `compareTo` off for every chart
// type would pass both assertions above.
const src = makeSource();
renderChart('bar', src);

await waitFor(() => expect(src.aggregate).toHaveBeenCalledTimes(2));
await waitFor(() => expect(lastSchema?.series?.length).toBe(2));
expect(lastSchema.series[0]).toMatchObject({ dataKey: 'amount', variant: 'current' });
expect(overlaySeriesOf(lastSchema)).toHaveLength(1);
expect(overlaySeriesOf(lastSchema)[0]).toMatchObject({
dataKey: `amount${COMPARISON_SUFFIX}`,
variant: 'comparison',
});
expect(lastSchema.data[0]).toMatchObject({ amount: 120, [`amount${COMPARISON_SUFFIX}`]: 100 });
});
});
10 changes: 7 additions & 3 deletions packages/plugin-dashboard/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,9 +21,9 @@ executor re-runs the same selection over the shifted window and attaches a

- For **metric** & **gauge** widgets, a delta percentage surfaced as a `trend`
indicator (overrides any static `trend` prop).
- For **chart** widgets (line / area / bar / horizontal-bar / scatter / combo),
a muted second series (dashed line, lower fill opacity). Pie, donut, and
funnel charts ignore `compareTo`.
- For **chart** widgets (line / area / bar / horizontal-bar / combo), a muted
second series (dashed line, lower fill opacity). Pie, donut, funnel and
scatter charts ignore `compareTo`.
- For **table** widgets, a comparison column beside each compared measure.
- For a **pivot** cross-tab (`type: 'pivot'` with ≥2 dimensions), the comparison
is stacked **inside** the cell — current value on top, comparison value and
Expand DownExpand Up@@ -159,6 +159,10 @@ running over a window nobody asked for.
or omit `compareTo` entirely.
- Pie / donut / funnel charts — comparison overlays are not visually
meaningful and are silently ignored.
- Scatter charts, and the `bubble` widget type that renders as one — a scatter
binds ONE measure to its y axis, so an overlay could only be drawn on the
primary's own y. `compareTo` is ignored until scatter can project a second
measure (objectui#7194 option A); ruled in objectui#7402.

## Related

Expand Down
17 changes: 16 additions & 1 deletion packages/plugin-dashboard/src/DatasetWidget.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1401,8 +1401,23 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource:
// those series are dimension VALUES, not measures, so there is no per-measure
// series a comparison could pair with (the `__compare` columns are still in
// the rows — nothing is lost, it just isn't drawn as an overlay).
//
// Skipped, too, for a chart family that IGNORES `compareTo` — today just
// `scatter`, which both the `scatter` and `bubble` widget types map to
// (CHART_TYPE_MAP above). A scatter binds ONE measure to its y axis, so the
// overlay was drawn through the PRIMARY's `YAxis dataKey` and painted
// "previous period" exactly on top of "current" (objectui#7402). It returns
// with the multi-measure projection declined as option A of objectui#7194.
// The sibling declaration for the inline chart path is `supportsCompareTo`
// in `@object-ui/plugin-charts`' ObjectChart; this is a second, deliberately
// narrow copy because plugin-charts is a devDependency here, not a runtime
// one. ⚠️ That list also excludes pie / donut / funnel and this one does
// not — a divergence older than this line, filed as objectui#7495 (the
// renderer drops the extra series for those families, so nothing is
// mis-drawn; the comparison query still runs).
const chartIgnoresCompareTo = chartType === 'scatter';
const pivotedSeries = dimensions.length >= 2 && values.length === 1;
const comparisonSeries = pivotedSeries
const comparisonSeries = pivotedSeries || chartIgnoresCompareTo
? []
: comparedValues.map((m) => {
// An overlay is the SAME measure one period back, so it takes its
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7402 — the DASHBOARD half of "a scatter ignores `compareTo`".
*
* `CHART_TYPE_MAP` maps BOTH widget types `scatter` and `bubble` onto
* `chartType: 'scatter'`, and this path had no chart-family exclusion at all:
* a compare-to scatter widget got a `revenue__compare` series appended, which
* the renderer then drew through the primary's single y axis — "previous
* period" painted exactly on top of "current".
*
* Both widget-type spellings are pinned, because an exclusion written against
* the widget type instead of the chart type would cover one and miss the other.
*
* The assertions are POSITIVE about the primary (it is still there, with its
* numbers) and positive about the absence of the overlay (no series keyed
* `<measure>__compare`) — "nothing refused" would also be true of a widget
* that rendered nothing.
*
* The `bar` control at the bottom is mandatory: without it, a regression that
* suppressed every comparison series would pass every assertion above.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';

let lastChartSchema: any = null;

vi.mock('@object-ui/react', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
SchemaRenderer: (props: any) => {
lastChartSchema = props.schema;
return null;
},
}));

import { DatasetWidget } from '../DatasetWidget';

afterEach(() => {
cleanup();
lastChartSchema = null;
});

const Q2 = { close_date: { $gte: '2026-04-01', $lte: '2026-06-30' } };

/** The executor answers with the `__compare` columns already attached. */
const makeSource = () => ({
queryDataset: vi.fn(async () => ({
rows: [
{ stage: 'won', revenue: 120, revenue__compare: 100 },
{ stage: 'lost', revenue: 20, revenue__compare: 40 },
],
fields: [{ name: 'revenue', type: 'number', label: 'Revenue' }],
})),
});

const renderWidget = (type: string, dataSource: unknown) =>
render(
<DatasetWidget
widget={{
type, dataset: 'sales', dimensions: ['stage'], values: ['revenue'],
filter: { ...Q2 }, compareTo: { kind: 'previousYear' },
}}
dataSource={dataSource}
/>,
);

/** Every series the chart was handed that reads a comparison column. */
const overlaySeriesOf = (schema: any) =>
(schema?.series ?? []).filter((s: any) => String(s?.dataKey).endsWith('__compare'));

describe.each(['scatter', 'bubble'])('DatasetWidget — widget type %s ignores compareTo (#7402)', (type) => {
it('charts the primary measure and appends NO comparison series', async () => {
const src = makeSource();
renderWidget(type, src);

await waitFor(() => expect(lastChartSchema).not.toBeNull());
// Both spellings reach the renderer as the SAME chart family — which is
// why one chart-type exclusion covers both.
expect(lastChartSchema.chartType).toBe('scatter');

// Primary: drawn, with its own numbers, untouched by the overlay's absence.
expect(lastChartSchema.series).toHaveLength(1);
expect(lastChartSchema.series[0]).toMatchObject({ dataKey: 'revenue' });
expect(lastChartSchema.series[0].variant).toBeUndefined();
expect(lastChartSchema.data[0]).toMatchObject({ stage: 'won', revenue: 120 });

// Overlay: positively absent, even though the executor DID return the
// `revenue__compare` column — the widget declines to draw it.
expect(overlaySeriesOf(lastChartSchema)).toEqual([]);
});
});

describe('DatasetWidget — the compareTo overlay control (#7402)', () => {
it('CONTROL: a bar widget with the same compareTo still gets its overlay', async () => {
const src = makeSource();
renderWidget('bar', src);

await waitFor(() => expect(lastChartSchema).not.toBeNull());
expect(lastChartSchema.chartType).toBe('bar');
expect(lastChartSchema.series).toHaveLength(2);
expect(lastChartSchema.series[0]).toMatchObject({ dataKey: 'revenue', variant: 'current' });
expect(overlaySeriesOf(lastChartSchema)).toHaveLength(1);
expect(overlaySeriesOf(lastChartSchema)[0]).toMatchObject({
dataKey: 'revenue__compare',
variant: 'comparison',
});
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
37 changes: 37 additions & 0 deletions .changeset/7402-scatter-ignores-compareto.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
'@object-ui/plugin-charts': minor
'@object-ui/plugin-dashboard': minor
---

`compareTo` on a `scatter` chart is no longer supported — scatter joins pie / donut /
funnel on the list of chart families that ignore it (objectui#7402, maintainer ruling
2026-09-03).

**This removes a published capability, deliberately.** Until now a `chartType: 'scatter'`
chart (and the dashboard widget types `scatter` and `bubble`, which both render as one)
with `compareTo` set synthesised a muted "previous period" overlay series. It drew the
wrong picture: a scatter binds ONE measure, and the renderer reads y through the single
`YAxis dataKey={series[0].dataKey}`, so the overlay was plotted on the PRIMARY series' y
— "previous period" painted exactly on top of "current" (objectui#7194).

Enforce-or-remove: rather than keep drawing that, the capability is removed until it can
be drawn honestly. Drawing a real second measure on a scatter needs the multi-measure
projection recorded as option A of objectui#7194, which is not built (zero authored
callers). **If and when that projection lands, `compareTo` on a scatter returns with
it** — it is the same missing mechanism, one payment.

What changes for authors:

- A `compareTo` on a scatter is now IGNORED rather than drawn. The primary series still
renders exactly as before — nothing refuses, nothing goes blank, and no comparison
query is issued on the inline chart path.
- No `<measure>__comparison` (inline chart) / `<measure>__compare` (dashboard) series is
appended for a scatter, so a compare-to scatter document also never reaches the
two-or-more-series scatter refusal being added under objectui#7194.
- Charts that keep the overlay: line, area, bar, horizontal-bar, combo. Charts that
ignore `compareTo`: pie, donut, funnel and — as of this change — scatter (and the
`bubble` widget type that renders as a scatter).

Reachability at the time of the change: **0** authored scatter/bubble instances in-repo
across both spellings (control `"type": "bar"` fires at 5 example files); incidence in
deployed tenant metadata is not measurable from this repo.
20 changes: 17 additions & 3 deletions packages/plugin-charts/src/ObjectChart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -333,9 +333,23 @@ export const ObjectChart = (props: any) => {
[schema.dataset, schema.dimensions, schema.values],
);

// Pie / donut / funnel are single-distribution charts where a comparison
// overlay would be meaningless — we skip the comparison fetch entirely.
const supportsCompareTo = (ct?: string) => ct !== 'pie' && ct !== 'donut' && ct !== 'funnel';
// Chart families that IGNORE `compareTo`: the comparison fetch is skipped
// entirely, so no `<valueKey>__comparison` column is produced and no overlay
// series is ever synthesised.
//
// - pie / donut / funnel — single-distribution charts where a comparison
// overlay would be meaningless.
// - scatter (objectui#7402) — a scatter binds ONE measure: the renderer
// reads y through the single `YAxis dataKey={series[0].dataKey}`, so the
// synthesised overlay was painted on the PRIMARY's y and "previous
// period" landed exactly on top of "current". Drawing it honestly needs
// the multi-measure projection declined as option A of objectui#7194;
// `compareTo` on a scatter returns WITH that projection. Until then the
// published capability is removed rather than left drawing a wrong
// picture — and because the overlay is never synthesised, a compare-to
// document never reaches #7194's two-or-more-series scatter refusal.
const supportsCompareTo = (ct?: string) =>
ct !== 'pie' && ct !== 'donut' && ct !== 'funnel' && ct !== 'scatter';

// Resolve the category dimension's option colors (P3). Best-effort: any
// failure leaves categoryColors null and the chart keeps the theme palette.
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7402 — a `scatter` IGNORES `compareTo`, exactly as pie / donut /
* funnel do.
*
* The overlay used to be synthesised for a scatter too, and the renderer reads
* y through the single `YAxis dataKey={series[0].dataKey}`: "previous period"
* was therefore painted on the PRIMARY's y, exactly on top of "current". The
* ruling on #7402 removes the published capability rather than keep drawing
* that picture; it returns with the multi-measure projection declined as
* option A of #7194.
*
* What is pinned here is the POSITIVE half of "ignored": the primary series
* (and its data) still reach ChartRenderer, and NO series whose `dataKey` ends
* `__comparison` is synthesised. Asserting only "no refusal" would pass for a
* chart that drew nothing at all.
*
* The `bar` control at the bottom is what makes those absences mean anything:
* a change that disabled `compareTo` everywhere would satisfy every scatter
* assertion above it and fail the control.
*
* Asserted at the schema handed to ChartRenderer — the seam the overlay is
* expressed in — as in `ObjectChart.compareTo.test.tsx`, because Recharts
* draws nothing at jsdom's zero-size container.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';

let lastSchema: any = null;

vi.mock('../ChartRenderer', () => ({
ChartRenderer: (props: any) => {
lastSchema = props.schema;
return null;
},
}));

import { ObjectChart, COMPARISON_SUFFIX } from '../ObjectChart';

/** ObjectChart probes `/api/v1/meta/object/deal` for option colors; answer it. */
function installMetaFetchDouble() {
const calls: string[] = [];
vi.stubGlobal(
'fetch',
vi.fn(async (input: unknown) => {
const url = String(
input && typeof input === 'object' && 'url' in input ? (input as { url: unknown }).url : input,
);
calls.push(url);
return { ok: true, json: async () => ({}) };
}),
);
return calls;
}

let metaCalls: string[] = [];

beforeEach(() => {
metaCalls = installMetaFetchDouble();
});

afterEach(() => {
expect(metaCalls.filter((u) => u !== '/api/v1/meta/object/deal')).toEqual([]);
vi.unstubAllGlobals();
cleanup();
lastSchema = null;
});

const quarterStart = (d: Date) => new Date(d.getFullYear(), Math.floor(d.getMonth() / 3) * 3, 1);
const iso = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const CURRENT_FROM = iso(quarterStart(new Date()));

/** 120 for the current window, 100 for any other window asked for. */
const makeSource = () => ({
aggregate: vi.fn(async (_object: string, q: any) => [
{ stage: 'won', amount: String(q?.filter?.close_date?.$gte) === CURRENT_FROM ? 120 : 100 },
]),
});

const renderChart = (chartType: string, dataSource: unknown, series?: unknown) =>
render(
<ObjectChart
schema={{
objectName: 'deal',
chartType,
aggregate: { field: 'amount', function: 'sum', groupBy: 'stage' },
filter: { close_date: { $gte: '{current_quarter_start}', $lte: '{current_quarter_end}' } },
xAxisKey: 'stage',
compareTo: { kind: 'previousYear' },
...(series ? { series } : {}),
}}
dataSource={dataSource}
/>,
);

/** Every series the chart was handed whose key is a synthesised overlay. */
const overlaySeriesOf = (schema: any) =>
(schema?.series ?? []).filter((s: any) => String(s?.dataKey).endsWith(COMPARISON_SUFFIX));

describe('ObjectChart — a scatter ignores compareTo (#7402)', () => {
it('draws the authored primary series and synthesises NO comparison overlay', async () => {
const src = makeSource();
renderChart('scatter', src, [{ dataKey: 'amount' }]);

await waitFor(() => expect(lastSchema).not.toBeNull());
// The comparison window is never even fetched — the same short-circuit
// pie / donut / funnel take.
expect(src.aggregate).toHaveBeenCalledTimes(1);

// Primary: present, intact, carrying its data.
expect(lastSchema.chartType).toBe('scatter');
expect(lastSchema.series).toHaveLength(1);
expect(lastSchema.series[0]).toMatchObject({ dataKey: 'amount' });
expect(lastSchema.data[0]).toMatchObject({ stage: 'won', amount: 120 });

// Overlay: positively absent — no series, and no column for one to read.
expect(overlaySeriesOf(lastSchema)).toEqual([]);
expect(lastSchema.data[0]).not.toHaveProperty(`amount${COMPARISON_SUFFIX}`);
});

it('adds no overlay when the author wrote no `series` at all', async () => {
// The authored spec for a compare-to scatter has ONE measure and no
// `series` key; the overlay was the only thing that ever put a second
// entry there. So the pin is that `series` stays UNsynthesised.
const src = makeSource();
renderChart('scatter', src);

await waitFor(() => expect(lastSchema).not.toBeNull());
expect(src.aggregate).toHaveBeenCalledTimes(1);
expect(overlaySeriesOf(lastSchema)).toEqual([]);
expect(lastSchema.data[0]).toMatchObject({ stage: 'won', amount: 120 });
expect(lastSchema.data[0]).not.toHaveProperty(`amount${COMPARISON_SUFFIX}`);
});

it('CONTROL: a bar with the same compareTo still synthesises the overlay', async () => {
// Without this, a regression that switched `compareTo` off for every chart
// type would pass both assertions above.
const src = makeSource();
renderChart('bar', src);

await waitFor(() => expect(src.aggregate).toHaveBeenCalledTimes(2));
await waitFor(() => expect(lastSchema?.series?.length).toBe(2));
expect(lastSchema.series[0]).toMatchObject({ dataKey: 'amount', variant: 'current' });
expect(overlaySeriesOf(lastSchema)).toHaveLength(1);
expect(overlaySeriesOf(lastSchema)[0]).toMatchObject({
dataKey: `amount${COMPARISON_SUFFIX}`,
variant: 'comparison',
});
expect(lastSchema.data[0]).toMatchObject({ amount: 120, [`amount${COMPARISON_SUFFIX}`]: 100 });
});
});
10 changes: 7 additions & 3 deletions packages/plugin-dashboard/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,9 +21,9 @@ executor re-runs the same selection over the shifted window and attaches a

- For **metric** & **gauge** widgets, a delta percentage surfaced as a `trend`
indicator (overrides any static `trend` prop).
- For **chart** widgets (line / area / bar / horizontal-bar / scatter / combo),
a muted second series (dashed line, lower fill opacity). Pie, donut, and
funnel charts ignore `compareTo`.
- For **chart** widgets (line / area / bar / horizontal-bar / combo), a muted
second series (dashed line, lower fill opacity). Pie, donut, funnel and
scatter charts ignore `compareTo`.
- For **table** widgets, a comparison column beside each compared measure.
- For a **pivot** cross-tab (`type: 'pivot'` with ≥2 dimensions), the comparison
is stacked **inside** the cell — current value on top, comparison value and
Expand DownExpand Up@@ -159,6 +159,10 @@ running over a window nobody asked for.
or omit `compareTo` entirely.
- Pie / donut / funnel charts — comparison overlays are not visually
meaningful and are silently ignored.
- Scatter charts, and the `bubble` widget type that renders as one — a scatter
binds ONE measure to its y axis, so an overlay could only be drawn on the
primary's own y. `compareTo` is ignored until scatter can project a second
measure (objectui#7194 option A); ruled in objectui#7402.

## Related

Expand Down
17 changes: 16 additions & 1 deletion packages/plugin-dashboard/src/DatasetWidget.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1401,8 +1401,23 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource:
// those series are dimension VALUES, not measures, so there is no per-measure
// series a comparison could pair with (the `__compare` columns are still in
// the rows — nothing is lost, it just isn't drawn as an overlay).
//
// Skipped, too, for a chart family that IGNORES `compareTo` — today just
// `scatter`, which both the `scatter` and `bubble` widget types map to
// (CHART_TYPE_MAP above). A scatter binds ONE measure to its y axis, so the
// overlay was drawn through the PRIMARY's `YAxis dataKey` and painted
// "previous period" exactly on top of "current" (objectui#7402). It returns
// with the multi-measure projection declined as option A of objectui#7194.
// The sibling declaration for the inline chart path is `supportsCompareTo`
// in `@object-ui/plugin-charts`' ObjectChart; this is a second, deliberately
// narrow copy because plugin-charts is a devDependency here, not a runtime
// one. ⚠️ That list also excludes pie / donut / funnel and this one does
// not — a divergence older than this line, filed as objectui#7495 (the
// renderer drops the extra series for those families, so nothing is
// mis-drawn; the comparison query still runs).
const chartIgnoresCompareTo = chartType === 'scatter';
const pivotedSeries = dimensions.length >= 2 && values.length === 1;
const comparisonSeries = pivotedSeries
const comparisonSeries = pivotedSeries || chartIgnoresCompareTo
? []
: comparedValues.map((m) => {
// An overlay is the SAME measure one period back, so it takes its
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7402 — the DASHBOARD half of "a scatter ignores `compareTo`".
*
* `CHART_TYPE_MAP` maps BOTH widget types `scatter` and `bubble` onto
* `chartType: 'scatter'`, and this path had no chart-family exclusion at all:
* a compare-to scatter widget got a `revenue__compare` series appended, which
* the renderer then drew through the primary's single y axis — "previous
* period" painted exactly on top of "current".
*
* Both widget-type spellings are pinned, because an exclusion written against
* the widget type instead of the chart type would cover one and miss the other.
*
* The assertions are POSITIVE about the primary (it is still there, with its
* numbers) and positive about the absence of the overlay (no series keyed
* `<measure>__compare`) — "nothing refused" would also be true of a widget
* that rendered nothing.
*
* The `bar` control at the bottom is mandatory: without it, a regression that
* suppressed every comparison series would pass every assertion above.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';

let lastChartSchema: any = null;

vi.mock('@object-ui/react', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
SchemaRenderer: (props: any) => {
lastChartSchema = props.schema;
return null;
},
}));

import { DatasetWidget } from '../DatasetWidget';

afterEach(() => {
cleanup();
lastChartSchema = null;
});

const Q2 = { close_date: { $gte: '2026-04-01', $lte: '2026-06-30' } };

/** The executor answers with the `__compare` columns already attached. */
const makeSource = () => ({
queryDataset: vi.fn(async () => ({
rows: [
{ stage: 'won', revenue: 120, revenue__compare: 100 },
{ stage: 'lost', revenue: 20, revenue__compare: 40 },
],
fields: [{ name: 'revenue', type: 'number', label: 'Revenue' }],
})),
});

const renderWidget = (type: string, dataSource: unknown) =>
render(
<DatasetWidget
widget={{
type, dataset: 'sales', dimensions: ['stage'], values: ['revenue'],
filter: { ...Q2 }, compareTo: { kind: 'previousYear' },
}}
dataSource={dataSource}
/>,
);

/** Every series the chart was handed that reads a comparison column. */
const overlaySeriesOf = (schema: any) =>
(schema?.series ?? []).filter((s: any) => String(s?.dataKey).endsWith('__compare'));

describe.each(['scatter', 'bubble'])('DatasetWidget — widget type %s ignores compareTo (#7402)', (type) => {
it('charts the primary measure and appends NO comparison series', async () => {
const src = makeSource();
renderWidget(type, src);

await waitFor(() => expect(lastChartSchema).not.toBeNull());
// Both spellings reach the renderer as the SAME chart family — which is
// why one chart-type exclusion covers both.
expect(lastChartSchema.chartType).toBe('scatter');

// Primary: drawn, with its own numbers, untouched by the overlay's absence.
expect(lastChartSchema.series).toHaveLength(1);
expect(lastChartSchema.series[0]).toMatchObject({ dataKey: 'revenue' });
expect(lastChartSchema.series[0].variant).toBeUndefined();
expect(lastChartSchema.data[0]).toMatchObject({ stage: 'won', revenue: 120 });

// Overlay: positively absent, even though the executor DID return the
// `revenue__compare` column — the widget declines to draw it.
expect(overlaySeriesOf(lastChartSchema)).toEqual([]);
});
});

describe('DatasetWidget — the compareTo overlay control (#7402)', () => {
it('CONTROL: a bar widget with the same compareTo still gets its overlay', async () => {
const src = makeSource();
renderWidget('bar', src);

await waitFor(() => expect(lastChartSchema).not.toBeNull());
expect(lastChartSchema.chartType).toBe('bar');
expect(lastChartSchema.series).toHaveLength(2);
expect(lastChartSchema.series[0]).toMatchObject({ dataKey: 'revenue', variant: 'current' });
expect(overlaySeriesOf(lastChartSchema)).toHaveLength(1);
expect(overlaySeriesOf(lastChartSchema)[0]).toMatchObject({
dataKey: 'revenue__compare',
variant: 'comparison',
});
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
37 changes: 37 additions & 0 deletions .changeset/7402-scatter-ignores-compareto.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
'@object-ui/plugin-charts': minor
'@object-ui/plugin-dashboard': minor
---

`compareTo` on a `scatter` chart is no longer supported — scatter joins pie / donut /
funnel on the list of chart families that ignore it (objectui#7402, maintainer ruling
2026-09-03).

**This removes a published capability, deliberately.** Until now a `chartType: 'scatter'`
chart (and the dashboard widget types `scatter` and `bubble`, which both render as one)
with `compareTo` set synthesised a muted "previous period" overlay series. It drew the
wrong picture: a scatter binds ONE measure, and the renderer reads y through the single
`YAxis dataKey={series[0].dataKey}`, so the overlay was plotted on the PRIMARY series' y
— "previous period" painted exactly on top of "current" (objectui#7194).

Enforce-or-remove: rather than keep drawing that, the capability is removed until it can
be drawn honestly. Drawing a real second measure on a scatter needs the multi-measure
projection recorded as option A of objectui#7194, which is not built (zero authored
callers). **If and when that projection lands, `compareTo` on a scatter returns with
it** — it is the same missing mechanism, one payment.

What changes for authors:

- A `compareTo` on a scatter is now IGNORED rather than drawn. The primary series still
renders exactly as before — nothing refuses, nothing goes blank, and no comparison
query is issued on the inline chart path.
- No `<measure>__comparison` (inline chart) / `<measure>__compare` (dashboard) series is
appended for a scatter, so a compare-to scatter document also never reaches the
two-or-more-series scatter refusal being added under objectui#7194.
- Charts that keep the overlay: line, area, bar, horizontal-bar, combo. Charts that
ignore `compareTo`: pie, donut, funnel and — as of this change — scatter (and the
`bubble` widget type that renders as a scatter).

Reachability at the time of the change: **0** authored scatter/bubble instances in-repo
across both spellings (control `"type": "bar"` fires at 5 example files); incidence in
deployed tenant metadata is not measurable from this repo.
20 changes: 17 additions & 3 deletions packages/plugin-charts/src/ObjectChart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -333,9 +333,23 @@ export const ObjectChart = (props: any) => {
[schema.dataset, schema.dimensions, schema.values],
);

// Pie / donut / funnel are single-distribution charts where a comparison
// overlay would be meaningless — we skip the comparison fetch entirely.
const supportsCompareTo = (ct?: string) => ct !== 'pie' && ct !== 'donut' && ct !== 'funnel';
// Chart families that IGNORE `compareTo`: the comparison fetch is skipped
// entirely, so no `<valueKey>__comparison` column is produced and no overlay
// series is ever synthesised.
//
// - pie / donut / funnel — single-distribution charts where a comparison
// overlay would be meaningless.
// - scatter (objectui#7402) — a scatter binds ONE measure: the renderer
// reads y through the single `YAxis dataKey={series[0].dataKey}`, so the
// synthesised overlay was painted on the PRIMARY's y and "previous
// period" landed exactly on top of "current". Drawing it honestly needs
// the multi-measure projection declined as option A of objectui#7194;
// `compareTo` on a scatter returns WITH that projection. Until then the
// published capability is removed rather than left drawing a wrong
// picture — and because the overlay is never synthesised, a compare-to
// document never reaches #7194's two-or-more-series scatter refusal.
const supportsCompareTo = (ct?: string) =>
ct !== 'pie' && ct !== 'donut' && ct !== 'funnel' && ct !== 'scatter';

// Resolve the category dimension's option colors (P3). Best-effort: any
// failure leaves categoryColors null and the chart keeps the theme palette.
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7402 — a `scatter` IGNORES `compareTo`, exactly as pie / donut /
* funnel do.
*
* The overlay used to be synthesised for a scatter too, and the renderer reads
* y through the single `YAxis dataKey={series[0].dataKey}`: "previous period"
* was therefore painted on the PRIMARY's y, exactly on top of "current". The
* ruling on #7402 removes the published capability rather than keep drawing
* that picture; it returns with the multi-measure projection declined as
* option A of #7194.
*
* What is pinned here is the POSITIVE half of "ignored": the primary series
* (and its data) still reach ChartRenderer, and NO series whose `dataKey` ends
* `__comparison` is synthesised. Asserting only "no refusal" would pass for a
* chart that drew nothing at all.
*
* The `bar` control at the bottom is what makes those absences mean anything:
* a change that disabled `compareTo` everywhere would satisfy every scatter
* assertion above it and fail the control.
*
* Asserted at the schema handed to ChartRenderer — the seam the overlay is
* expressed in — as in `ObjectChart.compareTo.test.tsx`, because Recharts
* draws nothing at jsdom's zero-size container.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';

let lastSchema: any = null;

vi.mock('../ChartRenderer', () => ({
ChartRenderer: (props: any) => {
lastSchema = props.schema;
return null;
},
}));

import { ObjectChart, COMPARISON_SUFFIX } from '../ObjectChart';

/** ObjectChart probes `/api/v1/meta/object/deal` for option colors; answer it. */
function installMetaFetchDouble() {
const calls: string[] = [];
vi.stubGlobal(
'fetch',
vi.fn(async (input: unknown) => {
const url = String(
input && typeof input === 'object' && 'url' in input ? (input as { url: unknown }).url : input,
);
calls.push(url);
return { ok: true, json: async () => ({}) };
}),
);
return calls;
}

let metaCalls: string[] = [];

beforeEach(() => {
metaCalls = installMetaFetchDouble();
});

afterEach(() => {
expect(metaCalls.filter((u) => u !== '/api/v1/meta/object/deal')).toEqual([]);
vi.unstubAllGlobals();
cleanup();
lastSchema = null;
});

const quarterStart = (d: Date) => new Date(d.getFullYear(), Math.floor(d.getMonth() / 3) * 3, 1);
const iso = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const CURRENT_FROM = iso(quarterStart(new Date()));

/** 120 for the current window, 100 for any other window asked for. */
const makeSource = () => ({
aggregate: vi.fn(async (_object: string, q: any) => [
{ stage: 'won', amount: String(q?.filter?.close_date?.$gte) === CURRENT_FROM ? 120 : 100 },
]),
});

const renderChart = (chartType: string, dataSource: unknown, series?: unknown) =>
render(
<ObjectChart
schema={{
objectName: 'deal',
chartType,
aggregate: { field: 'amount', function: 'sum', groupBy: 'stage' },
filter: { close_date: { $gte: '{current_quarter_start}', $lte: '{current_quarter_end}' } },
xAxisKey: 'stage',
compareTo: { kind: 'previousYear' },
...(series ? { series } : {}),
}}
dataSource={dataSource}
/>,
);

/** Every series the chart was handed whose key is a synthesised overlay. */
const overlaySeriesOf = (schema: any) =>
(schema?.series ?? []).filter((s: any) => String(s?.dataKey).endsWith(COMPARISON_SUFFIX));

describe('ObjectChart — a scatter ignores compareTo (#7402)', () => {
it('draws the authored primary series and synthesises NO comparison overlay', async () => {
const src = makeSource();
renderChart('scatter', src, [{ dataKey: 'amount' }]);

await waitFor(() => expect(lastSchema).not.toBeNull());
// The comparison window is never even fetched — the same short-circuit
// pie / donut / funnel take.
expect(src.aggregate).toHaveBeenCalledTimes(1);

// Primary: present, intact, carrying its data.
expect(lastSchema.chartType).toBe('scatter');
expect(lastSchema.series).toHaveLength(1);
expect(lastSchema.series[0]).toMatchObject({ dataKey: 'amount' });
expect(lastSchema.data[0]).toMatchObject({ stage: 'won', amount: 120 });

// Overlay: positively absent — no series, and no column for one to read.
expect(overlaySeriesOf(lastSchema)).toEqual([]);
expect(lastSchema.data[0]).not.toHaveProperty(`amount${COMPARISON_SUFFIX}`);
});

it('adds no overlay when the author wrote no `series` at all', async () => {
// The authored spec for a compare-to scatter has ONE measure and no
// `series` key; the overlay was the only thing that ever put a second
// entry there. So the pin is that `series` stays UNsynthesised.
const src = makeSource();
renderChart('scatter', src);

await waitFor(() => expect(lastSchema).not.toBeNull());
expect(src.aggregate).toHaveBeenCalledTimes(1);
expect(overlaySeriesOf(lastSchema)).toEqual([]);
expect(lastSchema.data[0]).toMatchObject({ stage: 'won', amount: 120 });
expect(lastSchema.data[0]).not.toHaveProperty(`amount${COMPARISON_SUFFIX}`);
});

it('CONTROL: a bar with the same compareTo still synthesises the overlay', async () => {
// Without this, a regression that switched `compareTo` off for every chart
// type would pass both assertions above.
const src = makeSource();
renderChart('bar', src);

await waitFor(() => expect(src.aggregate).toHaveBeenCalledTimes(2));
await waitFor(() => expect(lastSchema?.series?.length).toBe(2));
expect(lastSchema.series[0]).toMatchObject({ dataKey: 'amount', variant: 'current' });
expect(overlaySeriesOf(lastSchema)).toHaveLength(1);
expect(overlaySeriesOf(lastSchema)[0]).toMatchObject({
dataKey: `amount${COMPARISON_SUFFIX}`,
variant: 'comparison',
});
expect(lastSchema.data[0]).toMatchObject({ amount: 120, [`amount${COMPARISON_SUFFIX}`]: 100 });
});
});
10 changes: 7 additions & 3 deletions packages/plugin-dashboard/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,9 +21,9 @@ executor re-runs the same selection over the shifted window and attaches a

- For **metric** & **gauge** widgets, a delta percentage surfaced as a `trend`
indicator (overrides any static `trend` prop).
- For **chart** widgets (line / area / bar / horizontal-bar / scatter / combo),
a muted second series (dashed line, lower fill opacity). Pie, donut, and
funnel charts ignore `compareTo`.
- For **chart** widgets (line / area / bar / horizontal-bar / combo), a muted
second series (dashed line, lower fill opacity). Pie, donut, funnel and
scatter charts ignore `compareTo`.
- For **table** widgets, a comparison column beside each compared measure.
- For a **pivot** cross-tab (`type: 'pivot'` with ≥2 dimensions), the comparison
is stacked **inside** the cell — current value on top, comparison value and
Expand DownExpand Up@@ -159,6 +159,10 @@ running over a window nobody asked for.
or omit `compareTo` entirely.
- Pie / donut / funnel charts — comparison overlays are not visually
meaningful and are silently ignored.
- Scatter charts, and the `bubble` widget type that renders as one — a scatter
binds ONE measure to its y axis, so an overlay could only be drawn on the
primary's own y. `compareTo` is ignored until scatter can project a second
measure (objectui#7194 option A); ruled in objectui#7402.

## Related

Expand Down
17 changes: 16 additions & 1 deletion packages/plugin-dashboard/src/DatasetWidget.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1401,8 +1401,23 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource:
// those series are dimension VALUES, not measures, so there is no per-measure
// series a comparison could pair with (the `__compare` columns are still in
// the rows — nothing is lost, it just isn't drawn as an overlay).
//
// Skipped, too, for a chart family that IGNORES `compareTo` — today just
// `scatter`, which both the `scatter` and `bubble` widget types map to
// (CHART_TYPE_MAP above). A scatter binds ONE measure to its y axis, so the
// overlay was drawn through the PRIMARY's `YAxis dataKey` and painted
// "previous period" exactly on top of "current" (objectui#7402). It returns
// with the multi-measure projection declined as option A of objectui#7194.
// The sibling declaration for the inline chart path is `supportsCompareTo`
// in `@object-ui/plugin-charts`' ObjectChart; this is a second, deliberately
// narrow copy because plugin-charts is a devDependency here, not a runtime
// one. ⚠️ That list also excludes pie / donut / funnel and this one does
// not — a divergence older than this line, filed as objectui#7495 (the
// renderer drops the extra series for those families, so nothing is
// mis-drawn; the comparison query still runs).
const chartIgnoresCompareTo = chartType === 'scatter';
const pivotedSeries = dimensions.length >= 2 && values.length === 1;
const comparisonSeries = pivotedSeries
const comparisonSeries = pivotedSeries || chartIgnoresCompareTo
? []
: comparedValues.map((m) => {
// An overlay is the SAME measure one period back, so it takes its
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7402 — the DASHBOARD half of "a scatter ignores `compareTo`".
*
* `CHART_TYPE_MAP` maps BOTH widget types `scatter` and `bubble` onto
* `chartType: 'scatter'`, and this path had no chart-family exclusion at all:
* a compare-to scatter widget got a `revenue__compare` series appended, which
* the renderer then drew through the primary's single y axis — "previous
* period" painted exactly on top of "current".
*
* Both widget-type spellings are pinned, because an exclusion written against
* the widget type instead of the chart type would cover one and miss the other.
*
* The assertions are POSITIVE about the primary (it is still there, with its
* numbers) and positive about the absence of the overlay (no series keyed
* `<measure>__compare`) — "nothing refused" would also be true of a widget
* that rendered nothing.
*
* The `bar` control at the bottom is mandatory: without it, a regression that
* suppressed every comparison series would pass every assertion above.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';

let lastChartSchema: any = null;

vi.mock('@object-ui/react', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
SchemaRenderer: (props: any) => {
lastChartSchema = props.schema;
return null;
},
}));

import { DatasetWidget } from '../DatasetWidget';

afterEach(() => {
cleanup();
lastChartSchema = null;
});

const Q2 = { close_date: { $gte: '2026-04-01', $lte: '2026-06-30' } };

/** The executor answers with the `__compare` columns already attached. */
const makeSource = () => ({
queryDataset: vi.fn(async () => ({
rows: [
{ stage: 'won', revenue: 120, revenue__compare: 100 },
{ stage: 'lost', revenue: 20, revenue__compare: 40 },
],
fields: [{ name: 'revenue', type: 'number', label: 'Revenue' }],
})),
});

const renderWidget = (type: string, dataSource: unknown) =>
render(
<DatasetWidget
widget={{
type, dataset: 'sales', dimensions: ['stage'], values: ['revenue'],
filter: { ...Q2 }, compareTo: { kind: 'previousYear' },
}}
dataSource={dataSource}
/>,
);

/** Every series the chart was handed that reads a comparison column. */
const overlaySeriesOf = (schema: any) =>
(schema?.series ?? []).filter((s: any) => String(s?.dataKey).endsWith('__compare'));

describe.each(['scatter', 'bubble'])('DatasetWidget — widget type %s ignores compareTo (#7402)', (type) => {
it('charts the primary measure and appends NO comparison series', async () => {
const src = makeSource();
renderWidget(type, src);

await waitFor(() => expect(lastChartSchema).not.toBeNull());
// Both spellings reach the renderer as the SAME chart family — which is
// why one chart-type exclusion covers both.
expect(lastChartSchema.chartType).toBe('scatter');

// Primary: drawn, with its own numbers, untouched by the overlay's absence.
expect(lastChartSchema.series).toHaveLength(1);
expect(lastChartSchema.series[0]).toMatchObject({ dataKey: 'revenue' });
expect(lastChartSchema.series[0].variant).toBeUndefined();
expect(lastChartSchema.data[0]).toMatchObject({ stage: 'won', revenue: 120 });

// Overlay: positively absent, even though the executor DID return the
// `revenue__compare` column — the widget declines to draw it.
expect(overlaySeriesOf(lastChartSchema)).toEqual([]);
});
});

describe('DatasetWidget — the compareTo overlay control (#7402)', () => {
it('CONTROL: a bar widget with the same compareTo still gets its overlay', async () => {
const src = makeSource();
renderWidget('bar', src);

await waitFor(() => expect(lastChartSchema).not.toBeNull());
expect(lastChartSchema.chartType).toBe('bar');
expect(lastChartSchema.series).toHaveLength(2);
expect(lastChartSchema.series[0]).toMatchObject({ dataKey: 'revenue', variant: 'current' });
expect(overlaySeriesOf(lastChartSchema)).toHaveLength(1);
expect(overlaySeriesOf(lastChartSchema)[0]).toMatchObject({
dataKey: 'revenue__compare',
variant: 'comparison',
});
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
37 changes: 37 additions & 0 deletions .changeset/7402-scatter-ignores-compareto.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
'@object-ui/plugin-charts': minor
'@object-ui/plugin-dashboard': minor
---

`compareTo` on a `scatter` chart is no longer supported — scatter joins pie / donut /
funnel on the list of chart families that ignore it (objectui#7402, maintainer ruling
2026-09-03).

**This removes a published capability, deliberately.** Until now a `chartType: 'scatter'`
chart (and the dashboard widget types `scatter` and `bubble`, which both render as one)
with `compareTo` set synthesised a muted "previous period" overlay series. It drew the
wrong picture: a scatter binds ONE measure, and the renderer reads y through the single
`YAxis dataKey={series[0].dataKey}`, so the overlay was plotted on the PRIMARY series' y
— "previous period" painted exactly on top of "current" (objectui#7194).

Enforce-or-remove: rather than keep drawing that, the capability is removed until it can
be drawn honestly. Drawing a real second measure on a scatter needs the multi-measure
projection recorded as option A of objectui#7194, which is not built (zero authored
callers). **If and when that projection lands, `compareTo` on a scatter returns with
it** — it is the same missing mechanism, one payment.

What changes for authors:

- A `compareTo` on a scatter is now IGNORED rather than drawn. The primary series still
renders exactly as before — nothing refuses, nothing goes blank, and no comparison
query is issued on the inline chart path.
- No `<measure>__comparison` (inline chart) / `<measure>__compare` (dashboard) series is
appended for a scatter, so a compare-to scatter document also never reaches the
two-or-more-series scatter refusal being added under objectui#7194.
- Charts that keep the overlay: line, area, bar, horizontal-bar, combo. Charts that
ignore `compareTo`: pie, donut, funnel and — as of this change — scatter (and the
`bubble` widget type that renders as a scatter).

Reachability at the time of the change: **0** authored scatter/bubble instances in-repo
across both spellings (control `"type": "bar"` fires at 5 example files); incidence in
deployed tenant metadata is not measurable from this repo.
20 changes: 17 additions & 3 deletions packages/plugin-charts/src/ObjectChart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -333,9 +333,23 @@ export const ObjectChart = (props: any) => {
[schema.dataset, schema.dimensions, schema.values],
);

// Pie / donut / funnel are single-distribution charts where a comparison
// overlay would be meaningless — we skip the comparison fetch entirely.
const supportsCompareTo = (ct?: string) => ct !== 'pie' && ct !== 'donut' && ct !== 'funnel';
// Chart families that IGNORE `compareTo`: the comparison fetch is skipped
// entirely, so no `<valueKey>__comparison` column is produced and no overlay
// series is ever synthesised.
//
// - pie / donut / funnel — single-distribution charts where a comparison
// overlay would be meaningless.
// - scatter (objectui#7402) — a scatter binds ONE measure: the renderer
// reads y through the single `YAxis dataKey={series[0].dataKey}`, so the
// synthesised overlay was painted on the PRIMARY's y and "previous
// period" landed exactly on top of "current". Drawing it honestly needs
// the multi-measure projection declined as option A of objectui#7194;
// `compareTo` on a scatter returns WITH that projection. Until then the
// published capability is removed rather than left drawing a wrong
// picture — and because the overlay is never synthesised, a compare-to
// document never reaches #7194's two-or-more-series scatter refusal.
const supportsCompareTo = (ct?: string) =>
ct !== 'pie' && ct !== 'donut' && ct !== 'funnel' && ct !== 'scatter';

// Resolve the category dimension's option colors (P3). Best-effort: any
// failure leaves categoryColors null and the chart keeps the theme palette.
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7402 — a `scatter` IGNORES `compareTo`, exactly as pie / donut /
* funnel do.
*
* The overlay used to be synthesised for a scatter too, and the renderer reads
* y through the single `YAxis dataKey={series[0].dataKey}`: "previous period"
* was therefore painted on the PRIMARY's y, exactly on top of "current". The
* ruling on #7402 removes the published capability rather than keep drawing
* that picture; it returns with the multi-measure projection declined as
* option A of #7194.
*
* What is pinned here is the POSITIVE half of "ignored": the primary series
* (and its data) still reach ChartRenderer, and NO series whose `dataKey` ends
* `__comparison` is synthesised. Asserting only "no refusal" would pass for a
* chart that drew nothing at all.
*
* The `bar` control at the bottom is what makes those absences mean anything:
* a change that disabled `compareTo` everywhere would satisfy every scatter
* assertion above it and fail the control.
*
* Asserted at the schema handed to ChartRenderer — the seam the overlay is
* expressed in — as in `ObjectChart.compareTo.test.tsx`, because Recharts
* draws nothing at jsdom's zero-size container.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';

let lastSchema: any = null;

vi.mock('../ChartRenderer', () => ({
ChartRenderer: (props: any) => {
lastSchema = props.schema;
return null;
},
}));

import { ObjectChart, COMPARISON_SUFFIX } from '../ObjectChart';

/** ObjectChart probes `/api/v1/meta/object/deal` for option colors; answer it. */
function installMetaFetchDouble() {
const calls: string[] = [];
vi.stubGlobal(
'fetch',
vi.fn(async (input: unknown) => {
const url = String(
input && typeof input === 'object' && 'url' in input ? (input as { url: unknown }).url : input,
);
calls.push(url);
return { ok: true, json: async () => ({}) };
}),
);
return calls;
}

let metaCalls: string[] = [];

beforeEach(() => {
metaCalls = installMetaFetchDouble();
});

afterEach(() => {
expect(metaCalls.filter((u) => u !== '/api/v1/meta/object/deal')).toEqual([]);
vi.unstubAllGlobals();
cleanup();
lastSchema = null;
});

const quarterStart = (d: Date) => new Date(d.getFullYear(), Math.floor(d.getMonth() / 3) * 3, 1);
const iso = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const CURRENT_FROM = iso(quarterStart(new Date()));

/** 120 for the current window, 100 for any other window asked for. */
const makeSource = () => ({
aggregate: vi.fn(async (_object: string, q: any) => [
{ stage: 'won', amount: String(q?.filter?.close_date?.$gte) === CURRENT_FROM ? 120 : 100 },
]),
});

const renderChart = (chartType: string, dataSource: unknown, series?: unknown) =>
render(
<ObjectChart
schema={{
objectName: 'deal',
chartType,
aggregate: { field: 'amount', function: 'sum', groupBy: 'stage' },
filter: { close_date: { $gte: '{current_quarter_start}', $lte: '{current_quarter_end}' } },
xAxisKey: 'stage',
compareTo: { kind: 'previousYear' },
...(series ? { series } : {}),
}}
dataSource={dataSource}
/>,
);

/** Every series the chart was handed whose key is a synthesised overlay. */
const overlaySeriesOf = (schema: any) =>
(schema?.series ?? []).filter((s: any) => String(s?.dataKey).endsWith(COMPARISON_SUFFIX));

describe('ObjectChart — a scatter ignores compareTo (#7402)', () => {
it('draws the authored primary series and synthesises NO comparison overlay', async () => {
const src = makeSource();
renderChart('scatter', src, [{ dataKey: 'amount' }]);

await waitFor(() => expect(lastSchema).not.toBeNull());
// The comparison window is never even fetched — the same short-circuit
// pie / donut / funnel take.
expect(src.aggregate).toHaveBeenCalledTimes(1);

// Primary: present, intact, carrying its data.
expect(lastSchema.chartType).toBe('scatter');
expect(lastSchema.series).toHaveLength(1);
expect(lastSchema.series[0]).toMatchObject({ dataKey: 'amount' });
expect(lastSchema.data[0]).toMatchObject({ stage: 'won', amount: 120 });

// Overlay: positively absent — no series, and no column for one to read.
expect(overlaySeriesOf(lastSchema)).toEqual([]);
expect(lastSchema.data[0]).not.toHaveProperty(`amount${COMPARISON_SUFFIX}`);
});

it('adds no overlay when the author wrote no `series` at all', async () => {
// The authored spec for a compare-to scatter has ONE measure and no
// `series` key; the overlay was the only thing that ever put a second
// entry there. So the pin is that `series` stays UNsynthesised.
const src = makeSource();
renderChart('scatter', src);

await waitFor(() => expect(lastSchema).not.toBeNull());
expect(src.aggregate).toHaveBeenCalledTimes(1);
expect(overlaySeriesOf(lastSchema)).toEqual([]);
expect(lastSchema.data[0]).toMatchObject({ stage: 'won', amount: 120 });
expect(lastSchema.data[0]).not.toHaveProperty(`amount${COMPARISON_SUFFIX}`);
});

it('CONTROL: a bar with the same compareTo still synthesises the overlay', async () => {
// Without this, a regression that switched `compareTo` off for every chart
// type would pass both assertions above.
const src = makeSource();
renderChart('bar', src);

await waitFor(() => expect(src.aggregate).toHaveBeenCalledTimes(2));
await waitFor(() => expect(lastSchema?.series?.length).toBe(2));
expect(lastSchema.series[0]).toMatchObject({ dataKey: 'amount', variant: 'current' });
expect(overlaySeriesOf(lastSchema)).toHaveLength(1);
expect(overlaySeriesOf(lastSchema)[0]).toMatchObject({
dataKey: `amount${COMPARISON_SUFFIX}`,
variant: 'comparison',
});
expect(lastSchema.data[0]).toMatchObject({ amount: 120, [`amount${COMPARISON_SUFFIX}`]: 100 });
});
});
10 changes: 7 additions & 3 deletions packages/plugin-dashboard/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,9 +21,9 @@ executor re-runs the same selection over the shifted window and attaches a

- For **metric** & **gauge** widgets, a delta percentage surfaced as a `trend`
indicator (overrides any static `trend` prop).
- For **chart** widgets (line / area / bar / horizontal-bar / scatter / combo),
a muted second series (dashed line, lower fill opacity). Pie, donut, and
funnel charts ignore `compareTo`.
- For **chart** widgets (line / area / bar / horizontal-bar / combo), a muted
second series (dashed line, lower fill opacity). Pie, donut, funnel and
scatter charts ignore `compareTo`.
- For **table** widgets, a comparison column beside each compared measure.
- For a **pivot** cross-tab (`type: 'pivot'` with ≥2 dimensions), the comparison
is stacked **inside** the cell — current value on top, comparison value and
Expand DownExpand Up@@ -159,6 +159,10 @@ running over a window nobody asked for.
or omit `compareTo` entirely.
- Pie / donut / funnel charts — comparison overlays are not visually
meaningful and are silently ignored.
- Scatter charts, and the `bubble` widget type that renders as one — a scatter
binds ONE measure to its y axis, so an overlay could only be drawn on the
primary's own y. `compareTo` is ignored until scatter can project a second
measure (objectui#7194 option A); ruled in objectui#7402.

## Related

Expand Down
17 changes: 16 additions & 1 deletion packages/plugin-dashboard/src/DatasetWidget.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1401,8 +1401,23 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource:
// those series are dimension VALUES, not measures, so there is no per-measure
// series a comparison could pair with (the `__compare` columns are still in
// the rows — nothing is lost, it just isn't drawn as an overlay).
//
// Skipped, too, for a chart family that IGNORES `compareTo` — today just
// `scatter`, which both the `scatter` and `bubble` widget types map to
// (CHART_TYPE_MAP above). A scatter binds ONE measure to its y axis, so the
// overlay was drawn through the PRIMARY's `YAxis dataKey` and painted
// "previous period" exactly on top of "current" (objectui#7402). It returns
// with the multi-measure projection declined as option A of objectui#7194.
// The sibling declaration for the inline chart path is `supportsCompareTo`
// in `@object-ui/plugin-charts`' ObjectChart; this is a second, deliberately
// narrow copy because plugin-charts is a devDependency here, not a runtime
// one. ⚠️ That list also excludes pie / donut / funnel and this one does
// not — a divergence older than this line, filed as objectui#7495 (the
// renderer drops the extra series for those families, so nothing is
// mis-drawn; the comparison query still runs).
const chartIgnoresCompareTo = chartType === 'scatter';
const pivotedSeries = dimensions.length >= 2 && values.length === 1;
const comparisonSeries = pivotedSeries
const comparisonSeries = pivotedSeries || chartIgnoresCompareTo
? []
: comparedValues.map((m) => {
// An overlay is the SAME measure one period back, so it takes its
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7402 — the DASHBOARD half of "a scatter ignores `compareTo`".
*
* `CHART_TYPE_MAP` maps BOTH widget types `scatter` and `bubble` onto
* `chartType: 'scatter'`, and this path had no chart-family exclusion at all:
* a compare-to scatter widget got a `revenue__compare` series appended, which
* the renderer then drew through the primary's single y axis — "previous
* period" painted exactly on top of "current".
*
* Both widget-type spellings are pinned, because an exclusion written against
* the widget type instead of the chart type would cover one and miss the other.
*
* The assertions are POSITIVE about the primary (it is still there, with its
* numbers) and positive about the absence of the overlay (no series keyed
* `<measure>__compare`) — "nothing refused" would also be true of a widget
* that rendered nothing.
*
* The `bar` control at the bottom is mandatory: without it, a regression that
* suppressed every comparison series would pass every assertion above.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';

let lastChartSchema: any = null;

vi.mock('@object-ui/react', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
SchemaRenderer: (props: any) => {
lastChartSchema = props.schema;
return null;
},
}));

import { DatasetWidget } from '../DatasetWidget';

afterEach(() => {
cleanup();
lastChartSchema = null;
});

const Q2 = { close_date: { $gte: '2026-04-01', $lte: '2026-06-30' } };

/** The executor answers with the `__compare` columns already attached. */
const makeSource = () => ({
queryDataset: vi.fn(async () => ({
rows: [
{ stage: 'won', revenue: 120, revenue__compare: 100 },
{ stage: 'lost', revenue: 20, revenue__compare: 40 },
],
fields: [{ name: 'revenue', type: 'number', label: 'Revenue' }],
})),
});

const renderWidget = (type: string, dataSource: unknown) =>
render(
<DatasetWidget
widget={{
type, dataset: 'sales', dimensions: ['stage'], values: ['revenue'],
filter: { ...Q2 }, compareTo: { kind: 'previousYear' },
}}
dataSource={dataSource}
/>,
);

/** Every series the chart was handed that reads a comparison column. */
const overlaySeriesOf = (schema: any) =>
(schema?.series ?? []).filter((s: any) => String(s?.dataKey).endsWith('__compare'));

describe.each(['scatter', 'bubble'])('DatasetWidget — widget type %s ignores compareTo (#7402)', (type) => {
it('charts the primary measure and appends NO comparison series', async () => {
const src = makeSource();
renderWidget(type, src);

await waitFor(() => expect(lastChartSchema).not.toBeNull());
// Both spellings reach the renderer as the SAME chart family — which is
// why one chart-type exclusion covers both.
expect(lastChartSchema.chartType).toBe('scatter');

// Primary: drawn, with its own numbers, untouched by the overlay's absence.
expect(lastChartSchema.series).toHaveLength(1);
expect(lastChartSchema.series[0]).toMatchObject({ dataKey: 'revenue' });
expect(lastChartSchema.series[0].variant).toBeUndefined();
expect(lastChartSchema.data[0]).toMatchObject({ stage: 'won', revenue: 120 });

// Overlay: positively absent, even though the executor DID return the
// `revenue__compare` column — the widget declines to draw it.
expect(overlaySeriesOf(lastChartSchema)).toEqual([]);
});
});

describe('DatasetWidget — the compareTo overlay control (#7402)', () => {
it('CONTROL: a bar widget with the same compareTo still gets its overlay', async () => {
const src = makeSource();
renderWidget('bar', src);

await waitFor(() => expect(lastChartSchema).not.toBeNull());
expect(lastChartSchema.chartType).toBe('bar');
expect(lastChartSchema.series).toHaveLength(2);
expect(lastChartSchema.series[0]).toMatchObject({ dataKey: 'revenue', variant: 'current' });
expect(overlaySeriesOf(lastChartSchema)).toHaveLength(1);
expect(overlaySeriesOf(lastChartSchema)[0]).toMatchObject({
dataKey: 'revenue__compare',
variant: 'comparison',
});
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
37 changes: 37 additions & 0 deletions .changeset/7402-scatter-ignores-compareto.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
'@object-ui/plugin-charts': minor
'@object-ui/plugin-dashboard': minor
---

`compareTo` on a `scatter` chart is no longer supported — scatter joins pie / donut /
funnel on the list of chart families that ignore it (objectui#7402, maintainer ruling
2026-09-03).

**This removes a published capability, deliberately.** Until now a `chartType: 'scatter'`
chart (and the dashboard widget types `scatter` and `bubble`, which both render as one)
with `compareTo` set synthesised a muted "previous period" overlay series. It drew the
wrong picture: a scatter binds ONE measure, and the renderer reads y through the single
`YAxis dataKey={series[0].dataKey}`, so the overlay was plotted on the PRIMARY series' y
— "previous period" painted exactly on top of "current" (objectui#7194).

Enforce-or-remove: rather than keep drawing that, the capability is removed until it can
be drawn honestly. Drawing a real second measure on a scatter needs the multi-measure
projection recorded as option A of objectui#7194, which is not built (zero authored
callers). **If and when that projection lands, `compareTo` on a scatter returns with
it** — it is the same missing mechanism, one payment.

What changes for authors:

- A `compareTo` on a scatter is now IGNORED rather than drawn. The primary series still
renders exactly as before — nothing refuses, nothing goes blank, and no comparison
query is issued on the inline chart path.
- No `<measure>__comparison` (inline chart) / `<measure>__compare` (dashboard) series is
appended for a scatter, so a compare-to scatter document also never reaches the
two-or-more-series scatter refusal being added under objectui#7194.
- Charts that keep the overlay: line, area, bar, horizontal-bar, combo. Charts that
ignore `compareTo`: pie, donut, funnel and — as of this change — scatter (and the
`bubble` widget type that renders as a scatter).

Reachability at the time of the change: **0** authored scatter/bubble instances in-repo
across both spellings (control `"type": "bar"` fires at 5 example files); incidence in
deployed tenant metadata is not measurable from this repo.
20 changes: 17 additions & 3 deletions packages/plugin-charts/src/ObjectChart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -333,9 +333,23 @@ export const ObjectChart = (props: any) => {
[schema.dataset, schema.dimensions, schema.values],
);

// Pie / donut / funnel are single-distribution charts where a comparison
// overlay would be meaningless — we skip the comparison fetch entirely.
const supportsCompareTo = (ct?: string) => ct !== 'pie' && ct !== 'donut' && ct !== 'funnel';
// Chart families that IGNORE `compareTo`: the comparison fetch is skipped
// entirely, so no `<valueKey>__comparison` column is produced and no overlay
// series is ever synthesised.
//
// - pie / donut / funnel — single-distribution charts where a comparison
// overlay would be meaningless.
// - scatter (objectui#7402) — a scatter binds ONE measure: the renderer
// reads y through the single `YAxis dataKey={series[0].dataKey}`, so the
// synthesised overlay was painted on the PRIMARY's y and "previous
// period" landed exactly on top of "current". Drawing it honestly needs
// the multi-measure projection declined as option A of objectui#7194;
// `compareTo` on a scatter returns WITH that projection. Until then the
// published capability is removed rather than left drawing a wrong
// picture — and because the overlay is never synthesised, a compare-to
// document never reaches #7194's two-or-more-series scatter refusal.
const supportsCompareTo = (ct?: string) =>
ct !== 'pie' && ct !== 'donut' && ct !== 'funnel' && ct !== 'scatter';

// Resolve the category dimension's option colors (P3). Best-effort: any
// failure leaves categoryColors null and the chart keeps the theme palette.
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7402 — a `scatter` IGNORES `compareTo`, exactly as pie / donut /
* funnel do.
*
* The overlay used to be synthesised for a scatter too, and the renderer reads
* y through the single `YAxis dataKey={series[0].dataKey}`: "previous period"
* was therefore painted on the PRIMARY's y, exactly on top of "current". The
* ruling on #7402 removes the published capability rather than keep drawing
* that picture; it returns with the multi-measure projection declined as
* option A of #7194.
*
* What is pinned here is the POSITIVE half of "ignored": the primary series
* (and its data) still reach ChartRenderer, and NO series whose `dataKey` ends
* `__comparison` is synthesised. Asserting only "no refusal" would pass for a
* chart that drew nothing at all.
*
* The `bar` control at the bottom is what makes those absences mean anything:
* a change that disabled `compareTo` everywhere would satisfy every scatter
* assertion above it and fail the control.
*
* Asserted at the schema handed to ChartRenderer — the seam the overlay is
* expressed in — as in `ObjectChart.compareTo.test.tsx`, because Recharts
* draws nothing at jsdom's zero-size container.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';

let lastSchema: any = null;

vi.mock('../ChartRenderer', () => ({
ChartRenderer: (props: any) => {
lastSchema = props.schema;
return null;
},
}));

import { ObjectChart, COMPARISON_SUFFIX } from '../ObjectChart';

/** ObjectChart probes `/api/v1/meta/object/deal` for option colors; answer it. */
function installMetaFetchDouble() {
const calls: string[] = [];
vi.stubGlobal(
'fetch',
vi.fn(async (input: unknown) => {
const url = String(
input && typeof input === 'object' && 'url' in input ? (input as { url: unknown }).url : input,
);
calls.push(url);
return { ok: true, json: async () => ({}) };
}),
);
return calls;
}

let metaCalls: string[] = [];

beforeEach(() => {
metaCalls = installMetaFetchDouble();
});

afterEach(() => {
expect(metaCalls.filter((u) => u !== '/api/v1/meta/object/deal')).toEqual([]);
vi.unstubAllGlobals();
cleanup();
lastSchema = null;
});

const quarterStart = (d: Date) => new Date(d.getFullYear(), Math.floor(d.getMonth() / 3) * 3, 1);
const iso = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const CURRENT_FROM = iso(quarterStart(new Date()));

/** 120 for the current window, 100 for any other window asked for. */
const makeSource = () => ({
aggregate: vi.fn(async (_object: string, q: any) => [
{ stage: 'won', amount: String(q?.filter?.close_date?.$gte) === CURRENT_FROM ? 120 : 100 },
]),
});

const renderChart = (chartType: string, dataSource: unknown, series?: unknown) =>
render(
<ObjectChart
schema={{
objectName: 'deal',
chartType,
aggregate: { field: 'amount', function: 'sum', groupBy: 'stage' },
filter: { close_date: { $gte: '{current_quarter_start}', $lte: '{current_quarter_end}' } },
xAxisKey: 'stage',
compareTo: { kind: 'previousYear' },
...(series ? { series } : {}),
}}
dataSource={dataSource}
/>,
);

/** Every series the chart was handed whose key is a synthesised overlay. */
const overlaySeriesOf = (schema: any) =>
(schema?.series ?? []).filter((s: any) => String(s?.dataKey).endsWith(COMPARISON_SUFFIX));

describe('ObjectChart — a scatter ignores compareTo (#7402)', () => {
it('draws the authored primary series and synthesises NO comparison overlay', async () => {
const src = makeSource();
renderChart('scatter', src, [{ dataKey: 'amount' }]);

await waitFor(() => expect(lastSchema).not.toBeNull());
// The comparison window is never even fetched — the same short-circuit
// pie / donut / funnel take.
expect(src.aggregate).toHaveBeenCalledTimes(1);

// Primary: present, intact, carrying its data.
expect(lastSchema.chartType).toBe('scatter');
expect(lastSchema.series).toHaveLength(1);
expect(lastSchema.series[0]).toMatchObject({ dataKey: 'amount' });
expect(lastSchema.data[0]).toMatchObject({ stage: 'won', amount: 120 });

// Overlay: positively absent — no series, and no column for one to read.
expect(overlaySeriesOf(lastSchema)).toEqual([]);
expect(lastSchema.data[0]).not.toHaveProperty(`amount${COMPARISON_SUFFIX}`);
});

it('adds no overlay when the author wrote no `series` at all', async () => {
// The authored spec for a compare-to scatter has ONE measure and no
// `series` key; the overlay was the only thing that ever put a second
// entry there. So the pin is that `series` stays UNsynthesised.
const src = makeSource();
renderChart('scatter', src);

await waitFor(() => expect(lastSchema).not.toBeNull());
expect(src.aggregate).toHaveBeenCalledTimes(1);
expect(overlaySeriesOf(lastSchema)).toEqual([]);
expect(lastSchema.data[0]).toMatchObject({ stage: 'won', amount: 120 });
expect(lastSchema.data[0]).not.toHaveProperty(`amount${COMPARISON_SUFFIX}`);
});

it('CONTROL: a bar with the same compareTo still synthesises the overlay', async () => {
// Without this, a regression that switched `compareTo` off for every chart
// type would pass both assertions above.
const src = makeSource();
renderChart('bar', src);

await waitFor(() => expect(src.aggregate).toHaveBeenCalledTimes(2));
await waitFor(() => expect(lastSchema?.series?.length).toBe(2));
expect(lastSchema.series[0]).toMatchObject({ dataKey: 'amount', variant: 'current' });
expect(overlaySeriesOf(lastSchema)).toHaveLength(1);
expect(overlaySeriesOf(lastSchema)[0]).toMatchObject({
dataKey: `amount${COMPARISON_SUFFIX}`,
variant: 'comparison',
});
expect(lastSchema.data[0]).toMatchObject({ amount: 120, [`amount${COMPARISON_SUFFIX}`]: 100 });
});
});
10 changes: 7 additions & 3 deletions packages/plugin-dashboard/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,9 +21,9 @@ executor re-runs the same selection over the shifted window and attaches a

- For **metric** & **gauge** widgets, a delta percentage surfaced as a `trend`
indicator (overrides any static `trend` prop).
- For **chart** widgets (line / area / bar / horizontal-bar / scatter / combo),
a muted second series (dashed line, lower fill opacity). Pie, donut, and
funnel charts ignore `compareTo`.
- For **chart** widgets (line / area / bar / horizontal-bar / combo), a muted
second series (dashed line, lower fill opacity). Pie, donut, funnel and
scatter charts ignore `compareTo`.
- For **table** widgets, a comparison column beside each compared measure.
- For a **pivot** cross-tab (`type: 'pivot'` with ≥2 dimensions), the comparison
is stacked **inside** the cell — current value on top, comparison value and
Expand DownExpand Up@@ -159,6 +159,10 @@ running over a window nobody asked for.
or omit `compareTo` entirely.
- Pie / donut / funnel charts — comparison overlays are not visually
meaningful and are silently ignored.
- Scatter charts, and the `bubble` widget type that renders as one — a scatter
binds ONE measure to its y axis, so an overlay could only be drawn on the
primary's own y. `compareTo` is ignored until scatter can project a second
measure (objectui#7194 option A); ruled in objectui#7402.

## Related

Expand Down
17 changes: 16 additions & 1 deletion packages/plugin-dashboard/src/DatasetWidget.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1401,8 +1401,23 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource:
// those series are dimension VALUES, not measures, so there is no per-measure
// series a comparison could pair with (the `__compare` columns are still in
// the rows — nothing is lost, it just isn't drawn as an overlay).
//
// Skipped, too, for a chart family that IGNORES `compareTo` — today just
// `scatter`, which both the `scatter` and `bubble` widget types map to
// (CHART_TYPE_MAP above). A scatter binds ONE measure to its y axis, so the
// overlay was drawn through the PRIMARY's `YAxis dataKey` and painted
// "previous period" exactly on top of "current" (objectui#7402). It returns
// with the multi-measure projection declined as option A of objectui#7194.
// The sibling declaration for the inline chart path is `supportsCompareTo`
// in `@object-ui/plugin-charts`' ObjectChart; this is a second, deliberately
// narrow copy because plugin-charts is a devDependency here, not a runtime
// one. ⚠️ That list also excludes pie / donut / funnel and this one does
// not — a divergence older than this line, filed as objectui#7495 (the
// renderer drops the extra series for those families, so nothing is
// mis-drawn; the comparison query still runs).
const chartIgnoresCompareTo = chartType === 'scatter';
const pivotedSeries = dimensions.length >= 2 && values.length === 1;
const comparisonSeries = pivotedSeries
const comparisonSeries = pivotedSeries || chartIgnoresCompareTo
? []
: comparedValues.map((m) => {
// An overlay is the SAME measure one period back, so it takes its
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7402 — the DASHBOARD half of "a scatter ignores `compareTo`".
*
* `CHART_TYPE_MAP` maps BOTH widget types `scatter` and `bubble` onto
* `chartType: 'scatter'`, and this path had no chart-family exclusion at all:
* a compare-to scatter widget got a `revenue__compare` series appended, which
* the renderer then drew through the primary's single y axis — "previous
* period" painted exactly on top of "current".
*
* Both widget-type spellings are pinned, because an exclusion written against
* the widget type instead of the chart type would cover one and miss the other.
*
* The assertions are POSITIVE about the primary (it is still there, with its
* numbers) and positive about the absence of the overlay (no series keyed
* `<measure>__compare`) — "nothing refused" would also be true of a widget
* that rendered nothing.
*
* The `bar` control at the bottom is mandatory: without it, a regression that
* suppressed every comparison series would pass every assertion above.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';

let lastChartSchema: any = null;

vi.mock('@object-ui/react', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
SchemaRenderer: (props: any) => {
lastChartSchema = props.schema;
return null;
},
}));

import { DatasetWidget } from '../DatasetWidget';

afterEach(() => {
cleanup();
lastChartSchema = null;
});

const Q2 = { close_date: { $gte: '2026-04-01', $lte: '2026-06-30' } };

/** The executor answers with the `__compare` columns already attached. */
const makeSource = () => ({
queryDataset: vi.fn(async () => ({
rows: [
{ stage: 'won', revenue: 120, revenue__compare: 100 },
{ stage: 'lost', revenue: 20, revenue__compare: 40 },
],
fields: [{ name: 'revenue', type: 'number', label: 'Revenue' }],
})),
});

const renderWidget = (type: string, dataSource: unknown) =>
render(
<DatasetWidget
widget={{
type, dataset: 'sales', dimensions: ['stage'], values: ['revenue'],
filter: { ...Q2 }, compareTo: { kind: 'previousYear' },
}}
dataSource={dataSource}
/>,
);

/** Every series the chart was handed that reads a comparison column. */
const overlaySeriesOf = (schema: any) =>
(schema?.series ?? []).filter((s: any) => String(s?.dataKey).endsWith('__compare'));

describe.each(['scatter', 'bubble'])('DatasetWidget — widget type %s ignores compareTo (#7402)', (type) => {
it('charts the primary measure and appends NO comparison series', async () => {
const src = makeSource();
renderWidget(type, src);

await waitFor(() => expect(lastChartSchema).not.toBeNull());
// Both spellings reach the renderer as the SAME chart family — which is
// why one chart-type exclusion covers both.
expect(lastChartSchema.chartType).toBe('scatter');

// Primary: drawn, with its own numbers, untouched by the overlay's absence.
expect(lastChartSchema.series).toHaveLength(1);
expect(lastChartSchema.series[0]).toMatchObject({ dataKey: 'revenue' });
expect(lastChartSchema.series[0].variant).toBeUndefined();
expect(lastChartSchema.data[0]).toMatchObject({ stage: 'won', revenue: 120 });

// Overlay: positively absent, even though the executor DID return the
// `revenue__compare` column — the widget declines to draw it.
expect(overlaySeriesOf(lastChartSchema)).toEqual([]);
});
});

describe('DatasetWidget — the compareTo overlay control (#7402)', () => {
it('CONTROL: a bar widget with the same compareTo still gets its overlay', async () => {
const src = makeSource();
renderWidget('bar', src);

await waitFor(() => expect(lastChartSchema).not.toBeNull());
expect(lastChartSchema.chartType).toBe('bar');
expect(lastChartSchema.series).toHaveLength(2);
expect(lastChartSchema.series[0]).toMatchObject({ dataKey: 'revenue', variant: 'current' });
expect(overlaySeriesOf(lastChartSchema)).toHaveLength(1);
expect(overlaySeriesOf(lastChartSchema)[0]).toMatchObject({
dataKey: 'revenue__compare',
variant: 'comparison',
});
});
});
Loading