diff --git a/.changeset/dashboard-date-filter-preset-default.md b/.changeset/dashboard-date-filter-preset-default.md new file mode 100644 index 000000000..bab98d04a --- /dev/null +++ b/.changeset/dashboard-date-filter-preset-default.md @@ -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. diff --git a/packages/core/src/utils/__tests__/dashboard-filters.test.ts b/packages/core/src/utils/__tests__/dashboard-filters.test.ts index 3d445eefe..4e0d5b609 100644 --- a/packages/core/src/utils/__tests__/dashboard-filters.test.ts +++ b/packages/core/src/utils/__tests__/dashboard-filters.test.ts @@ -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', () => { diff --git a/packages/core/src/utils/dashboard-filters.ts b/packages/core/src/utils/dashboard-filters.ts index 4d64faf5b..d779f6368 100644 --- a/packages/core/src/utils/dashboard-filters.ts +++ b/packages/core/src/utils/dashboard-filters.ts @@ -88,6 +88,36 @@ const PRESET_RANGES: Record = { /** 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 @@ -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, }); } diff --git a/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.dateDefault.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.dateDefault.test.tsx new file mode 100644 index 000000000..b255eab04 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.dateDefault.test.tsx @@ -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(); + + 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(); + + expect(screen.getByTestId('dashboard-filter-created_at').textContent).toMatch(/All time/i); + }); +});