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
56 changes: 56 additions & 0 deletions .changeset/dashboard-date-filter-preset-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
"@object-ui/core": minor
---

fix(dashboard): a date globalFilter's preset-name default becomes a range, not an equality

Setup → System Overview rendered EVERY KPI tile as 0 while its period selector
read "All time" (objectstack#4475). Every request was `200 OK`, the widgets
rendered normally, and nothing in the UI signalled a failure — zeros read as
"nothing has happened yet" rather than as an error, which is why this survived
to an RC.

Both symptoms are one missing normalization. `resolveDashboardFilterDefs` lifts
the built-in `dateRange` declaration's preset NAME to `{ preset }`, but passed a
`globalFilters` entry's `defaultValue` through raw. `@objectstack/spec`'s
`GlobalFilterSchema.defaultValue` is `string | number | boolean`, so a bare
preset name is the ONLY spelling an author can write — and nothing ever mapped
it. System Overview declares
`{ field: 'created_at', type: 'date', defaultValue: 'last_7_days' }`, so:

- `buildFilterCondition` fell through to its "a bare string date means equality
on that day" branch and the widget sent
`runtimeFilter: { created_at: 'last_7_days' }`. The backend compiled
`SELECT COUNT(*) AS "user_count" FROM "sys_user" WHERE created_at = $1`
— verified against a live server, byte-for-byte the SQL in the issue. The
actual `sys_user` count is 4; that equality matches no row.
- `DateRangeFilter` derives its selected item from `value.preset` / `.from` /
`.to`, all `undefined` on a bare string, so the control fell through to its
ALL sentinel and displayed "All time" while sending that equality. The tiles
therefore looked deliberately unfiltered and merely empty.

`normalizeDateDefault` now applies the same lift the sibling `dateRange`
declaration already receives, for `date`/`dateRange` filters whose default names
a preset this module actually knows. This is not consumer-side leniency: it is
one normalization function completing the same conversion for the sibling
declaration, and the spec admits no other spelling for an author to fix at the
producer. A genuine ISO date string still means equality on that day (the
documented behaviour), and numbers, booleans and unrecognised strings are left
exactly as declared.

No backend change is needed: given a real range the dataset path already lowers
it correctly (`WHERE (created_at >= $1 AND created_at < $2)` → 4). The
framework's dashboard metadata needs none either — it is spec-compliant as
written, and editing it would only hide the defect.

Levelled `minor` rather than `patch` because the change is visible in rendered
dashboards rather than internal: any dashboard declaring a date-typed
`globalFilters` default now emits a different query shape, its numbers change
(from 0 to real values), and its filter control's displayed label changes with
them. Anything asserting on the previously-emitted condition will see it move.

Known residual, filed separately rather than widened into here: a `date` filter
whose value is neither a known preset nor a parseable ISO date still degrades
silently to an equality that matches nothing, producing the same
healthy-looking zero. Preset names are covered by this change; a misspelled
custom value is not.
67 changes: 67 additions & 0 deletions packages/core/src/utils/__tests__/dashboard-filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,73 @@ describe('resolveDashboardFilterDefs', () => {
expect(defs).toHaveLength(1);
expect(defs[0].field).toBe('sales_region');
});

// ---------------------------------------------------------------------
// framework#4475 — a `date` globalFilter's preset-name default.
//
// Setup → System Overview rendered EVERY KPI tile as 0 while its period
// selector read "All time". The dashboard declares
// globalFilters: [{ field: 'created_at', type: 'date',
// defaultValue: 'last_7_days' }]
// and `GlobalFilterSchema.defaultValue` is `string | number | boolean`, so
// a bare preset name is the ONLY spelling an author can write — but only
// the built-in `dateRange` declaration was ever lifted to `{ preset }`.
// The raw string then flowed to `buildFilterCondition`, hit its
// "bare string date means equality" branch, and the backend compiled
// SELECT COUNT(*) … FROM "sys_user" WHERE created_at = $1
// (verified against a live server) — 200 OK, zero rows, no error anywhere.
// The same missing lift is why the control showed "All time": DateRangeFilter
// reads `.preset`/`.from`/`.to`, all undefined on a string.
// ---------------------------------------------------------------------
it('[#4475] lifts a date filter\'s preset-name default to { preset }', () => {
const defs = resolveDashboardFilterDefs({
globalFilters: [
{ field: 'created_at', type: 'date', label: 'Date Range', defaultValue: 'last_7_days' },
] as any,
});
expect(defs[0].defaultValue).toEqual({ preset: 'last_7_days' });
});

it('[#4475] the lifted default produces a RANGE, never an equality', () => {
// The end-to-end assertion: what the dashboard declares must reach the
// query as bounds. Pre-fix this was the literal string 'last_7_days'.
const [def] = resolveDashboardFilterDefs({
globalFilters: [
{ field: 'created_at', type: 'date', defaultValue: 'last_7_days' },
] as any,
});
expect(buildFilterCondition(def, def.defaultValue)).toEqual({
$gte: '{7_days_ago}',
$lte: '{today}',
});
// And the widget-scoped shape a dataset widget forwards as runtimeFilter.
expect(buildWidgetScopedFilter({ id: 'w' }, [def], { created_at: def.defaultValue })).toEqual({
created_at: { $gte: '{7_days_ago}', $lte: '{today}' },
});
});

it('[#4475] leaves a genuine ISO date default as an equality', () => {
// The documented bare-string behaviour is unchanged — only names this
// module actually knows as presets are lifted.
const defs = resolveDashboardFilterDefs({
globalFilters: [
{ field: 'created_at', type: 'date', defaultValue: '2026-01-15' },
] as any,
});
expect(defs[0].defaultValue).toBe('2026-01-15');
expect(buildFilterCondition(defs[0], defs[0].defaultValue)).toBe('2026-01-15');
});

it('[#4475] does not touch non-date filters that share a preset-like default', () => {
const defs = resolveDashboardFilterDefs({
globalFilters: [
{ field: 'bucket', type: 'select', defaultValue: 'last_7_days' },
{ field: 'score', type: 'number', defaultValue: 7 },
] as any,
});
expect(defs[0].defaultValue).toBe('last_7_days');
expect(defs[1].defaultValue).toBe(7);
});
});

describe('dashboardFilterVariableDefs', () => {
Expand Down
38 changes: 36 additions & 2 deletions packages/core/src/utils/dashboard-filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,36 @@ const PRESET_RANGES: Record<string, { from?: string; to?: string }> = {
/** Preset keys the filter bar offers, in display order. */
export const DATE_RANGE_PRESETS = Object.keys(PRESET_RANGES);

/**
* Normalize a date filter's DECLARED default into the `DateRangeValue` shape
* every date consumer in this module reads (framework#4475).
*
* The built-in `dateRange` declaration has always been normalized this way —
* `schema.dateRange.defaultRange` is a preset NAME and
* `resolveDashboardFilterDefs` lifts it to `{ preset }`. A `globalFilters`
* entry of `type: 'date'` was passed through raw instead, and that asymmetry
* is the whole bug: `@objectstack/spec`'s `GlobalFilterSchema.defaultValue` is
* `string | number | boolean`, so a bare preset name is the ONLY spelling an
* author can write, yet nothing ever mapped it. Both symptoms of Setup's
* System Overview reading 0 across every KPI tile follow from that:
*
* - `buildFilterCondition` fell through to its "a bare string date means
* equality on that day" branch and emitted `created_at = 'last_7_days'`,
* which matches no row — a query the backend answers `200 OK` with a 0;
* - `DateRangeFilter` reads `value.preset` / `.from` / `.to`, all `undefined`
* on a bare string, so the control displayed "All time" while sending that
* equality — the tiles looked deliberately unfiltered and merely empty.
*
* Only a name this module actually knows is lifted. A genuine ISO date string
* still means equality on that day (the documented behaviour), and a number /
* boolean / unrecognised string is left exactly as declared.
*/
function normalizeDateDefault(type: DashboardFilterDef['type'], defaultValue: unknown): unknown {
if (type !== 'date' && type !== 'dateRange') return defaultValue;
if (typeof defaultValue !== 'string') return defaultValue;
return defaultValue in PRESET_RANGES ? { preset: defaultValue } : defaultValue;
}

/**
* Normalize a filter's static `options` declaration to `{ value, label }`
* pairs. The @objectstack/spec `GlobalFilterSchema.options` form is
Expand Down Expand Up @@ -147,14 +177,18 @@ export function resolveDashboardFilterDefs(
if (byName.has(name) && typeof console !== 'undefined') {
console.warn(`[dashboard-filters] duplicate filter name "${name}" — the later definition wins`);
}
const type = f.type ?? 'text';
byName.set(name, {
name,
field: f.field,
label: f.label,
type: f.type ?? 'text',
type,
options: normalizeFilterOptions(f.options),
optionsFrom: f.optionsFrom,
defaultValue: f.defaultValue,
// framework#4475 — same preset-name lifting the built-in `dateRange`
// above already does; see normalizeDateDefault for why a bare string is
// the only thing an author can declare here.
defaultValue: normalizeDateDefault(type, f.defaultValue),
targetWidgets: f.targetWidgets,
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* framework#4475 — the date filter's control must AGREE with the query it
* drives.
*
* Setup → System Overview rendered every KPI tile as 0 while its period
* selector read "All time". Both halves were one missing normalization: a
* `globalFilters` entry of `type: 'date'` declares its default as a bare
* preset NAME (`GlobalFilterSchema.defaultValue` is `string | number |
* boolean`, so there is no object spelling to write), and only the built-in
* `dateRange` declaration was ever lifted to `{ preset }`.
*
* `DateRangeFilter` derives its selected item from `value.preset` / `.from` /
* `.to` — all `undefined` on a bare string — so the control fell through to
* its ALL sentinel and displayed "All time" while the same raw string went
* out as `created_at = 'last_7_days'`. The tiles therefore looked deliberately
* unfiltered and merely empty, which is why nothing in the UI signalled a
* failure. This pins the display half; the query half lives in
* `@object-ui/core`'s dashboard-filters tests.
*/

import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { DashboardFilterBar } from '../DashboardFilterBar';
import { resolveDashboardFilterDefs } from '@object-ui/core';

afterEach(cleanup);

/** The System Overview declaration verbatim. */
const SYSTEM_OVERVIEW_FILTERS = [
{ field: 'created_at', type: 'date', label: 'Date Range', scope: 'dashboard', defaultValue: 'last_7_days' },
] as any;

describe('DashboardFilterBar — date filter default (framework#4475)', () => {
it('shows the declared preset, not "All time"', () => {
const defs = resolveDashboardFilterDefs({ globalFilters: SYSTEM_OVERVIEW_FILTERS });
// The dashboard variables provider seeds values from the resolved defaults.
const values = Object.fromEntries(defs.map((d) => [d.name, d.defaultValue]));

render(<DashboardFilterBar defs={defs} values={values} onChange={vi.fn()} />);

const control = screen.getByTestId('dashboard-filter-created_at');
expect(control.textContent).not.toMatch(/All time/i);
expect(control.textContent).toMatch(/last 7 days/i);
});

it('still shows "All time" when the filter genuinely has no value', () => {
// Clearing the selector sets the value to `undefined` — that IS all-time,
// and it must keep reading that way.
const defs = resolveDashboardFilterDefs({ globalFilters: SYSTEM_OVERVIEW_FILTERS });

render(<DashboardFilterBar defs={defs} values={{ created_at: undefined }} onChange={vi.fn()} />);

expect(screen.getByTestId('dashboard-filter-created_at').textContent).toMatch(/All time/i);
});
});
Loading