diff --git a/.changeset/7402-scatter-ignores-compareto.md b/.changeset/7402-scatter-ignores-compareto.md new file mode 100644 index 0000000000..b4fe1206b2 --- /dev/null +++ b/.changeset/7402-scatter-ignores-compareto.md @@ -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 `__comparison` (inline chart) / `__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. diff --git a/packages/plugin-charts/src/ObjectChart.tsx b/packages/plugin-charts/src/ObjectChart.tsx index 6d01291796..92a0b536b5 100644 --- a/packages/plugin-charts/src/ObjectChart.tsx +++ b/packages/plugin-charts/src/ObjectChart.tsx @@ -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 `__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. diff --git a/packages/plugin-charts/src/__tests__/ObjectChart.compareTo.scatter.test.tsx b/packages/plugin-charts/src/__tests__/ObjectChart.compareTo.scatter.test.tsx new file mode 100644 index 0000000000..6256ec3660 --- /dev/null +++ b/packages/plugin-charts/src/__tests__/ObjectChart.compareTo.scatter.test.tsx @@ -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( + , + ); + +/** 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 }); + }); +}); diff --git a/packages/plugin-dashboard/SKILL.md b/packages/plugin-dashboard/SKILL.md index 0654c26375..31f8bd0bbc 100644 --- a/packages/plugin-dashboard/SKILL.md +++ b/packages/plugin-dashboard/SKILL.md @@ -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 @@ -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 diff --git a/packages/plugin-dashboard/src/DatasetWidget.tsx b/packages/plugin-dashboard/src/DatasetWidget.tsx index f8f61e7129..0189be1365 100644 --- a/packages/plugin-dashboard/src/DatasetWidget.tsx +++ b/packages/plugin-dashboard/src/DatasetWidget.tsx @@ -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 diff --git a/packages/plugin-dashboard/src/__tests__/DatasetWidget.compareTo.scatter.test.tsx b/packages/plugin-dashboard/src/__tests__/DatasetWidget.compareTo.scatter.test.tsx new file mode 100644 index 0000000000..f522d3b306 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/DatasetWidget.compareTo.scatter.test.tsx @@ -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 + * `__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>()), + 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( + , + ); + +/** 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', + }); + }); +});