Skip to content

Commit a74282b

Browse files
committed
Merge origin/main (objectui#10338) into claude/issue-10387-header-bar-unread-keys
Resolves the WIDER ledger header: objectui#10334 (via #10338) retired the DashboardComponentSchema::dateRange row, and this branch retires the HeaderBarSchema::logo entry. Re-derived figures: 7 / 9 / 9, split 5 / 4 / 0 / 0, with both history sentences kept in order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01877XiBYSaRCk2CU7cMSg3S
2 parents 968cd76 + ce6bd99 commit a74282b

7 files changed

Lines changed: 182 additions & 44 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@object-ui/types': minor
3+
---
4+
5+
`DashboardComponentSchema.dateRange` now takes `@objectstack/spec`'s
6+
`DashboardSchema.dateRange` authoring shape by reference on both faces
7+
(objectui#10334, objectui#7759 group F).
8+
9+
`dateRange` left `DASHBOARD_SPEC_EXCLUDED`, so the Zod validator and the
10+
TypeScript interface both project the spec member instead of restating it.
11+
12+
**Breaking (validator only):** the validator used to accept any string as
13+
`dateRange.defaultRange` and silently strip unknown keys inside `dateRange`.
14+
It now gives the spec's verdict:
15+
16+
- a `defaultRange` outside `DATE_RANGE_DEFAULT_RANGES` (the spec's date presets
17+
plus `custom`) is refused with `invalid_value` at `dateRange.defaultRange`.
18+
Before, a typo such as `last_7_dayz` parsed green and then resolved to no
19+
default window at render time;
20+
- any key other than `field`, `defaultRange` and `allowCustomRange` is refused
21+
with `unrecognized_keys` at `dateRange`. The spec's refusal names the
22+
canonical key for its known aliases (`preset` / `range` / `default` →
23+
`defaultRange`, `dateField` / `fieldName` → `field`, `allowCustom` /
24+
`custom` → `allowCustomRange`).
25+
26+
The TypeScript type is unchanged in effect: `defaultRange` was already bound to
27+
the spec's `DateRangeDefaultRange`. The validator still writes no default into
28+
a parsed document, as for every imported spec schema.

‎content/docs/guide/dashboard-filters.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,11 @@ appears in the filter bar above the widgets:
107107
- `allowCustomRange` — offer a "Custom…" item that opens a from/to calendar
108108
(default `true`).
109109

110+
`dateRange` is the `@objectstack/spec` `DashboardSchema.dateRange` shape, taken
111+
by reference on both the validator and the TypeScript type: a preset name
112+
outside the list above, or any other key inside the object (`preset`, `range`,
113+
`dateField`, …), is refused at validation rather than silently ignored.
114+
110115
Presets stay **symbolic** until query time: they compile to date-macro tokens
111116
(`{30_days_ago}`, `{current_month_start}`, …) that each widget resolves
112117
exactly like hand-authored widget filters — so a dashboard saved today still
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#10334 (objectui#7759 group F residue) — `DashboardComponentSchema.dateRange`
11+
* takes the spec's AUTHORING member on both faces, by reference.
12+
*
13+
* Before: the Zod mirror restated the element as a stripping `z.object` whose
14+
* `defaultRange` was a bare `z.string()`, while the TypeScript twin bound
15+
* `defaultRange` to the spec's `DateRangeDefaultRange`. The mirror therefore
16+
* admitted preset names `tsc` refused (a typo such as `last_7_dayz` parsed green
17+
* and then silently resolved to no default window) — the `WiderThanDeclared`
18+
* row `complex.zod.ts#DashboardComponentSchema::dateRange`.
19+
*
20+
* `@objectstack/spec` declares the key (`DashboardSchema.dateRange`, a strict
21+
* object over `DATE_RANGE_DEFAULT_RANGES`), so by objectui#7759 rule 1 and
22+
* proposal F1 both faces point at that authoring shape: `dateRange` left
23+
* `DASHBOARD_SPEC_EXCLUDED` and flows through the same projection as every other
24+
* spec-owned dashboard key.
25+
*
26+
* The type half is enforced by `tsconfig.test.json` (chained from this package's
27+
* `type-check`); the runtime half asserts the mirror gives the spec's verdict and
28+
* the spec's issue envelope (`code` + `path`), and authors no default.
29+
*/
30+
31+
import { describe, it, expect } from 'vitest';
32+
import type { z } from 'zod';
33+
import { DashboardSchema as SpecDashboardSchema, DATE_RANGE_DEFAULT_RANGES } from '@objectstack/spec/ui';
34+
import type { Dashboard } from '@objectstack/spec/ui';
35+
import type { DashboardComponentSchema } from '../complex';
36+
import { DashboardComponentSchema as DashboardMirror, DASHBOARD_SPEC_EXCLUDED } from '../zod/complex.zod';
37+
38+
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
39+
40+
const dashboard = (dateRange: unknown) => ({ type: 'dashboard', widgets: [], dateRange });
41+
42+
describe('dateRange: both faces are the spec authoring member (objectui#10334)', () => {
43+
it('the TypeScript twin and the mirror input equal the spec document member', () => {
44+
const twin: Equal<DashboardComponentSchema['dateRange'], Dashboard['dateRange']> = true;
45+
const mirror: Equal<z.input<typeof DashboardMirror>['dateRange'], Dashboard['dateRange']> = true;
46+
// Control: the bare-string element the mirror used to carry is NOT the spec member.
47+
const control: Equal<
48+
{ field?: string; defaultRange?: string; allowCustomRange?: boolean } | undefined,
49+
Dashboard['dateRange']
50+
> = false;
51+
expect([twin, mirror, control]).toEqual([true, true, false]);
52+
});
53+
54+
it('is no longer on the exclusion list both faces read', () => {
55+
expect(DASHBOARD_SPEC_EXCLUDED as readonly string[]).not.toContain('dateRange');
56+
});
57+
});
58+
59+
describe('dateRange: the mirror gives the spec verdict and envelope', () => {
60+
it('refuses a preset name outside DATE_RANGE_DEFAULT_RANGES at dateRange.defaultRange', () => {
61+
const r = DashboardMirror.safeParse(dashboard({ defaultRange: 'last_7_dayz' }));
62+
expect(r.success).toBe(false);
63+
expect(r.error?.issues.map((i) => ({ code: i.code, path: i.path }))).toEqual([
64+
{ code: 'invalid_value', path: ['dateRange', 'defaultRange'] },
65+
]);
66+
});
67+
68+
it('refuses an unknown key inside dateRange instead of stripping it', () => {
69+
const r = DashboardMirror.safeParse(dashboard({ field: 'created_at', bogus: 1 }));
70+
expect(r.success).toBe(false);
71+
expect(r.error?.issues.map((i) => ({ code: i.code, path: i.path }))).toEqual([
72+
{ code: 'unrecognized_keys', path: ['dateRange'] },
73+
]);
74+
});
75+
76+
it('accepts every preset in DATE_RANGE_DEFAULT_RANGES, including the custom sentinel', () => {
77+
expect(DATE_RANGE_DEFAULT_RANGES).toContain('custom');
78+
for (const defaultRange of DATE_RANGE_DEFAULT_RANGES) {
79+
const r = DashboardMirror.safeParse(dashboard({ field: 'closed_at', defaultRange, allowCustomRange: false }));
80+
expect(r.success, defaultRange).toBe(true);
81+
}
82+
});
83+
84+
it('authors no default into the parsed document (stripImportedDefaults)', () => {
85+
const r = DashboardMirror.safeParse(dashboard({ field: 'created_at' }));
86+
expect(r.success).toBe(true);
87+
expect(r.success && r.data.dateRange).toEqual({ field: 'created_at' });
88+
});
89+
90+
it('agrees with the spec schema on every sample', () => {
91+
const specDateRange = SpecDashboardSchema.shape.dateRange;
92+
const samples: unknown[] = [
93+
{},
94+
{ field: 'created_at', defaultRange: 'last_30_days', allowCustomRange: true },
95+
{ defaultRange: 'custom' },
96+
{ defaultRange: 'last_7_dayz' },
97+
{ defaultRange: 30 },
98+
{ preset: 'today' },
99+
{ bogus: 1 },
100+
{ allowCustomRange: 'yes' },
101+
];
102+
for (const sample of samples) {
103+
expect(
104+
DashboardMirror.safeParse(dashboard(sample)).success,
105+
JSON.stringify(sample),
106+
).toBe(specDateRange.safeParse(sample).success);
107+
}
108+
});
109+
});

‎packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,8 @@ const CASES: Record<string, Case> = {
105105
Dashboard: {
106106
spec: SpecDashboardSchema,
107107
oui: OuiDashboardSchema,
108-
omitted: ['widgets', 'globalFilters', 'dateRange'],
108+
// `dateRange` left this list with objectui#10334: it now flows in by reference.
109+
omitted: ['widgets', 'globalFilters'],
109110
local: [],
110111
},
111112
};

‎packages/types/src/__tests__/zod-mirror-parity.test.ts‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -427,12 +427,17 @@
427427
* spelled "six" rots exactly as fast as one spelled `6`, it is just harder to
428428
* point a regex at. ⛔ Do not spell a live figure out again, and ⛔ do not
429429
* restate one without checking that the pin's spelling still reaches it.
430-
* - **7 entries** in `WiderThanDeclared`, **10 keys** across them, and **10 arms**
431-
* under those keys — split **5** SCHEMA-NODE, **5** CONCRETE, **0** MIXED, **0** unions.
432-
* It read 8 / 11 / 12 — 5 / 6 / 0 / 1 — until objectui#10387 RETIRED
430+
* - **7 entries** in `WiderThanDeclared`, **9 keys** across them, and **9 arms**
431+
* under those keys — split **5** SCHEMA-NODE, **4** CONCRETE, **0** MIXED, **0** unions.
432+
* It read 8 / 10 / 11 — 5 / 5 / 0 / 1 — until objectui#10387 RETIRED
433433
* `navigation.zod.ts#HeaderBarSchema::logo` on both faces: the `header-bar` renderer
434434
* reads no `logo`, so the entry, its one key and BOTH its arms left together. It was
435435
* the ledger's last two-arm key, which is why `unions` reached zero.
436+
* It read 8 / 11 / 12 — 5 / 6 / 0 / 1 — until objectui#10334 (objectui#7759 group F)
437+
* settled `complex.zod.ts#DashboardComponentSchema::dateRange` by rule 1: the spec
438+
* declares the key, so both faces now take the spec's authoring member by reference and
439+
* the mirror's bare-string `defaultRange` is gone. One key with one CONCRETE arm left an
440+
* entry that keeps two others, so `entries` did not move.
436441
* It read 11 / 16 / 18 — 5 / 11 / 0 / 2 — until objectui#10286 closed five CONCRETE
437442
* keys of objectui#7759's groups C and D: `complex.zod.ts#FilterFieldSchema::operators`
438443
* and `complex.zod.ts#FilterBuilderSchema::fields` (both faces now state the spec's
@@ -3132,9 +3137,10 @@ interface WiderThanDeclared {
31323137
// ruling beside the two runtime slots that remain.
31333138
/**
31343139
* CONCRETE. `header` and `globalFilters` carry the inline-locale widening one level
3135-
* down (a nested `label`) and `dateRange.defaultRange` is a bare string on the
3136-
* mirror against a closed literal set on the declaration. All three also carry a
3137-
* `KnownDrift` entry.
3140+
* down (a nested `label`). Both also carry a `KnownDrift` entry. `dateRange` was a
3141+
* third key until objectui#10334 — its `defaultRange` was a bare string on the mirror
3142+
* against the spec's closed preset set on the declaration; both faces now take the
3143+
* spec's authoring member by reference, so the pair measures clean on that key.
31383144
*
31393145
* ⚠️ `widgets` was the fourth key and made this entry MIXED — the only MIXED key
31403146
* this ledger has held. Its verdict read "`widgets` is SCHEMA-NODE" until
@@ -3147,7 +3153,7 @@ interface WiderThanDeclared {
31473153
* `WIDER_ARMS` below, which names one per arm; this docblock is the prose beside it
31483154
* and ⛔ may not be the only place a split is recorded again.
31493155
*/
3150-
'complex.zod.ts#DashboardComponentSchema': 'header' | 'globalFilters' | 'dateRange';
3156+
'complex.zod.ts#DashboardComponentSchema': 'header' | 'globalFilters';
31513157
// `complex.zod.ts#FilterBuilderSchema` (`fields`) and `complex.zod.ts#FilterFieldSchema`
31523158
// (`operators`) LEFT under objectui#10286: the second was the operator vocabulary, each
31533159
// face refusing a spelling the other accepted, and the first inherited it through the
@@ -3345,7 +3351,6 @@ const WIDER_ARMS: Readonly< Record< string, readonly WiderArmClass[] > > = {
33453351
'app.zod.ts#AppComponentSchema::areas': ['SCHEMA-NODE'],
33463352
'complex.zod.ts#DashboardComponentSchema::header': ['CONCRETE'],
33473353
'complex.zod.ts#DashboardComponentSchema::globalFilters': ['CONCRETE'],
3348-
'complex.zod.ts#DashboardComponentSchema::dateRange': ['CONCRETE'],
33493354
'form.zod.ts#FormSchema::layout': ['CONCRETE'],
33503355
'layout.zod.ts#PageNodeSchema::slots': ['SCHEMA-NODE'],
33513356
'views.zod.ts#DetailViewFieldSchema::options': ['CONCRETE'],

‎packages/types/src/complex.ts‎

Lines changed: 10 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717

1818
import type {
1919
DashboardWidget as SpecDashboardWidget,
20-
DateRangeDefaultRange as SpecDateRangeDefaultRange,
2120
GlobalFilter as SpecGlobalFilter,
2221
Dashboard as SpecDashboard,
2322
ViewFilterOperator as SpecViewFilterOperator,
@@ -2331,31 +2330,16 @@ export interface DashboardComponentSchema extends BaseSchema, Omit<SpecDashboard
23312330
* value vocabularies now track the protocol instead of a snapshot of it.
23322331
*/
23332332
globalFilters?: SpecGlobalFilter[];
2334-
/**
2335-
* Date range filter configuration.
2336-
* Aligned with @objectstack/spec DashboardSchema.dateRange.
2337-
*
2338-
* `defaultRange` is BOUND to the spec's `DateRangeDefaultRange` rather than
2339-
* restated (objectui#4984). It used to be a hand-written 14-member union —
2340-
* byte-faithful to the spec, but faithful only until the next spec release:
2341-
* a preset the spec ADDS would be a legal document that objectui's own types
2342-
* say cannot exist, the same "narrower than the contract it implements" shape
2343-
* as objectui#4163's `label`, whose consequence was that the bad reads were
2344-
* invisible to `tsc`. No gate could report the drift either — `check:spec-symbols`
2345-
* rule 1 matches by NAME and an inline union on an interface member has no
2346-
* symbol to collide with, while rule 2's claim heuristic was waved through by
2347-
* the `SpecGlobalFilter` reference a few lines up. Binding makes the "Aligned
2348-
* with" line above structural instead of prose.
2349-
*
2350-
* `DATE_RANGE_DEFAULT_RANGES` is `[...DATE_RANGE_PRESETS, 'custom']`, so this
2351-
* tracks the same vocabulary `@object-ui/core` re-exports by reference
2352-
* (objectui#4167) — one list, reached two ways.
2353-
*/
2354-
dateRange?: {
2355-
field?: string;
2356-
defaultRange?: SpecDateRangeDefaultRange;
2357-
allowCustomRange?: boolean;
2358-
};
2333+
// `dateRange` was DECLARED here until objectui#10334 — a hand-written
2334+
// `{ field?, defaultRange?, allowCustomRange? }` whose `defaultRange` was bound
2335+
// to the spec's `DateRangeDefaultRange` (objectui#4984) while its Zod twin
2336+
// restated the key as a bare `z.string()`, so the validator admitted preset
2337+
// names this type refused (the `WiderThanDeclared` row objectui#7759 group F
2338+
// left behind). The key is spec-declared, so both faces now take the spec's
2339+
// AUTHORING member by reference: `dateRange` is no longer on
2340+
// `DASHBOARD_SPEC_EXCLUDED`, and this interface inherits it from the
2341+
// `Omit< Dashboard, … >` projection above. Pinned by
2342+
// `__tests__/dashboard-daterange-spec-10334.test.ts`.
23592343
// `aria` was DECLARED here until objectui#5830, under a comment claiming
23602344
// alignment with @objectstack/spec AriaPropsSchema — by then the opposite of
23612345
// the contract: the spec removed `dashboard.aria` at the #3896 audit

‎packages/types/src/zod/complex.zod.ts‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1180,11 +1180,23 @@ export const GlobalFilterSchema = z.object({
11801180
*
11811181
* Omitted, each for a stated reason:
11821182
* - `name`/`label`/`description` — component-envelope keys owned by BaseSchema;
1183-
* - `widgets`/`globalFilters`/`dateRange` — objectui's element schemas are
1184-
* their own ledger entries (the local widget still carries the legacy
1185-
* `component` envelope the spec has no room for, and both local configs are
1183+
* - `widgets`/`globalFilters` — objectui's element schemas are their own
1184+
* ledger entries (the local widget still carries the legacy `component`
1185+
* envelope the spec has no room for, and the local filter config is
11861186
* deliberately looser than spec's); migration deferred.
11871187
*
1188+
* `dateRange` was a third member of that list until objectui#10334. Its local
1189+
* element (`defaultRange` a bare `z.string()`, a stripping object) admitted
1190+
* preset names the spec's enum and the TypeScript twin both refuse — the
1191+
* `WiderThanDeclared` row objectui#7759 group F left behind. The spec declares
1192+
* the key, so by that card's rule 1 / proposal F1 both faces now take the
1193+
* spec's AUTHORING shape by reference through this projection: the closed
1194+
* `DATE_RANGE_DEFAULT_RANGES` vocabulary, the spec's strict object with its
1195+
* named alias refusals, and — via `stripImportedDefaults` — no authored
1196+
* default. The read site (`resolveDashboardFilterDefs` in `@object-ui/core`)
1197+
* already implements every arm: each preset, the `custom` sentinel, `field`
1198+
* and `allowCustomRange`.
1199+
*
11881200
* `.partial()` guarantees no *future* spec field can become required and
11891201
* silently invalidate stored objectui dashboards.
11901202
*/
@@ -1194,7 +1206,6 @@ export const DASHBOARD_SPEC_EXCLUDED = [
11941206
'description',
11951207
'widgets',
11961208
'globalFilters',
1197-
'dateRange',
11981209
] as const;
11991210

12001211
// One list, two readers (objectui#9736): this call and the `DashboardComponentSchema`
@@ -1220,11 +1231,6 @@ export const DashboardComponentSchema = BaseSchema.extend(SpecDashboardFields.sh
12201231
widgets: z.array(z.union([DashboardWidgetSlotComponentSchema, DashboardWidgetSchema]))
12211232
.describe('Dashboard widgets'),
12221233
globalFilters: z.array(GlobalFilterSchema).optional().describe('Dashboard-level filters'),
1223-
dateRange: z.object({
1224-
field: z.string().optional(),
1225-
defaultRange: z.string().optional(),
1226-
allowCustomRange: z.boolean().optional(),
1227-
}).optional().describe('Built-in date range filter'),
12281234
body: retirementTombstone(
12291235
'REFUSED (objectui#9256, ADR-0049) — `dashboard` reads NEITHER content channel: measured with the '
12301236
+ 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or '

0 commit comments

Comments
 (0)