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

fix(dashboard): an unrecognised date filter value is skipped and named, not compared

The residual the preset-name fix (objectui#3150 / objectstack#4475) left behind,
and the more deceptive half of it: a `date`/`dateRange` filter value that is
neither a known preset name nor a parseable date used to fall through to the
"a bare string date means equality on that day" branch. A misspelled default —
`defaultValue: 'last_7_dayz'` — therefore reached the widget query as
`runtimeFilter: { created_at: 'last_7_dayz' }`, which the backend faithfully
compiled to `WHERE created_at = $1`. `200 OK`, widget renders, count is 0 —
indistinguishable from "this range genuinely has no data". No 4xx, no console
warning, no UI signal. objectstack#4475 took a full RC cycle to catch for
exactly this reason: **0 looks like a legitimate answer**.

`buildFilterCondition` now holds a date value to three spellings, and only
three:

1. a known preset name → range bounds (unchanged, objectui#3150);
2. an ISO date (`2026-01-15`, `2026-01-15T08:30:00Z`) or a date-macro token
(`{today}`, `{7_days_ago}`) → equality on that day (the documented
behaviour, unchanged);
3. **anything else → the filter is skipped and `console.warn` names the filter,
the offending value, and the accepted spellings.**

The `{ preset: '<unknown>' }` object form gets the same voice. It already
dropped the filter — silently — because the preset lookup missed and no
`from`/`to` remained; that drop is now announced. When explicit bounds ride
along with an unknown preset the bounds are still honoured, and the warning says
which of the two won.

Rule 3 is deliberately the same strictness `buildWidgetScopedFilter` already
applies to a *default binding on a field the object does not have* — skip and
warn, with the same rationale spelled out there: never emit a query the backend
can only empty-match. Field *names* had that guard; field *values* did not.

The macro-token check asks `resolveDateMacros` itself whether it recognises the
string, rather than restating its token grammar in a second place. One
vocabulary, no dialect to drift — and a token that resolver does not know
(`{last_7_dayz}`) is precisely the typo this guard exists to catch.

Levelled `minor`, matching objectui#3150, because the emitted query shape
changes: a dashboard carrying a misspelled date value stops sending a
never-matching equality and instead sends no constraint for that filter (its
numbers go from 0 to unfiltered) while the console says why. Anything asserting
on the previously-emitted equality will see it disappear.

Note the direction of the relaxation is chosen, not incidental: skipping widens
the result set, so the number visibly changes and the warning explains it —
whereas the old behaviour narrowed it to zero, which is the one outcome an
author cannot tell from a correct answer. Author-time rejection (validating
`GlobalFilterSchema.defaultValue` at publish, in `@objectstack/spec`) is the
stricter complement and belongs on the platform side; it is filed separately.
14 changes: 14 additions & 0 deletions content/docs/guide/dashboard-filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,20 @@ Add a `globalFilters` entry. Each entry renders one control in the filter bar:
| `select` / `lookup` | dropdown | `{ field: value }` (or `$in` for arrays) |
| `date` | preset/custom range | `{ field: { "$gte": from, "$lte": to } }` |

A `date` filter's `defaultValue` is a **string**, and exactly three spellings
are accepted:

- a **preset name** from the `defaultRange` list above (`"last_7_days"`) — it
is lifted to that preset's range, the same as picking it in the control;
- an **ISO date** (`"2026-01-15"`) — equality on that day;
- a **date-macro token** (`"{today}"`, `"{7_days_ago}"`) — resolved at query
time like any other filter token.

Anything else — a misspelled preset such as `"last_7_dayz"` — is **skipped**,
and the runtime logs a `console.warn` naming the filter and the value. It is
deliberately not compared as-is: `field = "last_7_dayz"` matches no row, and
the widget would render a perfectly healthy-looking `0`.

Static `options` accept the `@objectstack/spec` object form
(`{ "value": "amer", "label": "AMER" }` — canonical, and what the spec
validates) or a bare-string shorthand (`["EMEA", "APAC"]`); the runtime
Expand Down
113 changes: 113 additions & 0 deletions packages/core/src/utils/__tests__/dashboard-filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,119 @@ describe('buildFilterCondition', () => {
expect(buildFilterCondition(dateDef, {})).toBeUndefined();
expect(buildFilterCondition(dateDef, { preset: undefined })).toBeUndefined();
});

// ---------------------------------------------------------------------
// #3151 — a date value that is neither a known preset nor an ISO date.
//
// The sister case of framework#4475, and the more deceptive direction of
// the same failure: a misspelled preset ('last_7_dayz') used to fall
// through to the "bare string means equality" branch and emit
// SELECT COUNT(*) … WHERE created_at = 'last_7_dayz'
// — 200 OK, zero rows, no warning anywhere, indistinguishable from a range
// that genuinely has no data. It is now skipped and named out loud, the
// same strictness buildWidgetScopedFilter applies to unknown field names.
// ---------------------------------------------------------------------
describe('[#3151] unrecognised date values', () => {
const withWarn = (fn: (warn: ReturnType<typeof vi.spyOn>) => void) => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
fn(warn);
} finally {
warn.mockRestore();
}
};

it('skips a misspelled preset string and warns, naming the filter and the value', () => {
withWarn((warn) => {
expect(buildFilterCondition(dateDef, 'last_7_dayz')).toBeUndefined();
expect(warn).toHaveBeenCalledTimes(1);
const msg = String(warn.mock.calls[0][0]);
expect(msg).toContain('skipping filter "dateRange"');
expect(msg).toContain('last_7_dayz');
// The remedy travels with the rejection.
expect(msg).toContain('last_7_days');
});
});

it('keeps a valid ISO date string as an equality, with no warning', () => {
withWarn((warn) => {
expect(buildFilterCondition(dateDef, '2026-01-15')).toBe('2026-01-15');
expect(buildFilterCondition(dateDef, '2026-01-15T08:30:00Z')).toBe('2026-01-15T08:30:00Z');
expect(warn).not.toHaveBeenCalled();
});
});

it('keeps a date-macro token as an equality, with no warning', () => {
// Macro tokens stay symbolic in the condition and are resolved at query
// time by resolveDateMacros — the same vocabulary PRESET_RANGES emits.
withWarn((warn) => {
expect(buildFilterCondition(dateDef, '{today}')).toBe('{today}');
expect(buildFilterCondition(dateDef, '{7_days_ago}')).toBe('{7_days_ago}');
expect(warn).not.toHaveBeenCalled();
});
});

it('rejects a string that only looks like a date or a macro', () => {
withWarn((warn) => {
expect(buildFilterCondition(dateDef, '{last_7_dayz}')).toBeUndefined();
expect(buildFilterCondition(dateDef, '15/01/2026')).toBeUndefined();
expect(buildFilterCondition(dateDef, '2026-13-45')).toBeUndefined();
expect(warn).toHaveBeenCalledTimes(3);
});
});

it('[#4475 regression] a valid preset name still becomes a RANGE, with no warning', () => {
withWarn((warn) => {
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}',
});
expect(warn).not.toHaveBeenCalled();
});
});

it('skips an unknown object preset and warns (was a silent drop)', () => {
withWarn((warn) => {
expect(buildFilterCondition(dateDef, { preset: 'last_7_dayz' })).toBeUndefined();
expect(warn).toHaveBeenCalledTimes(1);
const msg = String(warn.mock.calls[0][0]);
expect(msg).toContain('skipping filter "dateRange"');
expect(msg).toContain('unknown date range preset "last_7_dayz"');
});
});

it('keeps explicit bounds when an unknown preset rides along, and says so', () => {
withWarn((warn) => {
expect(
buildFilterCondition(dateDef, { preset: 'last_7_dayz', from: '2026-01-01' }),
).toEqual({ $gte: '2026-01-01' });
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0][0])).toContain('ignoring unknown date range preset');
});
});

it('drops the filter end-to-end: no runtimeFilter reaches the widget query', () => {
withWarn((warn) => {
// What the dashboard actually forwards as `runtimeFilter`. Pre-fix
// this was { created_at: 'last_7_dayz' } — the zero-row query.
const [def] = resolveDashboardFilterDefs({
globalFilters: [
{ field: 'created_at', type: 'date', defaultValue: 'last_7_dayz' },
] as any,
});
expect(
buildWidgetScopedFilter({ id: 'kpi_users' }, [def], { created_at: def.defaultValue }),
).toBeUndefined();
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0][0])).toContain('last_7_dayz');
});
});
});
});

describe('buildWidgetScopedFilter', () => {
Expand Down
72 changes: 69 additions & 3 deletions packages/core/src/utils/dashboard-filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
*/

import type { DashboardComponentSchema, DashboardWidgetSchema, PageVariable } from '@object-ui/types';
import { resolveDateMacros } from './date-macros.js';

/** Reserved filter name for the dashboard's built-in date range. */
export const DATE_RANGE_FILTER_NAME = 'dateRange';
Expand Down Expand Up @@ -88,6 +89,41 @@ 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);

/**
* ISO calendar date, optionally carrying a time part — `2026-01-15`,
* `2026-01-15T08:30:00Z`. Deliberately narrower than `Date.parse`, which
* also accepts locale prose (`March 5, 2026`) and bare years (`2026`);
* neither is a value the backend compares a date column against usefully.
*/
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(?:[T ][\d:.]+(?:Z|[+-]\d{2}:?\d{2})?)?$/;

/**
* True when a bare string value can legitimately reach a query as a date
* (#3151). Two spellings qualify:
*
* - a **date-macro token** (`{today}`, `${current_month_start}`,
* `{7_days_ago}`) — it stays symbolic in the generated condition and is
* resolved at query time by `resolveDateMacros`, exactly like the bounds
* `PRESET_RANGES` emits. The check asks that resolver itself instead of
* restating its grammar here: one token vocabulary, no second dialect to
* drift — and a token it does not know is precisely the typo this guard
* exists to catch;
* - an **ISO date**, which means equality on that day (documented behaviour).
*/
function isUsableDateString(value: string): boolean {
if (resolveDateMacros(value) !== value) return true;
return ISO_DATE_RE.test(value) && !Number.isNaN(Date.parse(value));
}

/** `today, yesterday, …` — quoted in every rejection so the fix is in reach. */
function presetList(): string {
return DATE_RANGE_PRESETS.join(', ');
}

function warnDateFilter(message: string): void {
if (typeof console !== 'undefined') console.warn(`[dashboard-filters] ${message}`);
}

/**
* Normalize a date filter's DECLARED default into the `DateRangeValue` shape
* every date consumer in this module reads (framework#4475).
Expand All @@ -110,7 +146,9 @@ export const DATE_RANGE_PRESETS = Object.keys(PRESET_RANGES);
*
* 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.
* boolean / unrecognised string is left exactly as declared — an unrecognised
* string then never reaches a query at all: `buildFilterCondition` skips it
* with a warning rather than comparing a column against it (#3151).
*/
function normalizeDateDefault(type: DashboardFilterDef['type'], defaultValue: unknown): unknown {
if (type !== 'date' && type !== 'dateRange') return defaultValue;
Expand Down Expand Up @@ -227,6 +265,15 @@ function isEmptyValue(def: DashboardFilterDef, value: unknown): boolean {
* Build the operator shape (the value side of a `FilterCondition` entry) for
* one filter's current value. Returns `undefined` when the value imposes no
* constraint. The caller keys the result by the bound field name.
*
* A `date`/`dateRange` value is held to three spellings (#3151): a known
* preset name → range bounds; a date-macro token or ISO date → equality on
* that day; **anything else → skipped with a console warning**, never
* silently downgraded to an equality nothing can match. That last branch is
* the same strictness `buildWidgetScopedFilter` applies to a default binding
* on an unknown field name, for the same reason: a query the backend answers
* `200 OK` with zero rows is indistinguishable from "this range has no data",
* so the typo has to be said out loud somewhere.
*/
export function buildFilterCondition(
def: DashboardFilterDef,
Expand All @@ -237,16 +284,35 @@ export function buildFilterCondition(
if (def.type === 'dateRange' || def.type === 'date') {
const v = value as DateRangeValue;
if (typeof v === 'object') {
const range = v.preset ? PRESET_RANGES[v.preset] : undefined;
const preset = typeof v.preset === 'string' && v.preset ? v.preset : undefined;
const range = preset ? PRESET_RANGES[preset] : undefined;
const from = range?.from ?? v.from;
const to = range?.to ?? v.to;
if (preset && !range) {
// Was already dropped here — but silently, which reads as "no data".
warnDateFilter(
from || to
? `filter "${def.name}": ignoring unknown date range preset "${preset}" — ` +
`using the explicit from/to bounds instead; known presets: ${presetList()}`
: `skipping filter "${def.name}": unknown date range preset "${preset}" — ` +
`expected one of: ${presetList()}`,
);
}
if (!from && !to) return undefined;
return {
...(from ? { $gte: from } : {}),
...(to ? { $lte: to } : {}),
};
}
// A bare string date means equality on that day.
if (typeof v === 'string' && !isUsableDateString(v)) {
warnDateFilter(
`skipping filter "${def.name}": value "${v}" is neither a known date range preset ` +
`nor an ISO date — expected one of: ${presetList()}, an ISO date (YYYY-MM-DD), ` +
`or a date macro such as {today}`,
);
return undefined;
}
// A bare string date (or date macro) means equality on that day.
return value;
}

Expand Down
Loading