diff --git a/.changeset/29284-dashboard-mirror-global-filters-by-reference.md b/.changeset/29284-dashboard-mirror-global-filters-by-reference.md new file mode 100644 index 00000000000..6b7590461f2 --- /dev/null +++ b/.changeset/29284-dashboard-mirror-global-filters-by-reference.md @@ -0,0 +1,19 @@ +--- +'@object-ui/types': patch +--- + +fix(types)!: `DashboardComponentSchema` refuses a dashboard that declares two global filters under one key, as the spec does + +Clause-②: yes (narrowing) +Metadata-protocol: none + + + +**BREAKING** for a dashboard node whose `globalFilters` repeats a filter key. + +- **Before.** `DashboardComponentSchema` (`@object-ui/types/zod`) rebuilt `globalFilters` as an array of `GlobalFilterSchema`. That kept every check on each filter and dropped the spec array's own refusal, so two filters under one key passed this validator while every spec door (`defineStack`, `os validate`, `os build`, the dashboard save door) refused the dashboard. A filter's key is its `name`, or its `field` when it has no `name`. +- **Now.** The mirror takes `@objectstack/spec`'s `globalFilters` slot by reference, so it refuses the pair with the spec's own issue, at the second declaration's `name` (or its `field`): `This dashboard declares the global filter 'X' twice in globalFilters: globalFilters[i] and globalFilters[j]. …`. Each element is still the exported `GlobalFilterSchema`, so nothing else about a filter is judged differently. The slot's description is now the spec's. +- **Not changed.** Distinct keys, and two filters with distinct names over the same `field`. The TypeScript type of `globalFilters` and the `DASHBOARD_SPEC_EXCLUDED` list are unchanged. +- **Fix.** Rename all but one of the colliding entries (and every widget `filterBindings` key that means a renamed one), or delete the copy. The upgrade guide entry is `ui-dashboard-global-filter-duplicate-name-refused`. + +Also: the `ObjectFormSchema` key-taxonomy docblock no longer lists `drawerSide`/`drawerWidth` as spec-aligned form-view keys. The spec retired both on the form view; they are the React `ObjectForm` block's own props. Comment only. diff --git a/ui/packages/types/src/__tests__/dashboard-global-filters-duplicate-key-29284.test.ts b/ui/packages/types/src/__tests__/dashboard-global-filters-duplicate-key-29284.test.ts new file mode 100644 index 00000000000..c5ead6c1136 --- /dev/null +++ b/ui/packages/types/src/__tests__/dashboard-global-filters-duplicate-key-29284.test.ts @@ -0,0 +1,83 @@ +/** + * 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. + */ + +/** + * objectstack#29284 — the published mirror refuses a dashboard whose + * `globalFilters` declares one key twice, with the spec's own issue. + * + * `@objectstack/spec`'s `DashboardSchema` attaches its duplicate-key refusal + * (objectstack#29023) to the `globalFilters` ARRAY. `DashboardComponentSchema` + * rebuilt that array as `z.array(GlobalFilterSchema)`, which kept every element + * check and dropped the array's own, so two filters under one key passed here + * while every spec door refused them. The mirror now takes the spec's slot by + * reference; this file pins that it carries the refusal, and that the + * refusal is the spec's issue (code, path and message), not a local dialect. + * + * A filter's key is its `name`, else its `field`. + */ +import { describe, expect, it } from 'vitest'; +import { DashboardSchema as SpecDashboardSchema } from '@objectstack/spec/ui'; +import { DashboardComponentSchema, GlobalFilterSchema } from '../zod/complex.zod'; + +/** The mirror's node: component discriminator plus the one required slot. */ +const node = (globalFilters: unknown[]) => ({ type: 'dashboard', widgets: [], globalFilters }); +/** The spec's dashboard for the same filters. */ +const specBoard = (globalFilters: unknown[]) => ({ name: 'qa_board', label: 'QA board', widgets: [], globalFilters }); + +const customIssues = (result: { success: boolean; error?: { issues: Array<{ code: string; path: PropertyKey[]; message: string }> } }) => + (result.success ? [] : result.error!.issues.filter((i) => i.code === 'custom')) + .map(({ code, path, message }) => ({ code, path, message })); + +const BY_NAME = [ + { name: 'region', field: 'region', type: 'text' }, + { name: 'region', field: 'territory', type: 'text' }, +]; +const BY_FIELD = [ + { field: 'status', type: 'text' }, + { field: 'status', type: 'text' }, +]; + +describe('objectstack#29284 — DashboardComponentSchema refuses a duplicate globalFilters key with the spec\'s issue', () => { + it('two filters under one `name` are refused at the second one\'s `name`', () => { + const mirror = customIssues(DashboardComponentSchema.safeParse(node(BY_NAME))); + expect(mirror).toHaveLength(1); + expect(mirror[0]).toMatchObject({ code: 'custom', path: ['globalFilters', 1, 'name'] }); + expect(mirror[0]!.message.startsWith("This dashboard declares the global filter 'region' twice in globalFilters")).toBe(true); + }); + + it('two unnamed filters over one `field` are refused at the second one\'s `field` (the key falls back to the field)', () => { + const mirror = customIssues(DashboardComponentSchema.safeParse(node(BY_FIELD))); + expect(mirror).toHaveLength(1); + expect(mirror[0]).toMatchObject({ code: 'custom', path: ['globalFilters', 1, 'field'] }); + expect(mirror[0]!.message).toContain('A filter with no `name` is keyed by its `field`.'); + }); + + it.each([ + ['by name', BY_NAME], + ['by field fallback', BY_FIELD], + ])('the refusal %s is the spec door\'s own issue, byte for byte', (_label, filters) => { + const spec = customIssues(SpecDashboardSchema.safeParse(specBoard(filters))); + expect(spec).toHaveLength(1); + expect(customIssues(DashboardComponentSchema.safeParse(node(filters)))).toEqual(spec); + }); + + it('distinct keys parse — a named filter over a field another filter is keyed by is a different key', () => { + const filters = [ + { name: 'region', field: 'region', type: 'text' }, + { name: 'territory', field: 'region', type: 'text' }, + { field: 'status', type: 'text' }, + ]; + expect(SpecDashboardSchema.safeParse(specBoard(filters)).success).toBe(true); + expect(DashboardComponentSchema.safeParse(node(filters)).success).toBe(true); + }); + + it('the slot\'s element is still the published GlobalFilterSchema, so element checks are unchanged', () => { + const slot = DashboardComponentSchema.shape.globalFilters; + const array = slot.unwrap(); + expect(array.element).toBe(GlobalFilterSchema); + }); +}); diff --git a/ui/packages/types/src/objectql.ts b/ui/packages/types/src/objectql.ts index a3406bac6dd..262e9f53238 100644 --- a/ui/packages/types/src/objectql.ts +++ b/ui/packages/types/src/objectql.ts @@ -1769,10 +1769,12 @@ export type SubmitBehavior = * * - **[spec-aligned]** — same name & semantics as `@objectstack/spec` * `FormViewSchema` (`title`, `description`, `layout`, `columns`, `sections`, - * `defaultTab`, `tabPosition`, `allowSkip`, `showStepIndicator`, `drawerSide`/`drawerWidth`, + * `defaultTab`, `tabPosition`, `allowSkip`, `showStepIndicator`, * `modalSize`, `subforms`, `submitBehavior`; `formType` ↔ spec `type`). * - **[ObjectUI extension]** — serializable renderer extras with no spec * backing yet (button visibility/labels, `className`, `initialValues`, + * `drawerSide`/`drawerWidth` — tombstones on the spec form view, the React + * `ObjectForm` block's own props, * `fields`/`customFields`, …). Candidates for upstreaming are tracked in * #2545; until then they are sanctioned, documented extensions. * - **[runtime-only]** — non-serializable runtime concerns that never belong diff --git a/ui/packages/types/src/zod/complex.zod.ts b/ui/packages/types/src/zod/complex.zod.ts index f1d3f0de87c..5c413c5a1c7 100644 --- a/ui/packages/types/src/zod/complex.zod.ts +++ b/ui/packages/types/src/zod/complex.zod.ts @@ -1646,11 +1646,20 @@ export const GlobalFilterSchema = stripImportedDefaults(SpecGlobalFilterSchema); * - `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.) + * - `globalFilters` — re-added below as the spec's own SLOT, by reference + * (`StrippedSpecDashboard.shape.globalFilters`): the array whose element is + * the exported {@link GlobalFilterSchema} (since objectui#7759 the spec's + * element schema by reference) and which carries the spec's array-level + * refinement, so the accept set equals the spec member's. objectstack#29284: + * until then this mirror rebuilt the array as `z.array(GlobalFilterSchema)`, + * which kept every element check and dropped the array's own — the refusal + * of two filters under one key (a filter's `name`, else its `field`; + * objectstack#29023) — so this validator went green on a dashboard every + * spec door refuses. It stays on the excluded list because that list is + * published API and the TypeScript twin in `../complex.ts` restates the key + * over the same element type. (It used to be "deliberately looser than + * spec's" — the bare-string option shorthand and a relaxed `optionsFrom` — + * see {@link GlobalFilterSchema}.) * * `dateRange` was a third member of that list until objectui#10334. Its local * element (`defaultRange` a bare `z.string()`, a stripping object) admitted @@ -1679,7 +1688,8 @@ export const DASHBOARD_SPEC_EXCLUDED = [ // TypeScript twin in `../complex.ts`, which extends `Omit< Dashboard, … >` over the same // array — so the published validator and the published type project one spec // surface and cannot drift apart again. -const SpecDashboardFields = specFieldsExcept(stripImportedDefaults(SpecDashboardSchema).shape, DASHBOARD_SPEC_EXCLUDED); +const StrippedSpecDashboard = stripImportedDefaults(SpecDashboardSchema); +const SpecDashboardFields = specFieldsExcept(StrippedSpecDashboard.shape, DASHBOARD_SPEC_EXCLUDED); /** * Dashboard Schema — the objectui dashboard renderer node, derived from @@ -1703,7 +1713,9 @@ export const DashboardComponentSchema = BaseSchema.extend(SpecDashboardFields.sh // (see `closeStrictUnionArms` in `./node-derivation.ts`). widgets: z.array(z.union(closeStrictUnionArms([DashboardWidgetSlotComponentSchema, DashboardWidgetSchema] as const))) .describe('Dashboard widgets'), - globalFilters: z.array(GlobalFilterSchema).optional().describe('Dashboard-level filters'), + // The spec's slot by reference, refinement included (objectstack#29284; see the + // `globalFilters` bullet on `DASHBOARD_SPEC_EXCLUDED`). + globalFilters: StrippedSpecDashboard.shape.globalFilters, 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 '