From e00a0130e0c46115184dccfc6a37e22ec22520f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 11:06:08 +0000 Subject: [PATCH 1/2] fix(core): skip and warn on an unrecognised date filter value (#3151) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `date`/`dateRange` dashboard filter value that is neither a known preset name nor a parseable date fell through to the "a bare string date means equality on that day" branch, so a misspelled default ('last_7_dayz') reached the query as `WHERE created_at = 'last_7_dayz'` — 200 OK, widget renders, count 0, indistinguishable from a range with no data. `buildFilterCondition` now accepts exactly three spellings for a date value: a known preset (→ range bounds), an ISO date or date-macro token (→ equality on that day), and nothing else — an unrecognised value is skipped with a console.warn naming the filter, the value and the accepted spellings. The `{ preset: '' }` object form already dropped the filter silently; that drop is now announced too. This is the same strictness `buildWidgetScopedFilter` already applies to a default binding on a field the object lacks: never emit a query the backend can only empty-match. The macro-token check asks `resolveDateMacros` itself rather than restating its grammar, so there is no second token dialect. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PRJtkgUAaVG11FsJQbvZWA --- .../dashboard-date-filter-unknown-value.md | 55 +++++++++ .../utils/__tests__/dashboard-filters.test.ts | 113 ++++++++++++++++++ packages/core/src/utils/dashboard-filters.ts | 72 ++++++++++- 3 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 .changeset/dashboard-date-filter-unknown-value.md diff --git a/.changeset/dashboard-date-filter-unknown-value.md b/.changeset/dashboard-date-filter-unknown-value.md new file mode 100644 index 000000000..aac4cea0b --- /dev/null +++ b/.changeset/dashboard-date-filter-unknown-value.md @@ -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: '' }` 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. diff --git a/packages/core/src/utils/__tests__/dashboard-filters.test.ts b/packages/core/src/utils/__tests__/dashboard-filters.test.ts index 4e0d5b609..ca5c20126 100644 --- a/packages/core/src/utils/__tests__/dashboard-filters.test.ts +++ b/packages/core/src/utils/__tests__/dashboard-filters.test.ts @@ -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) => 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', () => { diff --git a/packages/core/src/utils/dashboard-filters.ts b/packages/core/src/utils/dashboard-filters.ts index d779f6368..2e56fb34e 100644 --- a/packages/core/src/utils/dashboard-filters.ts +++ b/packages/core/src/utils/dashboard-filters.ts @@ -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'; @@ -88,6 +89,41 @@ const PRESET_RANGES: Record = { /** 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). @@ -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; @@ -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, @@ -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; } From f9d421e9ed9a428b26aef43ae32c25bd32b597fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 11:12:24 +0000 Subject: [PATCH 2/2] docs(dashboard): state the three accepted date-filter defaultValue spellings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guide never documented what a `date` filter's `defaultValue` may be — which is how a misspelled preset reached a query in the first place. Now lists the three accepted spellings (preset name / ISO date / date macro) and says plainly that anything else is skipped with a warning, and why it is not compared as-is. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PRJtkgUAaVG11FsJQbvZWA --- content/docs/guide/dashboard-filters.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/content/docs/guide/dashboard-filters.md b/content/docs/guide/dashboard-filters.md index b9839c5c9..fc132f425 100644 --- a/content/docs/guide/dashboard-filters.md +++ b/content/docs/guide/dashboard-filters.md @@ -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