diff --git a/.changeset/25970-compare-to-all-time.md b/.changeset/25970-compare-to-all-time.md new file mode 100644 index 00000000000..11260361f37 --- /dev/null +++ b/.changeset/25970-compare-to-all-time.md @@ -0,0 +1,12 @@ +--- +'@object-ui/plugin-dashboard': patch +--- + +fix(plugin-dashboard): a `compareTo` KPI tile shows its number when the reader picks All time, instead of the executor's "compareTo needs a dated window to shift" error + +Clause-②: no +Metadata-protocol: none + +- **Before.** A dataset-bound widget with `compareTo` always sent `compareTo` to the dataset executor, but lowered a `timeDimensions` window only when its resolved filter held a closed `{ $gte, $lte }` date pair. Picking **All time** on the dashboard's date-range filter removes that pair, so the executor refused the query and the tile showed a 400 error where its number belonged. The same tile without `compareTo` showed its number. +- **Now.** When the resolved filter carries no date condition at all on the compared field (on any field when `compareTo.dimension` is omitted), the widget asks for no comparison: the query is the one a widget without `compareTo` sends, and the tile shows its number with no comparison badge. +- **Unchanged: an author-written window that is not closed stays an error.** A one-bound window (`{ $gte: '{current_year_start}' }`, a YTD tile), an exclusive bound or a single day is still sent with `compareTo`, and the executor still refuses it and names the fix. The executor and the spec define a comparison only against a two-bound window. Two dated fields with no `compareTo.dimension` stay the executor's ambiguity error. diff --git a/scripts/sdui-manifest.sources/plugin-dashboard.json b/scripts/sdui-manifest.sources/plugin-dashboard.json index 8d006a093fa..5371ccddf7a 100644 --- a/scripts/sdui-manifest.sources/plugin-dashboard.json +++ b/scripts/sdui-manifest.sources/plugin-dashboard.json @@ -1 +1 @@ -{"package":"ui/packages/plugin-dashboard","sources":33,"sha256":"c8aaf01c370229a85b2dedb0a4927d9edca345d3f029c35e6d732ca637b6dc08","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/plugin-dashboard","sources":33,"sha256":"20c947f73887cf0051d66da0c9daca4045ead2401c7adf47a0b15cad9c51efc7","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/ui/e2e/dashboard-metric-tile-height.spec.ts b/ui/e2e/dashboard-metric-tile-height.spec.ts index 40d5e8fa2c7..7280815a14b 100644 --- a/ui/e2e/dashboard-metric-tile-height.spec.ts +++ b/ui/e2e/dashboard-metric-tile-height.spec.ts @@ -139,7 +139,11 @@ const metric = (id, title, layout, extra) => ({ id, type: 'metric', title, dataset: 'opportunity_metrics', values: ['total_amount'], layout, ...extra, }); const BARE = (layout) => metric('bare', 'Total Pipeline', layout); -const DELTA = (layout) => metric('delta', 'Pipeline vs prior', layout, { compareTo: { kind: 'previousPeriod' } }); +// A comparison is asked only over a dated window, so the delta tile states one. +const DELTA = (layout) => metric('delta', 'Pipeline vs prior', layout, { + filter: { close_date: { $gte: '2026-04-01', $lte: '2026-06-30' } }, + compareTo: { kind: 'previousPeriod' }, +}); // Each case is its own dashboard (see the header). 12 columns, a quarter-width tile. const CASES = [ diff --git a/ui/packages/plugin-dashboard/SKILL.md b/ui/packages/plugin-dashboard/SKILL.md index dced1324057..09d8f64049f 100644 --- a/ui/packages/plugin-dashboard/SKILL.md +++ b/ui/packages/plugin-dashboard/SKILL.md @@ -162,10 +162,14 @@ running over a window nobody asked for. ### When NOT to use `compareTo` -- Filters with no bounded date range — there is no window to shift, and the - widget reports that instead of rendering a comparison. Date the widget (a +- Filters whose date condition is not a closed `$gte`/`$lte` window — a + one-bound YTD `{ $gte: '{current_year_start}' }`, an exclusive `$lt` bound, + a single day. There is no window to shift, and the widget reports that + instead of rendering a comparison. Date the widget with a closed pair (a date-macro `$gte`/`$lte` pair, or a dashboard date-range filter bound to it) - or omit `compareTo` entirely. + or omit `compareTo` entirely. A filter that dates **nothing** on the compared + field — the reader picked All time on the dashboard date filter — is not an + error: the widget asks for no comparison and shows its number with no badge. - 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 diff --git a/ui/packages/plugin-dashboard/src/DatasetWidget.tsx b/ui/packages/plugin-dashboard/src/DatasetWidget.tsx index 6fcccad0093..f5ce131ca4e 100644 --- a/ui/packages/plugin-dashboard/src/DatasetWidget.tsx +++ b/ui/packages/plugin-dashboard/src/DatasetWidget.tsx @@ -323,7 +323,9 @@ function boundedDateWindow(value: unknown): [string, string] | null { * lowered under the name the author wrote. Zero of them, or two, is the * executor's error to raise — it names the candidates (`resolveCompareDimension`). * Guessing one renderer-side would trade a loud error for a quietly wrong - * window, which is the failure class objectstack#5011 set out to end. + * window, which is the failure class objectstack#5011 set out to end. The one + * zero-window filter that never reaches the executor is a filter that dates + * nothing at all — see `filterDatesField`, which the widget asks first. */ export function extractDateWindows(filter: unknown): { windows: DatasetTimeWindow[]; @@ -390,6 +392,46 @@ export function extractDateWindows(filter: unknown): { }; } +/** + * Does a RESOLVED runtime filter state any date condition on `dimension` — or, + * with no dimension named, on any field at all? (objectstack#25970) + * + * A comparison needs a window, and a filter can lack one for two different + * reasons. The AUTHOR wrote a date condition that is not a closed window (a + * one-bound YTD `{ $gte }`, an exclusive bound, a single day): that is a + * metadata mistake, and it stays the executor's loud refusal — the executor + * and the spec define a comparison only against a two-bound window. Or the + * filter dates nothing at all, which is what a READER produces by picking All + * time on the dashboard's date-range filter: `DashboardRenderer` then merges no + * condition for it, and the author cannot prevent the choice. With no window + * there is no earlier period to compare against, so that widget asks for no + * comparison and shows its number, instead of a normal filter choice breaking + * the tile. + * + * "A date condition" is any ISO-date operand under the field, at any depth and + * in any position (`$or` / `$not` included) — deliberately wider than the + * windows `extractDateWindows` lowers, so every author-written date shape still + * reaches the executor and is told why it cannot be compared. + */ +export function filterDatesField(filter: unknown, dimension?: string): boolean { + const datesValue = (value: unknown): boolean => { + if (typeof value === 'string') return ISO_DATE_RE.test(value); + if (Array.isArray(value)) return value.some(datesValue); + if (value && typeof value === 'object') return Object.values(value as object).some(datesValue); + return false; + }; + const walk = (node: unknown): boolean => { + if (Array.isArray(node)) return node.some(walk); + if (!node || typeof node !== 'object') return false; + return Object.entries(node as Record).some(([key, value]) => + key.startsWith('$') + ? walk(value) + : (dimension == null || key === dimension) && datesValue(value), + ); + }; + return walk(filter); +} + /** Single-value KPI widget types — rendered as a number, not a chart. */ const METRIC_TYPES = new Set(['metric', 'kpi', 'gauge', 'solid-gauge', 'bullet']); @@ -694,7 +736,11 @@ export function DatasetWidget({ widget, dataSource, heading }: { widget: any; da // overlay the renderer then dropped. Gated on the CHART branch, not on the // widget type: a pie widget with no dimensions renders as a metric tile // (`isMetric`), which does show the comparison as a delta, so it keeps it. - const compareTo: CompareToConfig | undefined = + // + // This is the comparison the widget DECLARES for what it renders. Whether it + // is asked for also depends on the resolved filter (a reader's All time dates + // nothing), so the query and the overlay read `compareTo`, settled below. + const declaredCompareTo: CompareToConfig | undefined = !isMetric && !isTable && chartTypeIgnoresCompareTo(chartType) ? undefined : widget?.compareTo; // pivot with ≥2 dims → a true cross-tab: last dim spreads across as columns, // the rest go down as rows. Computed up-front so the fetch can also request @@ -805,6 +851,16 @@ export function DatasetWidget({ widget, dataSource, heading }: { widget: any; da // widget's query byte-identical keeps this change to the path it is about. // See `extractDateWindows` for why the windows are MOVED and why the choice // of dimension is left to the executor. + // + // A filter that dates nothing on the compared field — the dashboard's date + // filter set to All time, with no date in the widget's own filter — asks for + // no comparison at all: no `compareTo`, no window lowered, the same query and + // tile as a widget without `compareTo` (objectstack#25970). Any date + // condition the author wrote, a one-bound `{ $gte }` included, still goes to + // the executor, whose refusal names the fix (`filterDatesField`). + const compareTo = declaredCompareTo && filterDatesField(runtimeFilter, declaredCompareTo.dimension) + ? declaredCompareTo + : undefined; const { windows: compareWindows, rest: selectionFilter } = useMemo( () => (compareTo ? extractDateWindows(runtimeFilter) diff --git a/ui/packages/plugin-dashboard/src/__tests__/DashboardRenderer.compareToAllTime-25970.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/DashboardRenderer.compareToAllTime-25970.test.tsx new file mode 100644 index 00000000000..6cf52766e83 --- /dev/null +++ b/ui/packages/plugin-dashboard/src/__tests__/DashboardRenderer.compareToAllTime-25970.test.tsx @@ -0,0 +1,96 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#25970 — a `compareTo` KPI tile on a dashboard whose reader picks + * All time in the date filter shows its number, with no comparison badge and + * no error. + * + * All time is a supported filter choice the author cannot prevent: the filter + * bar stores no value, `DashboardRenderer` merges no condition, and the + * widget's resolved filter dates nothing. The tile used to send `compareTo` + * anyway and render the executor's "compareTo needs a dated window to shift" + * refusal in place of its number. A window the AUTHOR wrote as one bound (YTD) + * keeps that refusal: the executor and the spec define a comparison only + * against a two-bound window. + * + * Drives the real Radix select with user-event; the data source answers the + * way the executor does (`resolveCompareDimension`). + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { DashboardComponentSchema } from '@object-ui/types'; +import { DashboardRenderer } from '../DashboardRenderer'; + +afterEach(cleanup); + +const REFUSAL = + 'Dataset query failed: 400 Bad Request — [dataset-executor] compareTo needs a dated window to shift'; + +const makeQueryDataset = () => + vi.fn(async (_dataset: string, selection: any) => { + const shiftable = (selection.timeDimensions ?? []).some((t: any) => t.dateRange); + if (selection.compareTo && !shiftable) throw new Error(REFUSAL); + return { + rows: [selection.compareTo ? { revenue: 120, revenue__compare: 100 } : { revenue: 120 }], + fields: [], + }; + }); + +/** The selection of the LAST `queryDataset` call for a given dataset. */ +const lastSelection = (queryDataset: ReturnType, dataset: string): any => { + const calls = queryDataset.mock.calls.filter((c) => c[0] === dataset); + return calls.length ? calls[calls.length - 1][1] : undefined; +}; + +const schema: DashboardComponentSchema = { + type: 'dashboard', + dateRange: { field: 'close_date', defaultRange: 'this_quarter' }, + widgets: [ + // Dated by the dashboard filter alone — the "Selected Period" tile. + { + id: 'selected', type: 'metric', title: 'Selected period', dataset: 'sales', values: ['revenue'], + compareTo: { kind: 'previousPeriod' }, + }, + // Dated by its own one-bound window, opted out of the dashboard filter — the YTD tile. + { + id: 'ytd', type: 'metric', title: 'Year to date', dataset: 'sales_ytd', values: ['revenue'], + filter: { close_date: { $gte: '{current_year_start}' } }, + filterBindings: { dateRange: false }, + compareTo: { kind: 'previousPeriod' }, + }, + ], +}; + +describe('DashboardRenderer — a compareTo tile under All time (#25970)', () => { + it('compares under the default range, then shows the bare number under All time', async () => { + const user = userEvent.setup(); + const queryDataset = makeQueryDataset(); + render(); + + // Control: the default range is a closed window, so the comparison runs. + await waitFor(() => expect(screen.getAllByTestId('dataset-compare-trend')).toHaveLength(1)); + expect(lastSelection(queryDataset, 'sales').compareTo).toEqual({ kind: 'previousPeriod' }); + expect(lastSelection(queryDataset, 'sales').timeDimensions).toEqual([ + { dimension: 'close_date', dateRange: [expect.any(String), expect.any(String)] }, + ]); + + await user.click(within(screen.getByTestId('dashboard-filter-dateRange')).getByRole('combobox')); + await user.click(await screen.findByRole('option', { name: 'All time' })); + + await waitFor(() => expect(lastSelection(queryDataset, 'sales')).toEqual({ dimensions: [], measures: ['revenue'] })); + // The YTD tile never shows a number (see below), so this one is the selected-period tile. + expect(await screen.findByText('120')).toBeInTheDocument(); + expect(screen.queryByTestId('dataset-compare-trend')).not.toBeInTheDocument(); + // The one refusal left on the page is the YTD tile's. + expect(screen.getAllByRole('alert')).toHaveLength(1); + }); + + it('keeps the refusal for an author-written one-bound window', async () => { + const queryDataset = makeQueryDataset(); + render(); + expect(await screen.findByRole('alert')).toHaveTextContent(/dated window to shift/); + expect(lastSelection(queryDataset, 'sales_ytd').compareTo).toEqual({ kind: 'previousPeriod' }); + }); +}); diff --git a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.colorVariant.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.colorVariant.test.tsx index 926f153ab86..419c0d0ea45 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.colorVariant.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.colorVariant.test.tsx @@ -152,12 +152,14 @@ describe('DatasetWidget metric card — the declared colorVariant (#3359)', () = it('keeps the comparison trend row untouched by the accent', async () => { const rows = [{ revenue: 510000, revenue__compare: 400000 }]; - const plain = await renderMetric({ compareTo: { kind: 'previousPeriod' } }, rows); + // A comparison is asked only over a dated window (objectstack#25970). + const filter = { close_date: { $gte: '2026-04-01', $lte: '2026-06-30' } }; + const plain = await renderMetric({ filter, compareTo: { kind: 'previousPeriod' } }, rows); const plainHtml = plain.innerHTML; cleanup(); // The delta row renders (that is what makes this case worth pinning) … expect(plainHtml).toContain('data-testid="dataset-compare-trend"'); - const tinted = await renderMetric({ compareTo: { kind: 'previousPeriod' }, colorVariant: 'danger' }, rows); + const tinted = await renderMetric({ filter, compareTo: { kind: 'previousPeriod' }, colorVariant: 'danger' }, rows); // … and the tinted render differs from it in exactly one place: the value's // class attribute. The trend's own up/down colouring is data-driven and must // not inherit the widget's accent. diff --git a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.comboPresentation.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.comboPresentation.test.tsx index fbad0434f43..af44a4fa9f7 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.comboPresentation.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.comboPresentation.test.tsx @@ -213,7 +213,12 @@ describe('DatasetWidget — a comparison overlay carries no merged mark (objectu fields, })), }; - await renderWidget(comboWidget({ compareTo: { kind: 'previousYear' }, chartConfig: retiredComboChartConfig }), src); + await renderWidget(comboWidget({ + // A comparison is asked only over a dated window (objectstack#25970). + filter: { due_date: { $gte: '2026-01-01', $lte: '2026-12-31' } }, + compareTo: { kind: 'previousYear' }, + chartConfig: retiredComboChartConfig, + }), src); const series = lastChartSchema.series; expect(series.map((s: any) => [s.dataKey, s.variant])).toEqual([ diff --git a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.compareTo.ignoredFamilies-7495.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.compareTo.ignoredFamilies-7495.test.tsx index 650a8d40be7..31462f18c70 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.compareTo.ignoredFamilies-7495.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.compareTo.ignoredFamilies-7495.test.tsx @@ -168,7 +168,13 @@ describe('DatasetWidget — no dated window: an ignoring family draws its chart * The dataset executor refuses a `compareTo` it has no dated window to shift * (a dataset-invalid error). A widget that forwarded `compareTo` showed that * refusal in place of its chart; one that forwards nothing is never refused. + * + * The filter dates its field with ONE bound, so it is a date condition that is + * no closed window: a filter that dates nothing at all is asked no comparison + * by every family (objectstack#25970), which would leave the control below + * nothing to refuse. */ + const ONE_BOUND = { owner: 'u1', close_date: { $gte: '2026-01-01' } }; const refusingSource = () => ({ queryDataset: vi.fn(async (_dataset: string, selection: any) => { if (selection?.compareTo && !selection?.timeDimensions?.length) { @@ -181,10 +187,10 @@ describe('DatasetWidget — no dated window: an ignoring family draws its chart }), }); - it('a pie with compareTo and an undated filter renders the pie', async () => { + it('a pie with compareTo and no closed window renders the pie', async () => { const src = refusingSource(); const { queryByRole } = renderWidget( - { type: 'pie', dataset: 'sales', dimensions: ['stage'], values: ['revenue'], filter: { owner: 'u1' }, compareTo: { kind: 'previousYear' } }, + { type: 'pie', dataset: 'sales', dimensions: ['stage'], values: ['revenue'], filter: ONE_BOUND, compareTo: { kind: 'previousYear' } }, src, ); @@ -196,7 +202,7 @@ describe('DatasetWidget — no dated window: an ignoring family draws its chart it('CONTROL: a bar with the same compareTo and filter still asks, and shows the refusal', async () => { const src = refusingSource(); const { findByRole } = renderWidget( - { type: 'bar', dataset: 'sales', dimensions: ['stage'], values: ['revenue'], filter: { owner: 'u1' }, compareTo: { kind: 'previousYear' } }, + { type: 'bar', dataset: 'sales', dimensions: ['stage'], values: ['revenue'], filter: ONE_BOUND, compareTo: { kind: 'previousYear' } }, src, ); diff --git a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.compareTo.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.compareTo.test.tsx index 3f2fa9ef2f1..8bdc99038ce 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.compareTo.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.compareTo.test.tsx @@ -29,7 +29,7 @@ vi.mock('@object-ui/react', async (importOriginal) => ({ }, })); -import { DatasetWidget, extractDateWindows } from '../DatasetWidget'; +import { DatasetWidget, extractDateWindows, filterDatesField } from '../DatasetWidget'; afterEach(() => { cleanup(); @@ -204,6 +204,112 @@ describe('DatasetWidget — lowering the comparison window (#3337 point 5)', () }); }); +describe('filterDatesField', () => { + it('finds a date condition at the top level, under $and, $or and $not', () => { + expect(filterDatesField(Q2)).toBe(true); + expect(filterDatesField({ $and: [{ stage: 'won' }, { close_date: { $gte: '2026-01-01' } }] })).toBe(true); + expect(filterDatesField({ $or: [{ stage: 'won' }, { close_date: '2026-04-01' }] })).toBe(true); + expect(filterDatesField({ close_date: { $not: { $lt: '2026-04-01T00:00:00Z' } } })).toBe(true); + }); + + it('finds nothing in a filter that dates no field', () => { + expect(filterDatesField(undefined)).toBe(false); + expect(filterDatesField({ stage: 'won', amount: { $gte: 1000, $lte: 5000 } })).toBe(false); + expect(filterDatesField({ stage: { $in: ['won', 'lost'] } })).toBe(false); + }); + + it('with a dimension named, reads that field alone', () => { + const filter = { created_at: { $gte: '2026-01-01', $lte: '2026-12-31' } }; + expect(filterDatesField(filter, 'created_at')).toBe(true); + expect(filterDatesField(filter, 'close_date')).toBe(false); + }); +}); + +// objectstack#25970 — the reader picked All time on the dashboard date filter, +// so the widget's resolved filter dates nothing: there is no window to compare +// against, and the tile shows its number instead of the executor's refusal. +describe('DatasetWidget — a filter that dates nothing asks for no comparison (#25970)', () => { + // Answers the way the executor does: `compareTo` with no shiftable window is + // refused (`resolveCompareDimension`), anything else answers a row. + const executorLike = () => makeSource(async (_d, s) => { + if (s.compareTo && !(s.timeDimensions ?? []).some((t: any) => t.dateRange)) { + throw new Error( + 'Dataset query failed: 400 Bad Request — [dataset-executor] compareTo needs a dated window to shift', + ); + } + return { rows: [{ revenue: 120 }] }; + }); + + it('sends no compareTo for a widget with no filter and renders the number with no badge', async () => { + const src = executorLike(); + render( + , + ); + await waitFor(() => expect(src.queryDataset).toHaveBeenCalled()); + // The same query a widget without `compareTo` sends. + expect(selectionOf(src)).toEqual({ dimensions: [], measures: ['revenue'] }); + expect(await screen.findByText('120')).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.queryByTestId('dataset-compare-trend')).not.toBeInTheDocument(); + }); + + it('keeps a non-date filter as it is and sends no compareTo', async () => { + const src = executorLike(); + render( + , + ); + await waitFor(() => expect(src.queryDataset).toHaveBeenCalled()); + expect(selectionOf(src)).toEqual({ dimensions: [], measures: ['revenue'], runtimeFilter: { stage: 'won' } }); + expect(await screen.findByText('120')).toBeInTheDocument(); + }); + + it('sends no compareTo when the named dimension is the one left undated', async () => { + const src = executorLike(); + const filter = { created_at: { $gte: '2026-01-01', $lte: '2026-12-31' } }; + render( + , + ); + await waitFor(() => expect(src.queryDataset).toHaveBeenCalled()); + // Nothing lowered either: the window stays in the filter, as without compareTo. + expect(selectionOf(src)).toEqual({ dimensions: [], measures: ['revenue'], runtimeFilter: filter }); + }); + + // The ruling: an author-declared one-bound window (YTD) STAYS an error. The + // executor and the spec define a comparison only against a two-bound window, + // and this one was written by the author, not removed by the reader. + it('still sends compareTo for a one-bound `$gte` window, and shows the executor’s refusal', async () => { + const src = executorLike(); + render( + , + ); + await waitFor(() => expect(src.queryDataset).toHaveBeenCalled()); + expect(selectionOf(src).compareTo).toEqual({ kind: 'previousPeriod' }); + expect(selectionOf(src).timeDimensions).toBeUndefined(); + expect(await screen.findByRole('alert')).toHaveTextContent(/compareTo needs a dated window to shift/); + }); +}); + describe('DatasetWidget — showing the comparison the executor returned', () => { it('renders the KPI delta + window label from the __compare column', async () => { const src = makeSource(async () => ({ diff --git a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.dimensionlessMeasures-11261.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.dimensionlessMeasures-11261.test.tsx index 1038beae8d1..f54febf6e73 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.dimensionlessMeasures-11261.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.dimensionlessMeasures-11261.test.tsx @@ -180,7 +180,11 @@ describe('objectui#11261 — a dimensionless chart renders one mark per measure' // A dimensionless bar used to be a tile that showed its comparison as a // delta. As a chart it keeps asking for one, so the overlay has to draw. const container = await renderChart( - { id: 'rc', type: 'bar', dataset: 'sales', values: ['revenue', 'cost'], compareTo: { kind: 'previousPeriod' } }, + { + id: 'rc', type: 'bar', dataset: 'sales', values: ['revenue', 'cost'], + // A comparison is asked only over a dated window (objectstack#25970). + filter: { close_date: { $gte: '2026-04-01', $lte: '2026-06-30' } }, compareTo: { kind: 'previousPeriod' }, + }, [{ revenue: 510000, cost: 120000, revenue__compare: 400000, cost__compare: 100000 }], ); await waitFor(() => expect(barMarks(container)).toBe(4)); diff --git a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx index f5daa69aafd..0cb2f5c3884 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx @@ -197,17 +197,21 @@ describe('DatasetWidget', () => { // spelling is now invalid METADATA, rejected where it is authored rather than // laundered here. it('forwards compareTo unchanged — the executor’s own { kind, dimension? } contract', async () => { + // A comparison is asked only over a dated window (objectstack#25970); the + // window moves into `timeDimensions` (see DatasetWidget.compareTo.test.tsx). + const filter = { close_date: { $gte: '2026-04-01', $lte: '2026-06-30' } }; + const lowered = { timeDimensions: [{ dimension: 'close_date', dateRange: ['2026-04-01', '2026-06-30'] }] }; const withDimension = { kind: 'previousPeriod', dimension: 'close_date' }; const src1 = makeSource(async () => ({ rows: [{ revenue: 1 }] })); - render(); - await waitFor(() => expect(src1.queryDataset).toHaveBeenCalledWith('sales', { dimensions: [], measures: ['revenue'], compareTo: withDimension })); + render(); + await waitFor(() => expect(src1.queryDataset).toHaveBeenCalledWith('sales', { dimensions: [], measures: ['revenue'], ...lowered, compareTo: withDimension })); // `dimension` omitted — the executor resolves it (or fails loudly naming the // candidates). The widget must NOT fill one in on its way past. const bare = { kind: 'previousYear' }; const src2 = makeSource(async () => ({ rows: [{ revenue: 1 }] })); - render(); - await waitFor(() => expect(src2.queryDataset).toHaveBeenCalledWith('sales', { dimensions: [], measures: ['revenue'], compareTo: bare })); + render(); + await waitFor(() => expect(src2.queryDataset).toHaveBeenCalledWith('sales', { dimensions: [], measures: ['revenue'], ...lowered, compareTo: bare })); expect(src2.queryDataset.mock.calls[0][1].compareTo).not.toHaveProperty('dimension'); });