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 f8b0aa3a8f..340a4d46cb 100644 --- a/content/docs/guide/dashboard-filters.md +++ b/content/docs/guide/dashboard-filters.md @@ -107,6 +107,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 9421e537f6..4db17d08ce 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -427,8 +427,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. - * - **8 entries** in `WiderThanDeclared`, **11 keys** across them, and **12 arms** - * under those keys — split **5** SCHEMA-NODE, **6** CONCRETE, **0** MIXED, **1** unions. + * - **8 entries** in `WiderThanDeclared`, **10 keys** across them, and **11 arms** + * under those keys — split **5** SCHEMA-NODE, **5** CONCRETE, **0** MIXED, **1** unions. + * It read 8 / 11 / 12 — 5 / 6 / 0 / 1 — 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 11 / 16 / 18 — 5 / 11 / 0 / 2 — until objectui#10286 closed five CONCRETE * keys of objectui#7759's groups C and D: `complex.zod.ts#FilterFieldSchema::operators` * and `complex.zod.ts#FilterBuilderSchema::fields` (both faces now state the spec's @@ -3128,9 +3133,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 @@ -3143,7 +3149,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'; // `complex.zod.ts#FilterBuilderSchema` (`fields`) and `complex.zod.ts#FilterFieldSchema` // (`operators`) LEFT under objectui#10286: the second was the operator vocabulary, each // face refusing a spelling the other accepted, and the first inherited it through the @@ -3347,7 +3353,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'], 'form.zod.ts#FormSchema::layout': ['CONCRETE'], 'layout.zod.ts#PageNodeSchema::slots': ['SCHEMA-NODE'], 'navigation.zod.ts#HeaderBarSchema::logo': ['CONCRETE', 'CONCRETE'], diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index 09c80eb97b..568fb62175 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, ViewFilterOperator as SpecViewFilterOperator, @@ -2331,31 +2330,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 70e6d6bf94..b56dee56d4 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -1180,11 +1180,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. */ @@ -1194,7 +1206,6 @@ export const DASHBOARD_SPEC_EXCLUDED = [ 'description', 'widgets', 'globalFilters', - 'dateRange', ] as const; // One list, two readers (objectui#9736): this call and the `DashboardComponentSchema` @@ -1220,11 +1231,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 '