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
15 changes: 15 additions & 0 deletions .changeset/10339-daterange-spec-default.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 4 additions & 1 deletion content/docs/guide/dashboard-filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).

Expand Down
38 changes: 38 additions & 0 deletions packages/core/src/utils/__tests__/dashboard-filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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: [
Expand Down
36 changes: 34 additions & 2 deletions packages/core/src/utils/dashboard-filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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;
Expand Down Expand Up @@ -464,7 +492,11 @@ export function resolveDashboardFilterDefs(
const byName = new Map<string, DashboardFilterDef>();

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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<DashboardFilterBar defs={defs} values={values} onChange={vi.fn()} />);

const control = screen.getByTestId('dashboard-filter-dateRange');
expect(control.textContent).not.toMatch(/All time/i);
expect(control.textContent).toMatch(/this month/i);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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(<DashboardRenderer schema={schema} dataSource={{ queryDataset }} />);

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 = {
Expand Down
Loading