diff --git a/.changeset/10339-daterange-spec-default.md b/.changeset/10339-daterange-spec-default.md new file mode 100644 index 0000000000..04ae8a08bb --- /dev/null +++ b/.changeset/10339-daterange-spec-default.md @@ -0,0 +1,15 @@ +--- +'@object-ui/core': minor +--- + +fix(core): a dashboard `dateRange` that omits `defaultRange` now takes the spec's declared default preset (objectui#10339). + +`@objectstack/spec` declares `DashboardSchema.dateRange.defaultRange` with `.default('this_month')`, but +`resolveDashboardFilterDefs` treated an omitted `defaultRange` as "no filter", so a dashboard authored as +`dateRange: { field: 'created_at' }` rendered unfiltered while the platform's parse of the same document said +`this_month`. The read site now applies the spec default, read from the spec's own `dateRange` schema rather +than a copied string. + +**Behaviour note:** an authored `dateRange` with no `defaultRange` now opens filtered to `this_month`, matching +the spec. An explicit `defaultRange` — `custom` included — is unchanged, and a dashboard with no `dateRange` at +all still gets no built-in date filter. diff --git a/content/docs/guide/dashboard-filters.md b/content/docs/guide/dashboard-filters.md index 6661e74a09..f8b0aa3a8f 100644 --- a/content/docs/guide/dashboard-filters.md +++ b/content/docs/guide/dashboard-filters.md @@ -100,7 +100,10 @@ appears in the filter bar above the widgets: - `defaultRange` — the initially selected preset: `today`, `yesterday`, `this_week`, `last_week`, `this_month`, `last_month`, `this_quarter`, `last_quarter`, `this_year`, `last_year`, `last_7_days`, `last_30_days`, - `last_90_days`, or `custom` (starts empty and lets the user pick). + `last_90_days`, or `custom` (starts empty and lets the user pick). When + omitted, the default `@objectstack/spec` declares for this key applies + (`this_month`), so a bare `dateRange: { "field": "created_at" }` opens + filtered to the current month, not unfiltered. - `allowCustomRange` — offer a "Custom…" item that opens a from/to calendar (default `true`). diff --git a/packages/core/src/utils/__tests__/dashboard-filters.test.ts b/packages/core/src/utils/__tests__/dashboard-filters.test.ts index 120d9c46ed..5cf2dc0cc7 100644 --- a/packages/core/src/utils/__tests__/dashboard-filters.test.ts +++ b/packages/core/src/utils/__tests__/dashboard-filters.test.ts @@ -18,6 +18,7 @@ import { type DashboardFilterDef, } from '../dashboard-filters'; import { mergeFilters } from '../merge-filters'; +import { DashboardSchema as SpecDashboardSchema } from '@objectstack/spec/ui'; const regionDef: DashboardFilterDef = { name: 'region', @@ -89,6 +90,43 @@ describe('resolveDashboardFilterDefs', () => { expect(defs[0].defaultValue).toBeUndefined(); }); + // objectui#10339 — the spec declares `dateRange.defaultRange` with a default, + // so a `dateRange` that omits it is a FILTERED dashboard, not an unfiltered one. + it('applies the spec default preset (this_month) when an authored dateRange omits defaultRange', () => { + const defs = resolveDashboardFilterDefs({ dateRange: { field: 'created_at' } }); + expect(defs).toHaveLength(1); + expect(defs[0]).toMatchObject({ + name: DATE_RANGE_FILTER_NAME, + field: 'created_at', + type: 'dateRange', + defaultValue: { preset: 'this_month' }, + }); + // The filter it seeds is a real bound, not an empty value. + expect(buildFilterCondition(defs[0], defs[0].defaultValue)).toEqual({ + $gte: '{current_month_start}', + $lte: '{current_month_end}', + }); + }); + + it('reads the omitted-defaultRange preset from the spec schema, not from a local copy', () => { + const specDefault = SpecDashboardSchema.shape.dateRange.parse({})?.defaultRange; + expect(specDefault).toBeDefined(); + const defs = resolveDashboardFilterDefs({ dateRange: {} }); + expect(defs[0].field).toBe('created_at'); + expect(defs[0].defaultValue).toEqual({ preset: specDefault }); + }); + + it('leaves an explicit defaultRange and allowCustomRange exactly as authored', () => { + const defs = resolveDashboardFilterDefs({ + dateRange: { field: 'created_at', defaultRange: 'last_7_days', allowCustomRange: false }, + }); + expect(defs[0].defaultValue).toEqual({ preset: 'last_7_days' }); + expect(defs[0].allowCustomRange).toBe(false); + // No authored dateRange ⇒ no built-in filter at all; the default applies to + // an authored element only. + expect(resolveDashboardFilterDefs({})).toEqual([]); + }); + it('defaults a global filter name to its field and preserves declared names', () => { const defs = resolveDashboardFilterDefs({ globalFilters: [ diff --git a/packages/core/src/utils/dashboard-filters.ts b/packages/core/src/utils/dashboard-filters.ts index c37eab9674..2761b23360 100644 --- a/packages/core/src/utils/dashboard-filters.ts +++ b/packages/core/src/utils/dashboard-filters.ts @@ -22,7 +22,7 @@ import type { DashboardComponentSchema, DashboardWidgetSchema, I18nLabel, PageVariable } from '@object-ui/types'; import { liftLegacyGlobalFilterDefault } from '@object-ui/types'; -import { DATE_RANGE_PRESETS, type DateRangePreset } from '@objectstack/spec/ui'; +import { DATE_RANGE_PRESETS, DashboardSchema as SpecDashboardSchema, type DateRangePreset } from '@objectstack/spec/ui'; import { resolveDateMacros } from './date-macros.js'; /** Reserved filter name for the dashboard's built-in date range. */ @@ -31,6 +31,34 @@ export const DATE_RANGE_FILTER_NAME = 'dateRange'; /** Default target field for the built-in date range filter. */ const DATE_RANGE_DEFAULT_FIELD = 'created_at'; +/** + * The preset an authored `dateRange` resolves to when it omits `defaultRange` + * (objectui#10339) — READ FROM THE SPEC, never restated here. + * + * `@objectstack/spec` declares `DashboardSchema.dateRange.defaultRange` with a + * `.default(...)`, so the platform's parse of `dateRange: { field: 'created_at' }` + * carries that preset, and a renderer that treated the omission as "no filter" + * showed an UNFILTERED dashboard for a document the protocol says is filtered + * (#7759 ruling 5617465269, rule 1: the read site implements every arm the spec + * declares). The value is derived by parsing an EMPTY `dateRange` element + * through the spec's own schema: the only keys that come back are the ones the + * spec supplies by default, so this reads the spec's answer rather than a + * hand-copied string that could drift from it. `allowCustomRange` is the + * sibling default and needs no such read — it is consumed as `!== false`, + * which already is its spec default. + * + * Computed on first use and cached: `DashboardSchema` is a lazy schema, and + * forcing its construction at module load would cost every importer of + * `@object-ui/core` for a value only a dashboard with a bare `dateRange` needs. + */ +let specDateRangeDefault: string | undefined; +function specDefaultDateRangePreset(): string | undefined { + if (specDateRangeDefault === undefined) { + specDateRangeDefault = SpecDashboardSchema.shape.dateRange.parse({})?.defaultRange; + } + return specDateRangeDefault; +} + export interface DashboardFilterDef { /** Stable name — the variable key and the key widgets bind against. */ name: string; @@ -464,7 +492,11 @@ export function resolveDashboardFilterDefs( const byName = new Map(); if (schema.dateRange) { - const preset = schema.dateRange.defaultRange; + // An omitted `defaultRange` takes the spec's declared default (#10339); an + // explicit value — `'custom'` included — is used exactly as authored. + const preset = schema.dateRange.defaultRange === undefined + ? specDefaultDateRangePreset() + : schema.dateRange.defaultRange; byName.set(DATE_RANGE_FILTER_NAME, { name: DATE_RANGE_FILTER_NAME, field: schema.dateRange.field || DATE_RANGE_DEFAULT_FIELD, diff --git a/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.dateDefault.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.dateDefault.test.tsx index 3274758247..4e4d9c1c2b 100644 --- a/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.dateDefault.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.dateDefault.test.tsx @@ -97,3 +97,21 @@ describe('DashboardFilterBar — legacy `{ preset }` default (objectui#4165)', ( } }); }); + +/** + * objectui#10339 — the built-in `dateRange` with NO `defaultRange` takes the + * spec's declared default preset (`this_month` on the pinned spec), so the + * control must read that preset rather than "All time". + */ +describe('DashboardFilterBar — built-in dateRange omitting defaultRange (objectui#10339)', () => { + it('shows the spec default preset, not "All time"', () => { + const defs = resolveDashboardFilterDefs({ dateRange: { field: 'created_at' } }); + const values = Object.fromEntries(defs.map((d) => [d.name, d.defaultValue])); + + render(); + + const control = screen.getByTestId('dashboard-filter-dateRange'); + expect(control.textContent).not.toMatch(/All time/i); + expect(control.textContent).toMatch(/this month/i); + }); +}); diff --git a/packages/plugin-dashboard/src/__tests__/DashboardRenderer.filters.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardRenderer.filters.test.tsx index 894b45a029..e9441cdd8c 100644 --- a/packages/plugin-dashboard/src/__tests__/DashboardRenderer.filters.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DashboardRenderer.filters.test.tsx @@ -87,6 +87,23 @@ describe('DashboardRenderer dashboard-level filters', () => { }); }); + it('broadcasts the spec default range when dateRange omits defaultRange (objectui#10339)', async () => { + const queryDataset = makeQueryDataset(); + const schema: DashboardComponentSchema = { + type: 'dashboard', + // No `defaultRange` — the spec defaults it, so the dashboard is filtered. + dateRange: { field: 'created_at' }, + widgets: [{ id: 'w1', type: 'bar', dataset: 'invoices', values: ['count'] }], + }; + render(); + + await waitFor(() => { + expect(lastRuntimeFilter(queryDataset, 'invoices')).toEqual({ + created_at: { $gte: expect.any(String), $lte: expect.any(String) }, + }); + }); + }); + it('merges the broadcast with a widget\'s own filter and honors opt-out', async () => { const queryDataset = makeQueryDataset(); const schema: DashboardComponentSchema = {