diff --git a/.changeset/7759-dashboard-header-global-filters-spec.md b/.changeset/7759-dashboard-header-global-filters-spec.md new file mode 100644 index 0000000000..847bf8e1fa --- /dev/null +++ b/.changeset/7759-dashboard-header-global-filters-spec.md @@ -0,0 +1,52 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-dashboard': patch +--- + +`DashboardComponentSchema.header` and `.globalFilters` now state +`@objectstack/spec`'s `DashboardSchema` member on both faces, the TypeScript +interface and the Zod validator (objectui#7759 group A). Both keys are declared +by the spec, and each face used to refuse something the other accepted. + +**`header` (TypeScript interface).** The hand-written restatement is gone. The +interface inherits the spec's `DashboardHeader` through the same +`Omit` projection of the spec `Dashboard` type as its other spec-owned keys. Breaking for code +that builds header actions in TypeScript: + +- `actions[].label` is `I18nLabel`: a string or an inline per-locale map. It used + to be `string`. +- `actions[].actionUrl` is required. It used to be optional. +- `actions[].actionType` is the spec's `ActionType` (`url`, `script`, `modal`, + `flow`, `api` or `form`). It used to be any string. + +The validator is unchanged here: it always took the spec's `header`. + +**`globalFilters` (Zod validator).** `GlobalFilterSchema` is now the spec's +`GlobalFilterSchema` by reference, and its local `options` / `optionsFrom` +restatement is gone. Breaking for documents that relied on the looser validator: + +- the bare-string option shorthand (`options: ['EMEA']`) is refused with + `invalid_type` at `globalFilters.N.options.M`. Write + `{ "value": "EMEA", "label": "EMEA" }`; +- an option with no `label` is refused at `globalFilters.N.options.M.label`; +- an `optionsFrom` with no `labelField` is refused with `invalid_type` at + `globalFilters.N.optionsFrom.labelField`. Name the value field again if the + options have no separate label column; +- an `optionsFrom.filter` must be a filter-condition object. An array is + refused; +- an unknown key in a filter is refused with `unrecognized_keys` at + `globalFilters.N`. It used to be removed without a message. + +Widening: an option `label` may now be an inline per-locale map, which the spec +has always allowed and the filter bar already resolves. The TypeScript type is +unchanged, because it already bound the spec's `GlobalFilter`. The runtime is +unchanged too: `@object-ui/core` still converts a STORED bare-string option into +a pair when it reads the document, and logs a deprecation warning. That +conversion keeps its own retirement schedule (objectstack#7917). + +**`@object-ui/plugin-dashboard`.** Before this change, a header action whose +`label` was an inline per-locale map crashed the renderer: React reported +`Objects are not valid as a React child`. The mirror accepted that document. +`DashboardRenderer` now shows the label in the active language, both as the +button text and as the fallback for the +`dashboards.NAME.actions.KEY.label` bundle lookup. diff --git a/content/docs/guide/dashboard-filters.md b/content/docs/guide/dashboard-filters.md index b014e3cbb5..00c5a43362 100644 --- a/content/docs/guide/dashboard-filters.md +++ b/content/docs/guide/dashboard-filters.md @@ -173,9 +173,12 @@ deliberately not compared as-is: `field = "last_7_dayz"` matches no row, and the widget would render a perfectly healthy-looking `0`. Static `options` are `@objectstack/spec` object pairs — -`{ "value": "amer", "label": "AMER" }`. This is the only form the platform -accepts: a dashboard is validated against `GlobalFilterSchema` when it is -published, and anything else is refused there. +`{ "value": "amer", "label": "AMER" }`, where `label` is a string or an inline +per-locale map (`{ "en": "AMER", "zh-CN": "美洲" }`). This is the only form the +platform accepts: a dashboard is validated against `GlobalFilterSchema` when it is +published, and anything else is refused there. objectui's own validator +(`@object-ui/types`, which `objectui validate` runs) uses the same spec schema, so +it refuses the same documents before they reach the platform. > **Deprecated: the bare-string shorthand.** `"options": ["EMEA", "APAC"]` is > still lifted by the runtime to `{ "value": "EMEA", "label": "EMEA" }` pairs so @@ -202,6 +205,11 @@ Options can also be fetched from an object at runtime: } ``` +`object`, `valueField` and `labelField` are all required; when the options have +no separate label column, name the value field again, as above. An optional +`filter` narrows the source records and is a filter-condition object +(`{ "status": "active" }`), not an array. + With a dataset-capable data source, `optionsFrom` resolves distinct values **server-side** (a GROUP BY over the source object), so the option list is complete regardless of row count. Data sources without dataset queries fall diff --git a/content/docs/plugins/plugin-dashboard.mdx b/content/docs/plugins/plugin-dashboard.mdx index 56d2df2159..4f425b76e3 100644 --- a/content/docs/plugins/plugin-dashboard.mdx +++ b/content/docs/plugins/plugin-dashboard.mdx @@ -43,7 +43,12 @@ npm install @object-ui/plugin-dashboard header?: { // Header block — strict: exactly these keys showTitle?: boolean, showDescription?: boolean, - actions?: { label, actionUrl?, actionType?, icon? }[] + actions?: { + label: string | LocaleMap, // button text; a map resolves to the active language + actionUrl: string, // required: the URL, or the target the action runner dispatches + actionType?: 'url' | 'script' | 'modal' | 'flow' | 'api' | 'form', + icon?: string // Lucide icon name + }[] }, globalFilters?: GlobalFilter[], // Dashboard-level filter bar — see "Dashboard-level filters" dateRange?: { // Built-in date-range filter — see "Dashboard-level filters" diff --git a/packages/plugin-dashboard/README.md b/packages/plugin-dashboard/README.md index 86bfb67712..c19def8107 100644 --- a/packages/plugin-dashboard/README.md +++ b/packages/plugin-dashboard/README.md @@ -387,14 +387,16 @@ into each bound widget's inline query (`AND`-combined with the widget's own // optional "object": the object `field` lives on — its fields.. / fieldOptions... bundle entries then win, "label" is the fallback "type": "select", // text | select | date | number | lookup // Canonical @objectstack/spec pair form — the only form the platform - // accepts at publish. The bare-string shorthand (["EMEA", …]) is - // deprecated: still lifted at runtime, now warns (objectui#4356). + // accepts at publish, and the only form `@object-ui/types` validates + // (objectui#7759). The bare-string shorthand (["EMEA", …]) is + // deprecated: a STORED one is still lifted at runtime, and warns (objectui#4356). "options": [ { "value": "EMEA", "label": "EMEA" }, { "value": "APAC", "label": "APAC" }, { "value": "AMER", "label": "AMER" } ] - // or dynamic: "optionsFrom": { "object": "accounts", "valueField": "region" } + // or dynamic: "optionsFrom": { "object": "accounts", "valueField": "region", "labelField": "region" } + // (`labelField` is required by the spec, even when it names the value field) } ], "widgets": [ diff --git a/packages/plugin-dashboard/src/DashboardRenderer.tsx b/packages/plugin-dashboard/src/DashboardRenderer.tsx index 6cfd60627e..07e2ea9502 100644 --- a/packages/plugin-dashboard/src/DashboardRenderer.tsx +++ b/packages/plugin-dashboard/src/DashboardRenderer.tsx @@ -47,6 +47,15 @@ import { DatasetWidget } from './DatasetWidget'; import { useWidgetSubCaption } from './widgetSubCaption'; import { DashboardFilterBar } from './DashboardFilterBar'; +/** + * One `header.actions[]` entry, as the node's declaration types it: the spec's + * `DashboardHeaderAction` by reference since objectui#7759, so `label` is an + * `I18nLabel` and `actionUrl` is required. Named for the node, not the spec + * type, because it is read off `DashboardComponentSchema` and a local alias may + * not wear a spec export's name (`check:spec-symbols`). + */ +type DashboardNodeHeaderAction = NonNullable['actions']>[number]; + interface SortableWidgetWrapperProps { id: string; disabled?: boolean; @@ -273,19 +282,6 @@ const DashboardRendererInner = forwardRef(() => { - const actions = schema.header?.actions ?? []; - return actions.map((a: { label: string; actionUrl?: string; actionType?: string; icon?: string }) => ({ - name: a.actionUrl || a.label, - type: (a.actionType as ActionDef['type']) || 'url', - target: a.actionUrl, - label: a.label, - })); - }, [schema.header?.actions]); - - const { executeAction, engine } = useActionEngine({ actions: headerActionDefs }); - // ── i18n: convention-based label resolution for dashboard / widget / // action text. The dashboard name (`schema.name`) keys all lookups; when // it's missing we silently degrade to the raw English fallbacks. @@ -340,18 +336,44 @@ const DashboardRendererInner = forwardRef { - if (!dashName) return action.label; - const key = action.actionUrl || action.label; - return dashboardActionLabel(dashName, key, action.label); + (action: DashboardNodeHeaderAction): string => { + const authored = pickLocalized(action.label, language) || ''; + if (!dashName) return authored; + const key = action.actionUrl || authored; + return dashboardActionLabel(dashName, key, authored); }, - [dashName, dashboardActionLabel], + [dashName, dashboardActionLabel, language], ); + // Build ActionDef[] from header actions so useActionEngine can dispatch by + // name. `ActionDef.label` is a `string`, so the def carries the label + // resolved against the active language, exactly as the button shows it. + // Keyed on `language`, not on a memoised resolver's identity (AGENTS.md #10). + const headerActionDefs = useMemo(() => { + const actions = schema.header?.actions ?? []; + return actions.map((a) => { + const label = pickLocalized(a.label, language) || undefined; + return { + name: a.actionUrl || label, + type: (a.actionType as ActionDef['type']) || 'url', + target: a.actionUrl, + label, + }; + }); + }, [schema.header?.actions, language]); + + const { executeAction, engine } = useActionEngine({ actions: headerActionDefs }); + /** * Translate a widget title / description using the * `{ns}.dashboards.{dashName}.widgets.{widgetId}.title|description` @@ -1067,10 +1089,10 @@ const DashboardRendererInner = forwardRef 0 && (
- {headerActions.map((action: { label: string; actionUrl?: string; actionType?: string; icon?: string }, i: number) => { + {headerActions.map((action, i) => { const Icon = resolveLucideIcon(action.icon); const handleClick = async () => { - const { actionType, actionUrl, label } = action; + const { actionType, actionUrl } = action; if (!actionType || !actionUrl) { console.warn('[DashboardRenderer] Header action missing actionType/actionUrl:', action); return; @@ -1092,7 +1114,9 @@ const DashboardRendererInner = forwardRef { }); }); +/** + * A header action's `label` is the spec's `I18nLabel` — a plain string OR an + * inline per-locale map (`DashboardHeaderActionSchema.label`), and the zod + * mirror has always admitted the map because it takes the spec's `header` by + * reference. The TypeScript twin restated it as `string` until objectui#7759 + * group A, so this read site handed the authored value straight to React: an + * unnamed dashboard rendered the map as a button child, a named one passed it + * as the bundle lookup's `fallback`. Both render the resolved string now. + */ +function localizedActionDashboard(name?: string): DashboardComponentSchema { + return { + type: 'dashboard', + ...(name ? { name } : {}), + label: 'Ops', + widgets: [], + header: { + actions: [{ + label: { en: 'Convert Lead', 'zh-CN': '转换线索' }, + actionUrl: 'convert_lead_wizard', + actionType: 'flow', + }], + }, + } satisfies DashboardComponentSchema; +} + +describe('DashboardRenderer header actions — inline per-locale label (objectui#7759)', () => { + it.each([ + ['an unnamed dashboard', undefined], + ['a named dashboard with no bundle entry', 'ops_board'], + ])('renders the active-language string for %s, and dispatches by actionUrl', async (_what, name) => { + const handler = vi.fn().mockResolvedValue({ success: true }); + render( + + + + + , + ); + + const button = screen.getByRole('button', { name: '转换线索' }); + expect(button.textContent).not.toContain('[object Object]'); + await userEvent.setup().click(button); + await waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); + expect(handler.mock.calls[0][0]).toMatchObject({ type: 'flow', target: 'convert_lead_wizard' }); + }); +}); + /** * A dashboard authored the way the flagship exemplar authors them: `header` * declared with both text flags ON, because a standalone embed genuinely does diff --git a/packages/types/src/__tests__/dashboard-header-global-filters-spec-7759.test.ts b/packages/types/src/__tests__/dashboard-header-global-filters-spec-7759.test.ts new file mode 100644 index 0000000000..1a28263bf0 --- /dev/null +++ b/packages/types/src/__tests__/dashboard-header-global-filters-spec-7759.test.ts @@ -0,0 +1,127 @@ +/** + * 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#7759 group A — `DashboardComponentSchema.header` and `.globalFilters` + * state the spec's member on BOTH faces. + * + * Both keys are declared by `@objectstack/spec`'s `DashboardSchema`, so the card's + * ruling (5617465269, principle 1) governs: the objectui zod mirror and the + * TypeScript declaration both follow the spec, in both directions. Before this + * change each key was DISJOINT — ledgered in `KnownDrift` AND `WiderThanDeclared` + * in `zod-mirror-parity.test.ts`: + * + * - `header` — the mirror took the spec's member by reference, the declaration + * was a hand-written restatement. An inline per-locale action `label` parsed + * green and `tsc` refused it (and `DashboardRenderer` handed the map to React); + * an action with no `actionUrl`, or an `actionType` outside the spec's enum, + * compiled and the mirror refused it. ⇒ the declaration now inherits the + * spec's `header` through the `Omit< Dashboard, … >` projection. + * - `globalFilters` — the declaration bound the spec's `GlobalFilter`, the + * mirror's `GlobalFilterSchema` restated `options` / `optionsFrom`. The + * bare-string option shorthand, a label-less option, an `optionsFrom` with no + * `labelField` (and an array `filter`, and an unknown key, stripped in + * silence) parsed green where the spec refuses them; an inline per-locale + * option label was refused where the spec admits it. ⇒ the mirror's + * `GlobalFilterSchema` IS the spec's schema now. + * + * The type rows are enforced by `tsconfig.test.json` (chained from this + * package's `type-check`); the parse legs assert the mirror's verdict and its + * issue envelope (`code` + `path`) on the same literals. + */ + +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; +import { GlobalFilterSchema as SpecGlobalFilterSchema } from '@objectstack/spec/ui'; +import type { Dashboard, GlobalFilter } from '@objectstack/spec/ui'; +import type { DashboardComponentSchema } from '../complex'; +import { DashboardComponentSchema as DashboardMirror, GlobalFilterSchema } from '../zod/complex.zod'; + +type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; + +type DeclaredHeader = DashboardComponentSchema['header']; +type DeclaredFilter = NonNullable[number]; + +const dashboard = (extra: Record) => ({ type: 'dashboard', widgets: [], ...extra }); + +/** The mirror's verdict, and the (code, path) of each issue when it refuses. */ +function verdict(doc: unknown): true | Array<[string, string]> { + const r = DashboardMirror.safeParse(doc); + return r.success ? true : r.error.issues.map((i) => [i.code, i.path.join('.')] as [string, string]); +} + +describe('header: both faces are the spec member (objectui#7759)', () => { + it('the declaration and the mirror input both equal the spec `Dashboard[\'header\']`', () => { + const declared: Equal = true; + const mirror: Equal['header'], Dashboard['header']> = true; + // Control: the hand-written restatement the declaration used to carry is NOT the spec member. + const control: Equal< + { showTitle?: boolean; showDescription?: boolean; actions?: Array<{ label: string; actionUrl?: string; actionType?: string; icon?: string }> } | undefined, + Dashboard['header'] + > = false; + expect([declared, mirror, control]).toEqual([true, true, false]); + }); + + it('an inline per-locale action label is accepted by both faces', () => { + const header: DeclaredHeader = { actions: [{ label: { en: 'Convert', 'zh-CN': '转换' }, actionUrl: 'convert_flow', actionType: 'flow' }] }; + expect(verdict(dashboard({ header }))).toBe(true); + }); + + it('an action with no `actionUrl`, or an `actionType` outside the spec enum, is refused by both faces', () => { + // @ts-expect-error — `actionUrl` is required by the spec's header action. + const noUrl: DeclaredHeader = { actions: [{ label: 'Convert' }] }; + // @ts-expect-error — `refresh` is not a member of the spec's `ActionType`. + const badType: DeclaredHeader = { actions: [{ label: 'Refresh', actionUrl: 'x', actionType: 'refresh' }] }; + expect(verdict(dashboard({ header: noUrl }))).toEqual([['invalid_type', 'header.actions.0.actionUrl']]); + expect(verdict(dashboard({ header: badType }))).toEqual([['invalid_value', 'header.actions.0.actionType']]); + }); +}); + +describe('globalFilters: both faces are the spec element (objectui#7759)', () => { + it('the declared element, the mirror element and the local `GlobalFilterSchema` all equal the spec `GlobalFilter`', () => { + const declared: Equal = true; + const mirror: Equal['globalFilters']>[number], GlobalFilter> = true; + const local: Equal, z.input> = true; + expect([declared, mirror, local]).toEqual([true, true, true]); + }); + + it('an inline per-locale option label — refused by the mirror until now — is accepted by both faces', () => { + const filter: DeclaredFilter = { field: 'region', type: 'select', options: [{ value: 'emea', label: { en: 'EMEA', 'zh-CN': '欧洲' } }] }; + expect(verdict(dashboard({ globalFilters: [filter] }))).toBe(true); + }); + + it('the bare-string shorthand, a label-less option and a `labelField`-less `optionsFrom` are refused by both faces', () => { + // @ts-expect-error — the spec's options are `{ value, label }` pairs, never bare strings. + const shorthand: DeclaredFilter = { field: 'region', options: ['EMEA'] }; + // @ts-expect-error — the spec requires an option `label`. + const labelless: DeclaredFilter = { field: 'region', options: [{ value: 'emea' }] }; + // @ts-expect-error — the spec requires `optionsFrom.labelField`. + const noLabelField: DeclaredFilter = { field: 'owner', optionsFrom: { object: 'users', valueField: 'id' } }; + expect(verdict(dashboard({ globalFilters: [shorthand] }))).toEqual([['invalid_type', 'globalFilters.0.options.0']]); + expect(verdict(dashboard({ globalFilters: [labelless] }))).toEqual([['invalid_union', 'globalFilters.0.options.0.label']]); + expect(verdict(dashboard({ globalFilters: [noLabelField] }))).toEqual([['invalid_type', 'globalFilters.0.optionsFrom.labelField']]); + }); + + it('an unknown key in a filter is refused by name instead of being stripped in silence', () => { + expect(verdict(dashboard({ globalFilters: [{ field: 'region', bogus: 1 }] }))).toEqual([['unrecognized_keys', 'globalFilters.0']]); + }); + + it('authors no `scope` default — the imported default is stripped at the crossing', () => { + const r = DashboardMirror.safeParse(dashboard({ globalFilters: [{ field: 'region' }] })); + expect(r.success).toBe(true); + expect((r as { data: { globalFilters: unknown[] } }).data.globalFilters).toEqual([{ field: 'region' }]); + }); +}); + +describe('control: a document both faces accepted before and after', () => { + it('a plain-string action label and a labelled option pair still pass', () => { + const header: DeclaredHeader = { showTitle: true, actions: [{ label: 'Open', actionUrl: '/reports', actionType: 'url' }] }; + const filter: DeclaredFilter = { field: 'region', type: 'select', options: [{ value: 'emea', label: 'EMEA' }] }; + expect(verdict(dashboard({ header, globalFilters: [filter] }))).toBe(true); + }); +}); diff --git a/packages/types/src/__tests__/p1-spec-alignment.test.ts b/packages/types/src/__tests__/p1-spec-alignment.test.ts index 86cdf59f8c..b4c41d94c8 100644 --- a/packages/types/src/__tests__/p1-spec-alignment.test.ts +++ b/packages/types/src/__tests__/p1-spec-alignment.test.ts @@ -447,6 +447,12 @@ describe('P1.3 Dashboard Spec Alignment', () => { }); it('should accept DashboardHeader with actions', () => { + // `header` is the spec's `DashboardHeader` by reference (objectui#7759), so + // `actionUrl` is required, `actionType` is the spec's `ActionType` enum and + // `label` is an `I18nLabel`. The first action here used to read + // `{ label: 'Refresh', actionType: 'refresh' }` — legal only under the + // hand-written restatement this file pinned, refused by the spec and by the + // zod mirror alike (no `actionUrl`, and `refresh` is not an action type). const dashboard: DashboardComponentSchema = { type: 'dashboard', widgets: [], @@ -454,13 +460,16 @@ describe('P1.3 Dashboard Spec Alignment', () => { showTitle: true, showDescription: false, actions: [ - { label: 'Refresh', actionType: 'refresh', icon: 'RefreshCw' }, + { label: { en: 'Run forecast', 'zh-CN': '运行预测' }, actionUrl: 'run_forecast', actionType: 'flow', icon: 'Play' }, { label: 'Export', actionUrl: '/api/export', icon: 'Download' }, ], }, }; expect(dashboard.header?.showTitle).toBe(true); expect(dashboard.header?.actions).toHaveLength(2); + // @ts-expect-error — `refresh` is not a member of the spec's `ActionType`. + const refused: DashboardComponentSchema = { type: 'dashboard', widgets: [], header: { actions: [{ label: 'Refresh', actionUrl: 'x', actionType: 'refresh' }] } }; + expect(refused.type).toBe('dashboard'); }); // `should accept widget ARIA properties` REMOVED: `dashboard.widgets[].aria` diff --git a/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts b/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts index cc31b95f5c..1cbfdf329b 100644 --- a/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts +++ b/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts @@ -164,24 +164,41 @@ describe('GlobalFilterSchema derives from the spec', () => { }); }); -describe('GlobalFilterSchema pinned divergences', () => { - it('accepts the bare-string options shorthand the runtime normalizes', () => { - // `normalizeFilterOptions` (@object-ui/core dashboard-filters.ts) folds both - // spellings into the spec's `{ value, label }` form. - expect(GlobalFilterSchema.safeParse({ field: 'region', options: ['EMEA', 'APAC'] }).success).toBe(true); - expect(GlobalFilterSchema.safeParse({ field: 'region', options: [{ value: 'emea' }] }).success).toBe(true); - // The spec requires objects WITH a label. If it ever relaxes, drop the override. - expect(SpecGlobalFilterSchema.safeParse({ field: 'region', options: ['EMEA'] }).success).toBe(false); - expect(SpecGlobalFilterSchema.safeParse({ field: 'region', options: [{ value: 'emea' }] }).success).toBe(false); - }); - - it('keeps `optionsFrom.labelField` optional', () => { - expect(GlobalFilterSchema.safeParse({ - field: 'owner', optionsFrom: { object: 'users', valueField: 'id' }, - }).success).toBe(true); - expect(SpecGlobalFilterSchema.safeParse({ - field: 'owner', optionsFrom: { object: 'users', valueField: 'id' }, - }).success).toBe(false); +/** + * The two pinned divergences this block used to assert are RETIRED (objectui#7759 + * group A). `GlobalFilterSchema` restated `options` (bare-string shorthand, an + * optional `label`, a `z.string()` label) and `optionsFrom` (optional + * `labelField`, `filter: z.any()`) over the spec's shape, asserting each "in both + * directions so it dies the day the spec adopts it". The spec did not move; the + * ruling did — ruling 5617465269 principle 1: a key the spec declares is stated + * by the spec on both faces. So the local schema IS the spec's now, and what is + * pinned is that verdict, on the same literals, from both schemas. + * + * ⚠️ Retired at the SCHEMA only. `normalizeFilterOptions` (`@object-ui/core`'s + * `dashboard-filters.ts`) still lifts a stored bare-string option on read, with + * its deprecation warning, on the objectstack#7917 window — the objectui#4165 + * split: the schema refuses, the read path lifts. + */ +describe('GlobalFilterSchema: the local divergences are retired (objectui#7759)', () => { + const both = (doc: unknown) => [GlobalFilterSchema.safeParse(doc).success, SpecGlobalFilterSchema.safeParse(doc).success]; + + it('refuses the bare-string options shorthand and a label-less option, as the spec does', () => { + expect(both({ field: 'region', options: ['EMEA', 'APAC'] })).toEqual([false, false]); + expect(both({ field: 'region', options: [{ value: 'emea' }] })).toEqual([false, false]); + }); + + it('requires `optionsFrom.labelField`, and takes the spec\'s `FilterCondition` for `filter`', () => { + expect(both({ field: 'owner', optionsFrom: { object: 'users', valueField: 'id' } })).toEqual([false, false]); + expect(both({ + field: 'owner', optionsFrom: { object: 'users', valueField: 'id', labelField: 'name', filter: [['active', '=', true]] }, + })).toEqual([false, false]); + expect(both({ + field: 'owner', optionsFrom: { object: 'users', valueField: 'id', labelField: 'name', filter: { active: true } }, + })).toEqual([true, true]); + }); + + it('admits the inline per-locale option label it used to refuse', () => { + expect(both({ field: 'region', options: [{ value: 'emea', label: { en: 'EMEA', 'zh-CN': '欧洲' } }] })).toEqual([true, true]); }); }); @@ -291,14 +308,19 @@ describe('GlobalFilterSchema carries the spec rc.6 date refinement (objectui#416 expect(GlobalFilterSchema.safeParse({ field: 'amount', type: 'number', defaultValue: 42 }).success).toBe(true); }); - it('still applies the two surviving divergences while carrying the refinement', () => { - // The composition has to do BOTH. A version that adopted the refinement by - // simply deriving from the spec would silently take the options divergence - // away with it. - expect(GlobalFilterSchema.safeParse({ - field: 'created_at', type: 'date', defaultValue: 'last_7_days', options: ['EMEA'], - optionsFrom: { object: 'users', valueField: 'id' }, - }).success).toBe(true); + it('carries the refinement with the spec schema itself, now that no divergence is composed over it', () => { + // This case used to pin the COMPOSITION — the local `options` / `optionsFrom` + // overrides applied alongside a delegated refinement — with a document that + // carried the bare-string shorthand and a `labelField`-less `optionsFrom`. + // objectui#7759 retired both overrides, so the schema IS the spec's and the + // refinement arrives with it: a date default is still judged, on a document + // whose options are spec-legal. + const withOptions = { + field: 'created_at', type: 'date', options: [{ value: 'emea', label: 'EMEA' }], + optionsFrom: { object: 'users', valueField: 'id', labelField: 'name' }, + }; + expect(GlobalFilterSchema.safeParse({ ...withOptions, defaultValue: 'last_7_days' }).success).toBe(true); + expect(GlobalFilterSchema.safeParse({ ...withOptions, defaultValue: 'last_7_dayz' }).success).toBe(false); }); describe('the ADR-0089 legacy-alias lift', () => { diff --git a/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts b/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts index 72865568a4..d522fa0059 100644 --- a/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts +++ b/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts @@ -14,11 +14,12 @@ * extend `Omit< App | Dashboard | Page, … >` over the SAME `as const` exclusion * array their mirror's `specFieldsExcept` call reads (`APP_SPEC_EXCLUDED`, * `DASHBOARD_SPEC_EXCLUDED`, `PAGE_SPEC_EXCLUDED`), plus — on the TypeScript - * face only — the twin members whose hand-written type is not assignable to - * the spec's (`header` on the dashboard, `slots` on the page, both already - * ledgered as drift in `zod-mirror-parity.test.ts`), plus the page's - * `assignedProfiles`, which objectstack `main` retires (a forward-compat - * omission, objectui#9409). + * face only — the twin member whose hand-written type is not assignable to + * the spec's (`slots` on the page, ledgered as drift in + * `zod-mirror-parity.test.ts`), plus the page's `assignedProfiles`, which + * objectstack `main` retires (a forward-compat omission, objectui#9409). The + * dashboard's `header` was the second such member until objectui#7759 group A + * dropped the omission: the twin now inherits the spec's `header` by reference. * * ## Why the positive pins are TYPE equalities, not assignments * @@ -124,12 +125,18 @@ describe('spec tombstones surface on the twin as a refusal — the verdict the m }); describe('the twin-only omissions keep the twin\'s own member, unwidened', () => { - it('Dashboard `header` and Page `slots` are the hand-written types, not the spec\'s', () => { - type HeaderAction = NonNullable['actions']>[number]; - const headerLabel: Equal = true; - const headerNotSpec: Equal = false; + it('Page `slots` is the hand-written type, not the spec\'s', () => { const slotsNotSpec: Equal = false; - expect([headerLabel, headerNotSpec, slotsNotSpec]).toEqual([true, false, false]); + expect(slotsNotSpec).toBe(false); + }); + + it('Dashboard `header` is no longer a twin-only omission: it IS the spec\'s member (objectui#7759)', () => { + // It was the hand-written restatement until objectui#7759 group A, with + // `actions[].label` typed `string`. The spec declares the key, so the twin + // follows it; `dashboard-header-global-filters-spec-7759.test.ts` pins the + // accept set on both faces. + const headerIsSpec: Equal = true; + expect(headerIsSpec).toBe(true); }); it('Page `assignedProfiles` keeps the hand-written `string[]` whatever the spec pin declares', () => { diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index bd52625f77..5ae0d1a31b 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -154,7 +154,12 @@ * a delta to this number; count the registry. Nothing asserts it against a written * one, so this line is prose and can rot; the pin that cannot is the one * comparing the two halves to each other. - * - **46 entries** in `KnownDrift`, **86 keys** across them — 48 / 88 until + * - **46 entries** in `KnownDrift`, **84 keys** across them — 46 / 86 until + * objectui#7759 group A settled `complex.zod.ts#DashboardComponentSchema`'s + * `header` and `globalFilters` by the spec (ruling 5617465269, principle 1): the + * declaration inherits the spec's `header` and the mirror's `GlobalFilterSchema` + * IS the spec's, so both keys measure clean and the entry SHRANK to `widgets` — + * the key total fell by two and the entry count did not. It was 48 / 88 until * objectui#6033 settled the last two keys of its own ruling (C7 / C8): two * WHOLE entries left (`form.zod.ts#ComboboxSchema`, whose one key was `options`, * and `form.zod.ts#CommandSchema`, whose one key was `groups`). Both were @@ -433,8 +438,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. - * - **7 entries** in `WiderThanDeclared`, **9 keys** across them, and **9 arms** - * under those keys — split **5** SCHEMA-NODE, **4** CONCRETE, **0** MIXED, **0** unions. + * - **6 entries** in `WiderThanDeclared`, **7 keys** across them, and **7 arms** + * under those keys — split **5** SCHEMA-NODE, **2** CONCRETE, **0** MIXED, **0** unions. + * It read 7 / 9 / 9 — 5 / 4 / 0 / 0 — until objectui#7759 group A settled + * `complex.zod.ts#DashboardComponentSchema`'s `header` and `globalFilters`: both keys + * are spec-declared, so both faces now state the spec's member, and the entry, its + * two keys and their two CONCRETE arms LEFT together (both keys also left + * `KnownDrift` — they were DISJOINT). * It read 8 / 10 / 11 — 5 / 5 / 0 / 1 — until objectui#10387 RETIRED * `navigation.zod.ts#HeaderBarSchema::logo` on both faces: the `header-bar` renderer * reads no `logo`, so the entry, its one key and BOTH its arms left together. It was @@ -1906,16 +1916,27 @@ interface KnownDrift { /** The same three slots on the same channel — see `ChatbotEnhancedSchema` above. */ 'complex.zod.ts#ChatbotFloatingSchema': 'onClear' | 'onError' | 'onSend'; /** - * spec-derived shape (`SpecDashboardFields`) measured against a hand-written - * local declaration. Needs the spec-unification triage of #2231 rather than a - * local widening. + * `widgets` — the objectui widget element (`DashboardWidgetSchema`, carrying the + * legacy `component` envelope the spec has no room for) against the declaration's + * two-arm slot; see that element's own entry below. + * + * `header` and `globalFilters` LEFT under objectui#7759 group A. Both are declared + * by the spec's `DashboardSchema`, so under that card's ruling (5617465269, + * principle 1) both faces now follow the spec, both ways: the declaration dropped + * its hand-written `header` (whose `actions[].actionUrl` was optional and + * `actions[].actionType` an open string, both of which the mirror refused) and + * inherits the spec's; the mirror's `GlobalFilterSchema` dropped its local + * `options` restatement (whose `label` was `z.string()`, refusing the inline + * per-locale map the declaration admits) and IS the spec's schema. Each key was + * also in `WiderThanDeclared` — each face refused something the other accepted — + * and left that ledger in the same change. * * `aria` was a FOURTH drifted key here until objectui#5855 retired * `DashboardComponentSchema.aria` from the declaration as spec-tombstoned and * renderer-dead. Dropping it from the declaration dropped it from the * comparison, and this entry going stale is precisely what surfaced that. */ - 'complex.zod.ts#DashboardComponentSchema': 'header' | 'widgets' | 'globalFilters'; + 'complex.zod.ts#DashboardComponentSchema': 'widgets'; /** * `options` — TS declares `unknown`; the mirror declares a structured options object. * The mirror is the STRICTER side here — narrowing the check would be wrong, widening @@ -3149,25 +3170,20 @@ interface WiderThanDeclared { // nothing the declaration refuses and the entry is GONE, per clause 4 of this ledger's // "when it fires" note. The pair keeps its `KnownDrift` entry above, which records the // ruling beside the two runtime slots that remain. - /** - * CONCRETE. `header` and `globalFilters` carry the inline-locale widening one level - * 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 - * objectui#8252, and the three words were the whole verdict on a two-arm union: true - * of the widget-envelope arm, false of the component-node arm, which is concrete and - * whose TypeScript face was missing outright (objectui#7952, declared by PR #8296 as - * `c842594`). That arm was repaired there; the schema-node arm stopped reading wider - * under objectui#7760, when `SchemaNodeSchema` gained its input type argument — so - * the key measures clean and its row is gone. ⇒ The verdict that governs is still - * `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'; + // `complex.zod.ts#DashboardComponentSchema` LEFT this ledger WHOLE under objectui#7759 + // group A. Its last two keys were `header` and `globalFilters`, both CONCRETE and both + // DISJOINT (each also had a `KnownDrift` entry). This entry's docblock called them "the + // inline-locale widening one level down"; measured, that was true of `header` alone. + // `header`'s mirror (the spec's, by reference) took an inline per-locale action label + // the hand-written declaration refused. `globalFilters` read wider for a different + // reason: the mirror's local `options` / `optionsFrom` restatement took the bare-string + // option shorthand, an option with no `label` and an `optionsFrom` with no + // `labelField`, all of which the spec (and so the declaration) refuses; its locale arm + // ran the OTHER way, on the `KnownDrift` side. Both keys are spec-declared, so both + // faces now state the spec's member (ruling 5617465269, principle 1) and the pair + // measures clean in this direction. Before them, `dateRange` left under objectui#10334, + // and `widgets` — the only MIXED key this ledger ever held — under objectui#8252 and + // objectui#7760. // `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 @@ -3363,8 +3379,6 @@ const WIDER_ARM_ROW_SEPARATOR = '::'; 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'], 'form.zod.ts#FormSchema::layout': ['CONCRETE'], 'layout.zod.ts#PageNodeSchema::slots': ['SCHEMA-NODE'], 'views.zod.ts#DetailViewFieldSchema::options': ['CONCRETE'], @@ -3682,8 +3696,9 @@ interface MirroredUndeclared { // `protection`, and the tombstones `aria` / `refreshInterval` / `performance`) — // deleted by objectui#9736 for the reason the App note above gives: the twin // extends `Omit< Dashboard, … >` over `DASHBOARD_SPEC_EXCLUDED`. Its `header` - // member is withheld from that projection on the TypeScript face only, and stays - // in `KnownDrift` / `WiderThanDeclared` where it was. + // member was withheld from that projection on the TypeScript face only until + // objectui#7759 group A, which dropped the withholding: the twin inherits the + // spec's `header`, and the key left `KnownDrift` / `WiderThanDeclared` with it. /** diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index 6dc29d5cc4..ace7e75f2a 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -2233,22 +2233,22 @@ export interface DashboardWidgetSlotComponentSchema extends BaseSchema { * tombstone surfaces as an optional member typed `undefined`: authoring a * value is a compile error, the verdict the validator gives at parse. * - * ONE key is omitted from the spec projection beyond the shared list, on the - * TypeScript face only: - * - `header` — the member below is a hand-written restatement that DISAGREES - * with the spec's in both directions (`actions[].label` narrower: `string` - * against `I18nLabel`; `actions[].actionUrl` optional where the spec - * requires it; `actions[].actionType` an open `string` against the spec's - * enum), so it is not assignable to the spec's member and cannot sit beside - * it. That disagreement is already ledgered as `KnownDrift` and - * `WiderThanDeclared` for this pair in `__tests__/zod-mirror-parity.test.ts`; - * re-aligning it is that ledger's decision, ⛔ not widened here. The mirror - * keeps validating the spec's `header` — the omission is type-side only, - * which is why it is spelled beside the shared list, not inside it. + * `header` comes from that projection too (objectui#7759 group A). Until then + * it was omitted on this face alone and restated by hand, and the restatement + * disagreed with the spec in both directions: `actions[].label` was `string` + * against the spec's `I18nLabel`, `actions[].actionUrl` optional where the spec + * requires it, and `actions[].actionType` an open `string` against the spec's + * `ActionType` enum. The mirror always validated the spec's member, so an + * inline per-locale action label parsed green, `tsc` refused it, and the + * renderer handed the map to React as a button child. The spec declares the + * key, so under that card's ruling (5617465269, principle 1) the declaration + * follows the spec rather than the other way round, and `DashboardRenderer` + * resolves the label. Pinned by + * `__tests__/dashboard-header-global-filters-spec-7759.test.ts`. * * Pinned by `__tests__/twins-spec-by-reference-9736.test.ts`. */ -export interface DashboardComponentSchema extends BaseSchema, Omit { +export interface DashboardComponentSchema extends BaseSchema, Omit { type: 'dashboard'; // `title` was DECLARED here until objectui#7623, under the comment "Dashboard // title displayed in the header" — by then a description of behaviour that had @@ -2306,20 +2306,9 @@ export interface DashboardComponentSchema extends BaseSchema, Omit; - }; + // `header` is not declared here: it is inherited from the `Omit< Dashboard, … >` + // projection above, i.e. the spec's `DashboardHeader` by reference — see this + // interface's docblock for the restatement it replaced (objectui#7759 group A). /** * Global filter configurations. * Applied across all dashboard widgets. diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index baebe54842..72d0b6a85b 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -1135,92 +1135,50 @@ const DashboardWidgetSlotComponentSchema = BaseSchema.extend({ }); /** - * Global Filter Schema — a dashboard-level filter definition, DERIVED from - * `@objectstack/spec/ui` (objectstack#4115): `name`, `field`, `label`, `type`, - * `defaultValue`, `scope` and `targetWidgets` flow in **by reference**. - * - * Two pinned divergences, each backed by a runtime normalizer in - * `@object-ui/core`'s `dashboard-filters.ts`: - * - `options` also accepts the bare-string shorthand (`options: ['EMEA', …]`) - * and an object without `label`; `normalizeFilterOptions` folds both into the - * spec's `{ value, label }` form before anything renders them. - * - `optionsFrom.labelField` stays optional (it falls back to `valueField`) and - * `filter` stays `z.any()` — objectui passes an ObjectQL FilterNode array - * here, not the spec's `FilterCondition` envelope. - * - * There used to be a third — `defaultValue` widened to `z.any()` so the - * `{ preset }` object form would validate. It was RETIRED by the maintainer - * ruling on objectui#4165 (2026-08-11): the spec stays strict, the bare preset - * name is the single canonical spelling, and the object form is handled as a - * documented legacy alias by `liftLegacyGlobalFilterDefault` - * (`../dashboard-filter-alias.ts`, which carries the retirement window) rather - * than by a permanently tolerant schema. Keeping it would have been the - * tolerant-consumer failure AGENTS.md #0.1 names: objectui green on metadata - * the platform refuses, so the designer saves and the server rejects. - * - * Drift guard: `__tests__/report-chart-query-spec-parity.test.ts`. - * - * ## Composition: spread + delegated refinement (objectui#4165) - * - * @objectstack/spec 17.0.0-rc.6 put a refinement on `GlobalFilterSchema`, and - * a refined object schema in zod 4 closes every structural door this derivation - * would normally use. All three were measured on rc.6 + zod 4.4.3: - * - * - `.extend()` — what this used to be — **throws at module load**: *"Cannot - * overwrite keys on object schemas containing refinements. Use - * `.safeExtend()` instead."* It took six `@object-ui/types` suites down - * before any of them ran a test. - * - `.safeExtend()` — zod's own suggested replacement — runs, but is "safe" - * precisely in that it will not let you REPLACE an existing key's type: it - * types every incompatible override as `never`. Still true with only two - * overrides left (TS2322 on BOTH `options` and `optionsFrom`), so retiring - * the `defaultValue` divergence did not re-open this door. - * - `.omit()` — the obvious way to drop the two keys before re-adding them — - * **throws** as well: *".omit() cannot be used on object schemas containing - * refinements"*. So does `.pick()`, for the same reason. - * - * What is left is to spread the spec's `.shape` (fields still flow in BY - * REFERENCE, so a spec field change lands here) and re-attach the spec's - * OBJECT-LEVEL rules by DELEGATION: re-parse the spec-owned keys through the - * spec schema itself and forward its issues. That restates none of the spec's - * grammar — the rejection message an author sees is the spec's own, and a - * refinement the spec adds LATER flows in with no change here. The two - * divergent keys are excluded from the delegated parse by construction, which - * is the whole and only exemption. - * - * Cost: one extra parse of the spec-owned subset per validation. Acceptable — - * nothing in objectui validates dashboards on a render path; this schema is a - * published contract for consumers and tooling. - * - * Drift guard: `__tests__/report-chart-query-spec-parity.test.ts`. + * Global Filter Schema — a dashboard-level filter definition: `@objectstack/spec/ui`'s + * `GlobalFilterSchema` BY REFERENCE, whole (objectui#7759 group A). Every key, + * every nested shape and the spec's own object-level refinement (the date + * `defaultValue` vocabulary check) flow in unchanged; `stripImportedDefaults` + * only removes the imported `scope` default, the same treatment every other + * spec-derived schema in this package gets. + * + * ## The two local overrides this replaced, and why they went + * + * Until objectui#7759 this was the spec's `.shape` spread into a local + * `z.object` with `options` and `optionsFrom` restated, plus a `superRefine` + * that re-ran the spec schema on the other keys. The restatement disagreed with + * the spec in both directions, which is why the dashboard pair carried this key + * in BOTH `KnownDrift` and `WiderThanDeclared` + * (`__tests__/zod-mirror-parity.test.ts`): + * - WIDER — `options` took the bare-string shorthand (`options: ['EMEA']`) and + * an option with no `label`; `optionsFrom.labelField` was optional and + * `optionsFrom.filter` was `z.any()` (an array parsed green); an unknown key + * in a filter was stripped in silence. The spec refuses each of these, so + * this validator went green on metadata the platform refuses at publish. + * - NARROWER — an option's `label` was `z.string()`, so the spec's inline + * per-locale map (`I18nLabel`), which the declaration admits and the filter + * bar resolves with `pickLocalized`, was refused here. + * + * The spec declares the key, so under that card's ruling (5617465269, + * principle 1) both faces follow the spec, both ways: the declaration already + * bound `GlobalFilter` by reference (objectui#4032), and this validator now does + * too. The runtime is untouched: `@object-ui/core`'s `normalizeFilterOptions` + * still LIFTS a stored bare-string option on read, with its deprecation + * warning, on the objectstack#7917 option-② window (the spec stays strict; the + * renderer's lift retires behind a survey), and the filter bar still falls back + * to `valueField` when `labelField` is absent. This is the objectui#4165 split + * applied to `options`: the SCHEMA refuses the legacy spelling, the READ PATH + * lifts it. + * + * The `{ preset }` `defaultValue` object form was the objectui#4165 instance of + * the same shape: refused by the schema, lifted on read by + * `liftLegacyGlobalFilterDefault` (`../dashboard-filter-alias.ts`, which carries + * the retirement window). + * + * Drift guard: `__tests__/report-chart-query-spec-parity.test.ts`; pinned by + * `__tests__/dashboard-header-global-filters-spec-7759.test.ts`. */ -export const GlobalFilterSchema = z.object({ - ...stripImportedDefaults(SpecGlobalFilterSchema).shape, - options: z.array(z.union([ - z.string(), - z.object({ - value: z.union([z.string(), z.number(), z.boolean()]), - label: z.string().optional(), - }), - ])).optional().describe('Static options — spec `{value,label}` objects or bare-string shorthand'), - optionsFrom: z.object({ - object: z.string(), - valueField: z.string(), - labelField: z.string().optional(), - filter: z.any().optional(), - }).optional().describe('Dynamic option source'), -}).superRefine((filter, ctx) => { - // Delegate every spec-owned rule (today the rc.6 date-`defaultValue` - // refinement; tomorrow whatever the spec adds) to the spec schema itself. - // `options`/`optionsFrom` are the declared divergences and are withheld — - // both are `.optional()` upstream, so omitting them is valid input. - const specOwned: Record = { ...filter }; - delete specOwned.options; - delete specOwned.optionsFrom; - const result = stripImportedDefaults(SpecGlobalFilterSchema).safeParse(specOwned); - if (result.success) return; - for (const issue of result.error.issues) ctx.addIssue({ ...issue }); -}); +export const GlobalFilterSchema = stripImportedDefaults(SpecGlobalFilterSchema); /** * Dashboard Schema - Dashboard component @@ -1239,10 +1197,14 @@ export const GlobalFilterSchema = z.object({ * * Omitted, each for a stated reason: * - `name`/`label`/`description` — component-envelope keys owned by BaseSchema; - * - `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. + * - `widgets` — objectui's element schema is its own ledger entry (the local + * widget still carries the legacy `component` envelope the spec has no room + * for); migration deferred. + * - `globalFilters` — re-added below over the exported {@link GlobalFilterSchema}, + * which since objectui#7759 IS the spec's element schema by reference, so the + * accept set equals the spec member's; the separate const is kept because it + * is published API. (It used to be "deliberately looser than spec's" — the + * bare-string option shorthand and a relaxed `optionsFrom` — see that const.) * * `dateRange` was a third member of that list until objectui#10334. Its local * element (`defaultRange` a bare `z.string()`, a stripping object) admitted