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/global-filter-preset-alias-4165.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@object-ui/types': patch
'@object-ui/core': patch
'@object-ui/plugin-designer': patch
---

A dashboard date filter's default has one spelling again — the bare preset name — and the `{ preset }` object becomes a documented legacy alias with a retirement window

`@objectstack/spec` 17.0.0-rc.6 added a cross-field refinement to `GlobalFilterSchema` holding a `type: 'date'` filter's `defaultValue` to three spellings: a preset NAME (`last_7_days`), an ISO date (`2026-01-15`), or a date-macro token (`{today}`). objectui's derived schema had widened `defaultValue` to `z.any()` and did not carry the refinement, so it accepted `{ preset: 'last_7_days' }` — metadata the platform refuses. That is the tolerant-consumer shape where the designer goes green and the save fails server-side, and it is now closed: the refinement is adopted, the widening is retired, and the object form is refused with the spec's own message.

Per the maintainer ruling on objectui#4165, the spec stays strict and the bare preset name is the single canonical spelling. `{ preset }` is handled as an ADR-0089 legacy alias rather than by a permanently tolerant schema: `liftLegacyGlobalFilterDefault` / `liftLegacyDashboardFilterDefaults` (new exports on `@object-ui/types`) convert it to the bare name, `@object-ui/core`'s `resolveDashboardFilterDefs` applies the lift when it reads a stored dashboard, and the console's dashboard designer applies it as the document enters the editable draft so the next save persists the canonical spelling. The retirement window is recorded at the read site: the alias may be removed in `@object-ui/types` 18.0.0, and every lift warns on the console so a surviving legacy document is visible rather than silently tolerated.

No stored dashboard has to change for this release. The lift means a document carrying the object form keeps loading and rendering exactly as before — measured, not assumed: a legacy declaration already resolved correctly, because `{ preset }` also happens to be the runtime value shape objectui's own date filters use, and that coincidence is why the object form went unnoticed for so long. What changes is that the declaration is now canonicalized on read and rewritten on save, so the two spellings converge instead of accreting.

The other two divergences in this schema — the bare-string `options` shorthand and the optional `optionsFrom.labelField` — are unaffected. Carrying the spec's refinement while keeping them needed a new composition: a refined object schema in zod 4 rejects `.extend()` and `.omit()` outright and types every `.safeExtend()` override as `never`, so objectui's schema now spreads the spec's shape and re-attaches the spec's object-level rules by delegating to the spec schema itself. Nothing restates the spec's grammar, and a refinement the spec adds later flows in with no change here.
96 changes: 96 additions & 0 deletions packages/core/src/utils/__tests__/dashboard-filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,3 +482,99 @@ describe('DATE_RANGE_PRESETS is the spec\'s list, not a copy of it', () => {
}
});
});

// ---------------------------------------------------------------------------
// objectui#4165 — the ADR-0089 legacy-alias window for a date filter's
// declared `defaultValue`.
//
// Maintainer ruling (2026-08-11): the spec stays strict, the bare preset NAME
// is the single canonical spelling, and the stored `{ preset }` object form is
// a documented legacy alias — lifted on read, rewritten on next save.
//
// READ THE STRENGTHS OF THESE PINS HONESTLY — reverse verification (deleting
// the lift call in `resolveDashboardFilterDefs`, re-running, restoring) turned
// exactly ONE of them red: the warning. The convergence and rendering pins were
// green with the lift and green without it, and it is worth knowing why rather
// than mistaking them for proof.
//
// A legacy DECLARATION (`defaultValue: { preset }`) happens to be shaped like
// the runtime VALUE `normalizeDateDefault` produces for the canonical spelling,
// and `normalizeDateDefault` passes non-strings straight through. So a stored
// `{ preset }` dashboard already rendered correctly before this change and
// still would with the lift removed. That coincidence is the whole reason the
// object form looked harmless for so long — and it is how the divergence prose
// drifted into calling it "the on-disk form".
//
// So what each pin is for:
// - the WARNING pin is the one that can detect the lift's absence, and it is
// what makes the window closable at all (ADR-0078);
// - the CONVERGENCE and BOUNDS pins are regression guards, not evidence: they
// say the lift did not break the shape everything downstream reads. Kept
// deliberately, labelled deliberately;
// - the behavioural teeth of #4165 are elsewhere and DO go red — the schema's
// refusal (`@object-ui/types` parity suite) and the rewrite-on-save
// (`@object-ui/plugin-designer`'s DashboardDesignPage pin).
// ---------------------------------------------------------------------------
describe('[#4165] legacy `{ preset }` declaration — ADR-0089 alias lift', () => {
const legacy = { field: 'created_at', type: 'date', label: 'Date Range', defaultValue: { preset: 'last_7_days' } };
const canonical = { field: 'created_at', type: 'date', label: 'Date Range', defaultValue: 'last_7_days' };

const resolveQuietly = (globalFilters: unknown[]) => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
return {
defs: resolveDashboardFilterDefs({ globalFilters } as any),
warnings: warn.mock.calls.map((c) => String(c[0])),
};
} finally {
warn.mockRestore();
}
};

// Regression guard (see the block note): green with or without the lift.
it('resolves to defs identical to the canonical spelling', () => {
const { defs: fromLegacy } = resolveQuietly([legacy]);
const { defs: fromCanonical } = resolveQuietly([canonical]);
expect(fromLegacy).toEqual(fromCanonical);
// …and specifically to the runtime VALUE shape the date consumers read.
// Note this is `{ preset }` again — the round trip is not a no-op, it is
// declaration → canonical name → value. See `normalizeDateDefault`'s note
// on declaration space vs value space; conflating the two is what #4165
// was filed about.
expect(fromLegacy[0].defaultValue).toEqual({ preset: 'last_7_days' });
});

it('produces the same query bounds as the canonical spelling', () => {
const { defs } = resolveQuietly([legacy]);
expect(buildFilterCondition(defs[0], defs[0].defaultValue)).toEqual({
$gte: '{7_days_ago}',
$lte: '{today}',
});
});

it('warns when it lifts, so a surviving legacy document is visible', () => {
// ADR-0078 — a silent lift can never be retired: nothing would ever show
// that the last legacy document is gone.
const { warnings } = resolveQuietly([legacy]);
const lift = warnings.filter((m) => m.includes('LEGACY'));
expect(lift).toHaveLength(1);
expect(lift[0]).toContain('created_at');
expect(lift[0]).toContain('last_7_days');
expect(lift[0]).toContain('#4165');
});

it('says nothing at all for a canonical declaration', () => {
const { warnings } = resolveQuietly([canonical]);
expect(warnings).toEqual([]);
});

it('does not lift a `{ preset, from, to }` value — it has no canonical spelling', () => {
// Left exactly as declared; `buildFilterCondition` still reads the bounds
// off it, so nothing breaks, but no data is silently dropped to fit the
// bare-name form.
const withBounds = { field: 'created_at', type: 'date', defaultValue: { preset: 'last_7_days', from: '2026-01-01' } };
const { defs, warnings } = resolveQuietly([withBounds]);
expect(defs[0].defaultValue).toEqual({ preset: 'last_7_days', from: '2026-01-01' });
expect(warnings.filter((m) => m.includes('LEGACY'))).toEqual([]);
});
});
93 changes: 91 additions & 2 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 { liftLegacyGlobalFilterDefault } from '@object-ui/types';
import { DATE_RANGE_PRESETS, type DateRangePreset } from '@objectstack/spec/ui';
import { resolveDateMacros } from './date-macros.js';

Expand Down Expand Up @@ -169,10 +170,93 @@ function warnDateFilter(message: string): void {
if (typeof console !== 'undefined') console.warn(`[dashboard-filters] ${message}`);
}

/**
* Apply the ADR-0089 legacy-alias lift to ONE stored `globalFilters` entry, and
* say so out loud when it fires (objectui#4165).
*
* ## The alias, at this read site
*
* **What it is.** `defaultValue: { preset: 'last_7_days' }` on a `type: 'date'`
* filter. The canonical spelling is the bare preset NAME,
* `defaultValue: 'last_7_days'` — one of the three the spec's rc.6 refinement
* accepts (preset name / ISO date / date-macro token).
*
* **Why it is lifted here rather than tolerated.** Maintainer ruling on
* objectui#4165 (2026-08-11): 「spec stays strict — no widening」. objectui's
* `GlobalFilterSchema` now carries that refinement, so a document holding the
* object form fails validation; lifting it BEFORE anything reads the entry
* makes the declaration canonical by construction. The lift itself lives in
* `@object-ui/types`' `dashboard-filter-alias.ts` (one implementation, shared
* with the designer's rewrite-on-save path); this is one of its two call sites.
*
* **What it does NOT buy, measured.** It does not rescue rendering. A legacy
* declaration already resolved correctly before #4165 and still does with this
* call deleted — reverse-verified, only the warning below changes. The reason
* is a coincidence worth knowing: `{ preset }` is also the runtime VALUE shape
* `normalizeDateDefault` produces for the canonical name, and that function
* passes non-strings through untouched. That coincidence is why the object form
* looked harmless for so long, and how the schema's own prose drifted into
* calling it "the on-disk form". Read this call as canonicalization plus
* observability, not as a repair — claiming more would be claiming coverage the
* tests in `__tests__/dashboard-filters.test.ts` do not have.
*
* **When it may be removed.** At the next MAJOR of `@object-ui/types` (18.0.0).
* By then every dashboard opened in the designer has been rewritten to the bare
* name, because `DashboardDesignPage` lifts into the editable draft and the
* next save persists it. Delete this function and its call below together with
* the lift itself; a document still carrying the object form then gets the
* spec's named rejection, which is the intended end state.
*
* The warning is not decoration: a silent lift can never be retired, because
* nothing would ever show that the last legacy document is gone (ADR-0078 —
* nothing silently inert).
*/
function liftLegacyFilterDeclaration<T>(filter: T): T {
const lifted = liftLegacyGlobalFilterDefault(filter);
if (lifted === filter) return filter;
const name = (filter as { name?: string; field?: string })?.name
?? (filter as { field?: string })?.field
?? '?';
const preset = (lifted as { defaultValue?: unknown })?.defaultValue;
warnDateFilter(
`filter "${name}": \`defaultValue: { preset: ${JSON.stringify(preset)} }\` is a LEGACY ` +
`spelling (objectui#4165) and was lifted to the canonical bare preset name ` +
`${JSON.stringify(preset)}. Rewrite the stored dashboard — the object form is ` +
`refused by @objectstack/spec and its acceptance here ends with @object-ui/types 18.`,
);
return lifted;
}

/**
* Normalize a date filter's DECLARED default into the `DateRangeValue` shape
* every date consumer in this module reads (framework#4475).
*
* ## Declaration space vs value space — the distinction objectui#4165 turned on
*
* These are two different things that share the name `defaultValue`, and
* conflating them is what produced #4165:
*
* - the **declaration** is `globalFilters[].defaultValue` in a stored
* dashboard. `@objectstack/spec` owns it, it is `string | number | boolean`,
* and since rc.6 a refinement holds a `type: 'date'` one to a preset NAME,
* an ISO date or a date-macro token. A bare preset name is the canonical
* spelling and the object form is a retiring alias (see
* `liftLegacyFilterDeclaration` above);
* - the **value** is what this function RETURNS: `DashboardFilterDef
* .defaultValue`, which seeds the filter variable and is read by
* `DateRangeFilter` (`.preset`/`.from`/`.to`) and `buildFilterCondition`.
* That shape is `DateRangeValue`, it is objectui-internal, the spec has no
* opinion about it, and for a preset it is `{ preset }`.
*
* So this function converts declaration → value. It is NOT a producer of stored
* metadata: nothing writes a resolved `DashboardFilterDef` back into a
* dashboard document (measured in #4165 — `resolveDashboardFilterDefs`' only
* callers are `DashboardRenderer` and `DashboardWidgetInspector`, both read
* side). Making it emit the bare name instead would therefore not change one
* byte on disk; it would only hand `DateRangeFilter` a string it cannot read
* and `buildFilterCondition` a value it warns-and-skips — i.e. re-open
* framework#4475 exactly, which is why it keeps emitting `{ preset }`.
*
* 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`
Expand Down Expand Up @@ -254,8 +338,13 @@ export function resolveDashboardFilterDefs(
});
}

for (const f of schema.globalFilters ?? []) {
if (!f?.field) continue;
for (const raw of schema.globalFilters ?? []) {
if (!raw?.field) continue;
// ADR-0089 legacy-alias lift (#4165) — a stored `defaultValue: { preset }`
// becomes the canonical bare name BEFORE anything else reads the entry, so
// a legacy dashboard resolves to byte-identical defs. See
// `liftLegacyFilterDeclaration` for the retirement window.
const f = liftLegacyFilterDeclaration(raw);
const name = f.name || f.field;
if (byName.has(name) && typeof console !== 'undefined') {
console.warn(`[dashboard-filters] duplicate filter name "${name}" — the later definition wins`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,38 @@ describe('DashboardFilterBar — date filter default (framework#4475)', () => {
expect(screen.getByTestId('dashboard-filter-created_at').textContent).toMatch(/All time/i);
});
});

/**
* objectui#4165 — a stored dashboard carrying the LEGACY `{ preset }` spelling
* of the same declaration must render identically.
*
* The maintainer ruling made the bare preset name the single canonical
* spelling and turned the object form into an ADR-0089 legacy alias, lifted on
* read by `resolveDashboardFilterDefs`. "Lifted" is only worth anything if the
* user cannot tell: this is the display half of that claim, and it is asserted
* against the framework#4475 fixture above rather than a fresh one, so the two
* spellings are compared on identical input.
*/
describe('DashboardFilterBar — legacy `{ preset }` default (objectui#4165)', () => {
const LEGACY_FILTERS = [
{ field: 'created_at', type: 'date', label: 'Date Range', scope: 'dashboard', defaultValue: { preset: 'last_7_days' } },
] as any;

it('renders a lifted legacy default exactly like the canonical one', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const legacyDefs = resolveDashboardFilterDefs({ globalFilters: LEGACY_FILTERS });
const canonicalDefs = resolveDashboardFilterDefs({ globalFilters: SYSTEM_OVERVIEW_FILTERS });
expect(legacyDefs).toEqual(canonicalDefs);

const values = Object.fromEntries(legacyDefs.map((d) => [d.name, d.defaultValue]));
render(<DashboardFilterBar defs={legacyDefs} values={values} onChange={vi.fn()} />);

const control = screen.getByTestId('dashboard-filter-created_at');
expect(control.textContent).not.toMatch(/All time/i);
expect(control.textContent).toMatch(/last 7 days/i);
} finally {
warn.mockRestore();
}
});
});
Loading
Loading