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
28 changes: 28 additions & 0 deletions .changeset/10334-dashboard-daterange-spec-authoring.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions content/docs/guide/dashboard-filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
109 changes: 109 additions & 0 deletions packages/types/src/__tests__/dashboard-daterange-spec-10334.test.ts
Original file line number Diff line number Diff line change
@@ -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<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => 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<DashboardComponentSchema['dateRange'], Dashboard['dateRange']> = true;
const mirror: Equal<z.input<typeof DashboardMirror>['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);
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ const CASES: Record<string, Case> = {
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: [],
},
};
Expand Down
19 changes: 12 additions & 7 deletions packages/types/src/__tests__/zod-mirror-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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'],
Expand Down
36 changes: 10 additions & 26 deletions packages/types/src/complex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@

import type {
DashboardWidget as SpecDashboardWidget,
DateRangeDefaultRange as SpecDateRangeDefaultRange,
GlobalFilter as SpecGlobalFilter,
Dashboard as SpecDashboard,
ViewFilterOperator as SpecViewFilterOperator,
Expand Down Expand Up @@ -2331,31 +2330,16 @@ export interface DashboardComponentSchema extends BaseSchema, Omit<SpecDashboard
* value vocabularies now track the protocol instead of a snapshot of it.
*/
globalFilters?: SpecGlobalFilter[];
/**
* Date range filter configuration.
* Aligned with @objectstack/spec DashboardSchema.dateRange.
*
* `defaultRange` is BOUND to the spec's `DateRangeDefaultRange` rather than
* restated (objectui#4984). It used to be a hand-written 14-member union —
* byte-faithful to the spec, but faithful only until the next spec release:
* a preset the spec ADDS would be a legal document that objectui's own types
* say cannot exist, the same "narrower than the contract it implements" shape
* as objectui#4163's `label`, whose consequence was that the bad reads were
* invisible to `tsc`. No gate could report the drift either — `check:spec-symbols`
* rule 1 matches by NAME and an inline union on an interface member has no
* symbol to collide with, while rule 2's claim heuristic was waved through by
* the `SpecGlobalFilter` reference a few lines up. Binding makes the "Aligned
* with" line above structural instead of prose.
*
* `DATE_RANGE_DEFAULT_RANGES` is `[...DATE_RANGE_PRESETS, 'custom']`, so this
* tracks the same vocabulary `@object-ui/core` re-exports by reference
* (objectui#4167) — one list, reached two ways.
*/
dateRange?: {
field?: string;
defaultRange?: SpecDateRangeDefaultRange;
allowCustomRange?: boolean;
};
// `dateRange` was DECLARED here until objectui#10334 — a hand-written
// `{ field?, defaultRange?, allowCustomRange? }` whose `defaultRange` was bound
// to the spec's `DateRangeDefaultRange` (objectui#4984) while its Zod twin
// restated the key as a bare `z.string()`, so the validator admitted preset
// names this type refused (the `WiderThanDeclared` row objectui#7759 group F
// left behind). The key is spec-declared, so both faces now take the spec's
// AUTHORING member by reference: `dateRange` is no longer on
// `DASHBOARD_SPEC_EXCLUDED`, and this interface inherits it from the
// `Omit< Dashboard, … >` 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
Expand Down
24 changes: 15 additions & 9 deletions packages/types/src/zod/complex.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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`
Expand All @@ -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 '
Expand Down
Loading