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
19 changes: 19 additions & 0 deletions .changeset/29284-dashboard-mirror-global-filters-by-reference.md
Original file line number Diff line number Diff line change
@@ -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

<!-- adr-0087: not-required (already-registered ui-dashboard-global-filter-duplicate-name-refused) The refused shape is the one that entry already registers for `@objectstack/spec`'s `DashboardSchema`: a `globalFilters` array that declares one filter key twice. This change makes the published `@object-ui/types` mirror refuse the same shape, with the same issue, by taking the spec's slot by reference. No spec key, accepted value or stored shape moves, so the upgrade guide entry, its remedy and its stored-row behaviour are the ones already published; `objectstack migrate meta` has nothing new to rewrite. -->

**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.
Original file line number Diff line number Diff line change
@@ -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);
});
});
4 changes: 3 additions & 1 deletion ui/packages/types/src/objectql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 19 additions & 7 deletions ui/packages/types/src/zod/complex.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 '
Expand Down
Loading