From 04c1eee0a17c6f7d3963394bb4ba92d4bf82c0c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 18:21:13 +0000 Subject: [PATCH] fix(types): dashboard dateRange takes the spec authoring member on both faces DashboardComponentSchema.dateRange was on DASHBOARD_SPEC_EXCLUDED and restated locally: the Zod mirror typed defaultRange as a bare string (and stripped unknown keys) while the TypeScript twin bound it to the spec's DateRangeDefaultRange, so the validator admitted preset names tsc refused. The spec declares the key, so per the group F rule 1 / F1 ruling both faces now take the spec's authoring member by reference: dateRange leaves the exclusion list, the local element and member are deleted, and the WiderThanDeclared / WIDER_ARMS rows are removed. A pin asserts type equality with the spec member and the spec's verdict and issue envelope. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01877XiBYSaRCk2CU7cMSg3S --- ...0334-dashboard-daterange-spec-authoring.md | 28 +++++ content/docs/guide/dashboard-filters.md | 5 + .../dashboard-daterange-spec-10334.test.ts | 109 ++++++++++++++++++ .../page-app-dashboard-spec-parity.test.ts | 3 +- .../src/__tests__/zod-mirror-parity.test.ts | 19 +-- packages/types/src/complex.ts | 36 ++---- packages/types/src/zod/complex.zod.ts | 24 ++-- 7 files changed, 181 insertions(+), 43 deletions(-) create mode 100644 .changeset/10334-dashboard-daterange-spec-authoring.md create mode 100644 packages/types/src/__tests__/dashboard-daterange-spec-10334.test.ts diff --git a/.changeset/10334-dashboard-daterange-spec-authoring.md b/.changeset/10334-dashboard-daterange-spec-authoring.md new file mode 100644 index 0000000000..9db28f75fc --- /dev/null +++ b/.changeset/10334-dashboard-daterange-spec-authoring.md @@ -0,0 +1,28 @@ +--- +'@object-ui/types': minor +--- + +`DashboardComponentSchema.dateRange` now takes `@objectstack/spec`'s +`DashboardSchema.dateRange` authoring shape by reference on both faces +(objectui#10334, objectui#7759 group F). + +`dateRange` left `DASHBOARD_SPEC_EXCLUDED`, so the Zod validator and the +TypeScript interface both project the spec member instead of restating it. + +**Breaking (validator only):** the validator used to accept any string as +`dateRange.defaultRange` and silently strip unknown keys inside `dateRange`. +It now gives the spec's verdict: + +- a `defaultRange` outside `DATE_RANGE_DEFAULT_RANGES` (the spec's date presets + plus `custom`) is refused with `invalid_value` at `dateRange.defaultRange`. + Before, a typo such as `last_7_dayz` parsed green and then resolved to no + default window at render time; +- any key other than `field`, `defaultRange` and `allowCustomRange` is refused + with `unrecognized_keys` at `dateRange`. The spec's refusal names the + canonical key for its known aliases (`preset` / `range` / `default` → + `defaultRange`, `dateField` / `fieldName` → `field`, `allowCustom` / + `custom` → `allowCustomRange`). + +The TypeScript type is unchanged in effect: `defaultRange` was already bound to +the spec's `DateRangeDefaultRange`. The validator still writes no default into +a parsed document, as for every imported spec schema. diff --git a/content/docs/guide/dashboard-filters.md b/content/docs/guide/dashboard-filters.md index 6661e74a09..64af58bb1d 100644 --- a/content/docs/guide/dashboard-filters.md +++ b/content/docs/guide/dashboard-filters.md @@ -104,6 +104,11 @@ appears in the filter bar above the widgets: - `allowCustomRange` — offer a "Custom…" item that opens a from/to calendar (default `true`). +`dateRange` is the `@objectstack/spec` `DashboardSchema.dateRange` shape, taken +by reference on both the validator and the TypeScript type: a preset name +outside the list above, or any other key inside the object (`preset`, `range`, +`dateField`, …), is refused at validation rather than silently ignored. + Presets stay **symbolic** until query time: they compile to date-macro tokens (`{30_days_ago}`, `{current_month_start}`, …) that each widget resolves exactly like hand-authored widget filters — so a dashboard saved today still diff --git a/packages/types/src/__tests__/dashboard-daterange-spec-10334.test.ts b/packages/types/src/__tests__/dashboard-daterange-spec-10334.test.ts new file mode 100644 index 0000000000..163c3015d2 --- /dev/null +++ b/packages/types/src/__tests__/dashboard-daterange-spec-10334.test.ts @@ -0,0 +1,109 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#10334 (objectui#7759 group F residue) — `DashboardComponentSchema.dateRange` + * takes the spec's AUTHORING member on both faces, by reference. + * + * Before: the Zod mirror restated the element as a stripping `z.object` whose + * `defaultRange` was a bare `z.string()`, while the TypeScript twin bound + * `defaultRange` to the spec's `DateRangeDefaultRange`. The mirror therefore + * admitted preset names `tsc` refused (a typo such as `last_7_dayz` parsed green + * and then silently resolved to no default window) — the `WiderThanDeclared` + * row `complex.zod.ts#DashboardComponentSchema::dateRange`. + * + * `@objectstack/spec` declares the key (`DashboardSchema.dateRange`, a strict + * object over `DATE_RANGE_DEFAULT_RANGES`), so by objectui#7759 rule 1 and + * proposal F1 both faces point at that authoring shape: `dateRange` left + * `DASHBOARD_SPEC_EXCLUDED` and flows through the same projection as every other + * spec-owned dashboard key. + * + * The type half is enforced by `tsconfig.test.json` (chained from this package's + * `type-check`); the runtime half asserts the mirror gives the spec's verdict and + * the spec's issue envelope (`code` + `path`), and authors no default. + */ + +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; +import { DashboardSchema as SpecDashboardSchema, DATE_RANGE_DEFAULT_RANGES } from '@objectstack/spec/ui'; +import type { Dashboard } from '@objectstack/spec/ui'; +import type { DashboardComponentSchema } from '../complex'; +import { DashboardComponentSchema as DashboardMirror, DASHBOARD_SPEC_EXCLUDED } from '../zod/complex.zod'; + +type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; + +const dashboard = (dateRange: unknown) => ({ type: 'dashboard', widgets: [], dateRange }); + +describe('dateRange: both faces are the spec authoring member (objectui#10334)', () => { + it('the TypeScript twin and the mirror input equal the spec document member', () => { + const twin: Equal = true; + const mirror: Equal['dateRange'], Dashboard['dateRange']> = true; + // Control: the bare-string element the mirror used to carry is NOT the spec member. + const control: Equal< + { field?: string; defaultRange?: string; allowCustomRange?: boolean } | undefined, + Dashboard['dateRange'] + > = false; + expect([twin, mirror, control]).toEqual([true, true, false]); + }); + + it('is no longer on the exclusion list both faces read', () => { + expect(DASHBOARD_SPEC_EXCLUDED as readonly string[]).not.toContain('dateRange'); + }); +}); + +describe('dateRange: the mirror gives the spec verdict and envelope', () => { + it('refuses a preset name outside DATE_RANGE_DEFAULT_RANGES at dateRange.defaultRange', () => { + const r = DashboardMirror.safeParse(dashboard({ defaultRange: 'last_7_dayz' })); + expect(r.success).toBe(false); + expect(r.error?.issues.map((i) => ({ code: i.code, path: i.path }))).toEqual([ + { code: 'invalid_value', path: ['dateRange', 'defaultRange'] }, + ]); + }); + + it('refuses an unknown key inside dateRange instead of stripping it', () => { + const r = DashboardMirror.safeParse(dashboard({ field: 'created_at', bogus: 1 })); + expect(r.success).toBe(false); + expect(r.error?.issues.map((i) => ({ code: i.code, path: i.path }))).toEqual([ + { code: 'unrecognized_keys', path: ['dateRange'] }, + ]); + }); + + it('accepts every preset in DATE_RANGE_DEFAULT_RANGES, including the custom sentinel', () => { + expect(DATE_RANGE_DEFAULT_RANGES).toContain('custom'); + for (const defaultRange of DATE_RANGE_DEFAULT_RANGES) { + const r = DashboardMirror.safeParse(dashboard({ field: 'closed_at', defaultRange, allowCustomRange: false })); + expect(r.success, defaultRange).toBe(true); + } + }); + + it('authors no default into the parsed document (stripImportedDefaults)', () => { + const r = DashboardMirror.safeParse(dashboard({ field: 'created_at' })); + expect(r.success).toBe(true); + expect(r.success && r.data.dateRange).toEqual({ field: 'created_at' }); + }); + + it('agrees with the spec schema on every sample', () => { + const specDateRange = SpecDashboardSchema.shape.dateRange; + const samples: unknown[] = [ + {}, + { field: 'created_at', defaultRange: 'last_30_days', allowCustomRange: true }, + { defaultRange: 'custom' }, + { defaultRange: 'last_7_dayz' }, + { defaultRange: 30 }, + { preset: 'today' }, + { bogus: 1 }, + { allowCustomRange: 'yes' }, + ]; + for (const sample of samples) { + expect( + DashboardMirror.safeParse(dashboard(sample)).success, + JSON.stringify(sample), + ).toBe(specDateRange.safeParse(sample).success); + } + }); +}); diff --git a/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts b/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts index a2e877de50..155c169311 100644 --- a/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts +++ b/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts @@ -105,7 +105,8 @@ const CASES: Record = { Dashboard: { spec: SpecDashboardSchema, oui: OuiDashboardSchema, - omitted: ['widgets', 'globalFilters', 'dateRange'], + // `dateRange` left this list with objectui#10334: it now flows in by reference. + omitted: ['widgets', 'globalFilters'], local: [], }, }; diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index d1628fc06d..44d8c285a3 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -395,8 +395,13 @@ * spelled "six" rots exactly as fast as one spelled `6`, it is just harder to * point a regex at. ⛔ Do not spell a live figure out again, and ⛔ do not * restate one without checking that the pin's spelling still reaches it. - * - **17 entries** in `WiderThanDeclared`, **26 keys** across them, and **30 arms** - * under those keys — split **5** SCHEMA-NODE, **21** CONCRETE, **0** MIXED, **4** unions. + * - **17 entries** in `WiderThanDeclared`, **25 keys** across them, and **29 arms** + * under those keys — split **5** SCHEMA-NODE, **20** CONCRETE, **0** MIXED, **4** unions. + * It read 17 / 26 / 30 — 5 / 21 / 0 / 4 — until objectui#10334 (objectui#7759 group F) + * settled `complex.zod.ts#DashboardComponentSchema::dateRange` by rule 1: the spec + * declares the key, so both faces now take the spec's authoring member by reference and + * the mirror's bare-string `defaultRange` is gone. One key with one CONCRETE arm left an + * entry that keeps two others, so `entries` did not move. * It read 19 / 29 / 36 — 5 / 24 / 0 / 7 — until objectui#10280 (objectui#7759 group B) * emptied two entries: `form.zod.ts#SliderSchema` (`defaultValue` by widening the * declaration to the single-or-list the renderer normalizes, `value` by retiring it on @@ -3036,9 +3041,10 @@ interface WiderThanDeclared { // ruling beside the two runtime slots that remain. /** * CONCRETE. `header` and `globalFilters` carry the inline-locale widening one level - * down (a nested `label`) and `dateRange.defaultRange` is a bare string on the - * mirror against a closed literal set on the declaration. All three also carry a - * `KnownDrift` entry. + * down (a nested `label`). Both also carry a `KnownDrift` entry. `dateRange` was a + * third key until objectui#10334 — its `defaultRange` was a bare string on the mirror + * against the spec's closed preset set on the declaration; both faces now take the + * spec's authoring member by reference, so the pair measures clean on that key. * * ⚠️ `widgets` was the fourth key and made this entry MIXED — the only MIXED key * this ledger has held. Its verdict read "`widgets` is SCHEMA-NODE" until @@ -3051,7 +3057,7 @@ interface WiderThanDeclared { * `WIDER_ARMS` below, which names one per arm; this docblock is the prose beside it * and ⛔ may not be the only place a split is recorded again. */ - 'complex.zod.ts#DashboardComponentSchema': 'header' | 'globalFilters' | 'dateRange'; + 'complex.zod.ts#DashboardComponentSchema': 'header' | 'globalFilters'; /** CONCRETE: the element shape differs from the named declaration in both directions; also in `KnownDrift`. */ 'complex.zod.ts#FilterBuilderSchema': 'fields'; /** CONCRETE: the mirror's operator enum and the declared operator union are not the same set; also in `KnownDrift`. */ @@ -3252,7 +3258,6 @@ const WIDER_ARMS: Readonly< Record< string, readonly WiderArmClass[] > > = { 'app.zod.ts#AppComponentSchema::areas': ['SCHEMA-NODE'], 'complex.zod.ts#DashboardComponentSchema::header': ['CONCRETE'], 'complex.zod.ts#DashboardComponentSchema::globalFilters': ['CONCRETE'], - 'complex.zod.ts#DashboardComponentSchema::dateRange': ['CONCRETE'], 'complex.zod.ts#FilterBuilderSchema::fields': ['CONCRETE'], 'complex.zod.ts#FilterFieldSchema::operators': ['CONCRETE'], 'data-display.zod.ts#DataTableSchema::columns': ['CONCRETE'], diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index 073d790f5a..b972344770 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -17,7 +17,6 @@ import type { DashboardWidget as SpecDashboardWidget, - DateRangeDefaultRange as SpecDateRangeDefaultRange, GlobalFilter as SpecGlobalFilter, Dashboard as SpecDashboard, } from '@objectstack/spec/ui'; @@ -2263,31 +2262,16 @@ export interface DashboardComponentSchema extends BaseSchema, Omit` projection above. Pinned by + // `__tests__/dashboard-daterange-spec-10334.test.ts`. // `aria` was DECLARED here until objectui#5830, under a comment claiming // alignment with @objectstack/spec AriaPropsSchema — by then the opposite of // the contract: the spec removed `dashboard.aria` at the #3896 audit diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index 47248a2fe1..b304dfabf6 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -1153,11 +1153,23 @@ export const GlobalFilterSchema = z.object({ * * Omitted, each for a stated reason: * - `name`/`label`/`description` — component-envelope keys owned by BaseSchema; - * - `widgets`/`globalFilters`/`dateRange` — objectui's element schemas are - * their own ledger entries (the local widget still carries the legacy - * `component` envelope the spec has no room for, and both local configs are + * - `widgets`/`globalFilters` — objectui's element schemas are their own + * ledger entries (the local widget still carries the legacy `component` + * envelope the spec has no room for, and the local filter config is * deliberately looser than spec's); migration deferred. * + * `dateRange` was a third member of that list until objectui#10334. Its local + * element (`defaultRange` a bare `z.string()`, a stripping object) admitted + * preset names the spec's enum and the TypeScript twin both refuse — the + * `WiderThanDeclared` row objectui#7759 group F left behind. The spec declares + * the key, so by that card's rule 1 / proposal F1 both faces now take the + * spec's AUTHORING shape by reference through this projection: the closed + * `DATE_RANGE_DEFAULT_RANGES` vocabulary, the spec's strict object with its + * named alias refusals, and — via `stripImportedDefaults` — no authored + * default. The read site (`resolveDashboardFilterDefs` in `@object-ui/core`) + * already implements every arm: each preset, the `custom` sentinel, `field` + * and `allowCustomRange`. + * * `.partial()` guarantees no *future* spec field can become required and * silently invalidate stored objectui dashboards. */ @@ -1167,7 +1179,6 @@ export const DASHBOARD_SPEC_EXCLUDED = [ 'description', 'widgets', 'globalFilters', - 'dateRange', ] as const; // One list, two readers (objectui#9736): this call and the `DashboardComponentSchema` @@ -1193,11 +1204,6 @@ export const DashboardComponentSchema = BaseSchema.extend(SpecDashboardFields.sh widgets: z.array(z.union([DashboardWidgetSlotComponentSchema, DashboardWidgetSchema])) .describe('Dashboard widgets'), globalFilters: z.array(GlobalFilterSchema).optional().describe('Dashboard-level filters'), - dateRange: z.object({ - field: z.string().optional(), - defaultRange: z.string().optional(), - allowCustomRange: z.boolean().optional(), - }).optional().describe('Built-in date range filter'), body: retirementTombstone( 'REFUSED (objectui#9256, ADR-0049) — `dashboard` reads NEITHER content channel: measured with the ' + 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or '