Skip to content
Merged
12 changes: 12 additions & 0 deletions .changeset/25970-compare-to-all-time.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion scripts/sdui-manifest.sources/plugin-dashboard.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"package":"ui/packages/plugin-dashboard","sources":33,"sha256":"c8aaf01c370229a85b2dedb0a4927d9edca345d3f029c35e6d732ca637b6dc08","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
{"package":"ui/packages/plugin-dashboard","sources":33,"sha256":"20c947f73887cf0051d66da0c9daca4045ead2401c7adf47a0b15cad9c51efc7","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
6 changes: 5 additions & 1 deletion ui/e2e/dashboard-metric-tile-height.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
10 changes: 7 additions & 3 deletions ui/packages/plugin-dashboard/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 58 additions & 2 deletions ui/packages/plugin-dashboard/src/DatasetWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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<string, unknown>).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']);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof makeQueryDataset>, 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(<DashboardRenderer schema={schema} dataSource={{ queryDataset }} />);

// 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(<DashboardRenderer schema={schema} dataSource={{ queryDataset }} />);
expect(await screen.findByRole('alert')).toHaveTextContent(/dated window to shift/);
expect(lastSelection(queryDataset, 'sales_ytd').compareTo).toEqual({ kind: 'previousPeriod' });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
);

Expand All @@ -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,
);

Expand Down
Loading
Loading