Skip to content

Commit 5651647

Browse files
fix(types)!: DashboardComponentSchema refuses a duplicate globalFilters key, as the spec does (#29507)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb8aT1PgdpXdduoh1KQczPT1)_ Fixes #29284 Clause-②: yes (narrowing) Metadata-protocol: none ## 维护者速读 - 改了什么:`@object-ui/types` 发布的仪表盘校验器 `DashboardComponentSchema` 的 `globalFilters` 改为直接引用 spec 的同名字段,于是同一仪表盘里两个全局筛选用了同一个键(`name`,没有 `name` 时用 `field`)会被拒绝;另顺手修正一行表单视图注释。 - 为什么改:spec 自 #29023 起拒绝这种重复键,但 ui 镜像自己重建了这个数组,把数组上的拒绝规则丢了,结果 ui 校验通过、平台发布却拒绝,两边口径不一致。 - 风险与回滚:只收紧 ui 镜像对一种已被平台拒绝的写法的判断,元素校验、TypeScript 类型和 `DASHBOARD_SPEC_EXCLUDED` 都不变;回滚即 revert 本 PR。 - 先看:`ui/packages/types/src/zod/complex.zod.ts`(`globalFilters:` 那一行和上方说明),`ui/packages/types/src/__tests__/dashboard-global-filters-duplicate-key-29284.test.ts`,`.changeset/29284-dashboard-mirror-global-filters-by-reference.md`。 - 两分钟验证:在 `ui/` 下运行 `pnpm exec vitest run packages/types/src/__tests__/dashboard-global-filters-duplicate-key-29284.test.ts`,预期 6 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes - **Mechanism (a): the spec's slot by reference.** `DashboardComponentSchema.globalFilters` is now `StrippedSpecDashboard.shape.globalFilters`, the `globalFilters` member of `stripImportedDefaults(SpecDashboardSchema)`, instead of a local `z.array(GlobalFilterSchema).optional()`. The spec attaches its duplicate-key refusal (`refuseDuplicateGlobalFilterNames`, #29023) to that ARRAY, so taking the slot carries it, with the spec's own issue. The array's element is still the exported `GlobalFilterSchema` (same object, pinned), so the precondition the card names for (a) holds and no element check moves. `stripImportedDefaults` clones by `def`, so the array's checks survive the walk (its own docblock). - `globalFilters` stays in `DASHBOARD_SPEC_EXCLUDED`: that list is published API and the TypeScript twin in `complex.ts` restates the key over the same element type. The list's docblock now says why the key is re-added and how. - No spec export: the wrapper stays unexported, and the `spec-object-refinements-7715` SITES table is untouched, because the refinement rides the slot and is not an object-level check the mirror attaches by identifier. - **Ride-along (#29284 comment, spec seat's review of PR #29287):** `ui/packages/types/src/objectql.ts`'s FormView key taxonomy no longer lists `drawerSide`/`drawerWidth` as `[spec-aligned]`. They are tombstones on the spec form view and the React `ObjectForm` block's own props. Comment only. The `dashboard.zod.ts` docblock sentence the card mentions is domain:spec's and is not touched here. - Changeset: `patch` for `@object-ui/types`, both declaration lines, `**BREAKING**` banner, ADR-0087 `not-required (already-registered ui-dashboard-global-filter-duplicate-name-refused)`. ## Tests - New `ui/packages/types/src/__tests__/dashboard-global-filters-duplicate-key-29284.test.ts`, 6 cases: refused by `name` at `globalFilters[1].name`; refused by `field` fallback at `globalFilters[1].field`; for both, the mirror's custom issues equal the spec door's (`code`, `path`, `message`); distinct keys parse on both; the slot's element is the published `GlobalFilterSchema`. - `pnpm --filter @object-ui/types test`: 387 files, 10462 tests passed (head `db3d4622` before the main merge, and again on the final head `4bbe9510` after rebuilding the `@object-ui/types` closure: 387 files, 10462 tests passed). - `turbo run type-check --filter=@object-ui/types`: exit 0 (`tsc --noEmit`, examples, test config). ## Acceptance notes - Ablation (`scripts/ablation-replace.mjs`, test import resolves `../zod/complex.zod` from source, so no dist leg): the slot line put back to `z.array(GlobalFilterSchema).optional().describe('Dashboard-level filters')` reds 4 of 6 cases (the two refusals and the two spec-equality cases); the distinct-keys and element-identity cases stay green, as expected. Restore proven by blob equal to HEAD and an empty `git diff HEAD`. - The slot's description is now the spec's (`Global filters that apply to all widgets in the dashboard. …`) instead of `Dashboard-level filters`; nothing pins the old string. - Serial: the spec seat's PR #29370 edits the same `DashboardComponentSchema` tail (`.superRefine(checkDashboardReservedDateRangeGlobalFilters)`) and the SITES row. This diff touches neither: its hunks are the docblock above `DASHBOARD_SPEC_EXCLUDED`, the `SpecDashboardFields` line and the `globalFilters` line. ## Verification Final head `4bbe9510` (branch merged with `origin/main` `86d36e99`; neither merge touched a changed file). `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 34 families; each ran with its exit code recorded, and `--ran` answered: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN (exit 0). <details><summary>Gate notes</summary> - All 34 derived families exited 0 on `03269aa6` (the changeset commit); `pnpm --dir ui exec vitest run scripts/`: 169 files passed, 2 skipped; 4921 tests passed. - Re-run on the final head `4bbe9510`, all exit 0: `check-adr-0087-registration --base origin/main`, `check-changeset-no-major --base origin/main` (this body as the event payload), `check-closing-keyword-parity`, `check-empty-changeset --base origin/main`, `check-sdui-manifest`, `check:nul-bytes`, `check:type-check-debt`, `check:cross-package-test-inputs`. - `pnpm exec turbo run type-check --filter=@object-ui/types`: exit 0. - Dogfood battery (owed by the `(narrowing)` arm), head `571c0eb8` (one main merge before the final head; that merge touched no file of this diff), closure build then `pnpm --filter @objectstack/dogfood test` under the lock: VERDICT command-exit 1, Test Files 2 failed, 395 passed, 3 skipped (400); Tests 4 failed, 3261 passed, 28 skipped (3293). The four failures are `flow-runas.dogfood.test.ts` (three `runAs` cases; first assertion: trigger HTTP 404 `RECORD_NOT_FOUND` in `runas_note`) and `declarative-endpoint-anonymous-guest.dogfood.test.ts` (one case). They reproduce when the two files run alone (4 failed, 8 passed). Not this diff: the dogfood closure build contains no `@object-ui/*` package, and no file under `packages/qa/dogfood`, `packages/verify` or `packages/plugins` imports `@object-ui/types`. Reported to the seat as a finding. - Serial: PR #29370 (open, head `6b7710c9`) touches the same file with disjoint hunks. A three-way `git merge-file` of `complex.zod.ts`, `spec-object-refinements-7715.test.ts` and `objectql.ts` (ours, `origin/main` as base, theirs) answers 0 conflicts on all three, re-checked on the final head. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) · https://claude.ai/code/session_01QsR2jUAK3FofY5VJPyS8fr --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jUAK3FofY5VJPyS8fr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 399df5b commit 5651647

4 files changed

Lines changed: 124 additions & 8 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/types': patch
3+
---
4+
5+
fix(types)!: `DashboardComponentSchema` refuses a dashboard that declares two global filters under one key, as the spec does
6+
7+
Clause-②: yes (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- 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. -->
11+
12+
**BREAKING** for a dashboard node whose `globalFilters` repeats a filter key.
13+
14+
- **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`.
15+
- **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.
16+
- **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.
17+
- **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`.
18+
19+
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.
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
/**
9+
* objectstack#29284 — the published mirror refuses a dashboard whose
10+
* `globalFilters` declares one key twice, with the spec's own issue.
11+
*
12+
* `@objectstack/spec`'s `DashboardSchema` attaches its duplicate-key refusal
13+
* (objectstack#29023) to the `globalFilters` ARRAY. `DashboardComponentSchema`
14+
* rebuilt that array as `z.array(GlobalFilterSchema)`, which kept every element
15+
* check and dropped the array's own, so two filters under one key passed here
16+
* while every spec door refused them. The mirror now takes the spec's slot by
17+
* reference; this file pins that it carries the refusal, and that the
18+
* refusal is the spec's issue (code, path and message), not a local dialect.
19+
*
20+
* A filter's key is its `name`, else its `field`.
21+
*/
22+
import { describe, expect, it } from 'vitest';
23+
import { DashboardSchema as SpecDashboardSchema } from '@objectstack/spec/ui';
24+
import { DashboardComponentSchema, GlobalFilterSchema } from '../zod/complex.zod';
25+
26+
/** The mirror's node: component discriminator plus the one required slot. */
27+
const node = (globalFilters: unknown[]) => ({ type: 'dashboard', widgets: [], globalFilters });
28+
/** The spec's dashboard for the same filters. */
29+
const specBoard = (globalFilters: unknown[]) => ({ name: 'qa_board', label: 'QA board', widgets: [], globalFilters });
30+
31+
const customIssues = (result: { success: boolean; error?: { issues: Array<{ code: string; path: PropertyKey[]; message: string }> } }) =>
32+
(result.success ? [] : result.error!.issues.filter((i) => i.code === 'custom'))
33+
.map(({ code, path, message }) => ({ code, path, message }));
34+
35+
const BY_NAME = [
36+
{ name: 'region', field: 'region', type: 'text' },
37+
{ name: 'region', field: 'territory', type: 'text' },
38+
];
39+
const BY_FIELD = [
40+
{ field: 'status', type: 'text' },
41+
{ field: 'status', type: 'text' },
42+
];
43+
44+
describe('objectstack#29284 — DashboardComponentSchema refuses a duplicate globalFilters key with the spec\'s issue', () => {
45+
it('two filters under one `name` are refused at the second one\'s `name`', () => {
46+
const mirror = customIssues(DashboardComponentSchema.safeParse(node(BY_NAME)));
47+
expect(mirror).toHaveLength(1);
48+
expect(mirror[0]).toMatchObject({ code: 'custom', path: ['globalFilters', 1, 'name'] });
49+
expect(mirror[0]!.message.startsWith("This dashboard declares the global filter 'region' twice in globalFilters")).toBe(true);
50+
});
51+
52+
it('two unnamed filters over one `field` are refused at the second one\'s `field` (the key falls back to the field)', () => {
53+
const mirror = customIssues(DashboardComponentSchema.safeParse(node(BY_FIELD)));
54+
expect(mirror).toHaveLength(1);
55+
expect(mirror[0]).toMatchObject({ code: 'custom', path: ['globalFilters', 1, 'field'] });
56+
expect(mirror[0]!.message).toContain('A filter with no `name` is keyed by its `field`.');
57+
});
58+
59+
it.each([
60+
['by name', BY_NAME],
61+
['by field fallback', BY_FIELD],
62+
])('the refusal %s is the spec door\'s own issue, byte for byte', (_label, filters) => {
63+
const spec = customIssues(SpecDashboardSchema.safeParse(specBoard(filters)));
64+
expect(spec).toHaveLength(1);
65+
expect(customIssues(DashboardComponentSchema.safeParse(node(filters)))).toEqual(spec);
66+
});
67+
68+
it('distinct keys parse — a named filter over a field another filter is keyed by is a different key', () => {
69+
const filters = [
70+
{ name: 'region', field: 'region', type: 'text' },
71+
{ name: 'territory', field: 'region', type: 'text' },
72+
{ field: 'status', type: 'text' },
73+
];
74+
expect(SpecDashboardSchema.safeParse(specBoard(filters)).success).toBe(true);
75+
expect(DashboardComponentSchema.safeParse(node(filters)).success).toBe(true);
76+
});
77+
78+
it('the slot\'s element is still the published GlobalFilterSchema, so element checks are unchanged', () => {
79+
const slot = DashboardComponentSchema.shape.globalFilters;
80+
const array = slot.unwrap();
81+
expect(array.element).toBe(GlobalFilterSchema);
82+
});
83+
});

‎ui/packages/types/src/objectql.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1769,10 +1769,12 @@ export type SubmitBehavior =
17691769
*
17701770
* - **[spec-aligned]** — same name & semantics as `@objectstack/spec`
17711771
* `FormViewSchema` (`title`, `description`, `layout`, `columns`, `sections`,
1772-
* `defaultTab`, `tabPosition`, `allowSkip`, `showStepIndicator`, `drawerSide`/`drawerWidth`,
1772+
* `defaultTab`, `tabPosition`, `allowSkip`, `showStepIndicator`,
17731773
* `modalSize`, `subforms`, `submitBehavior`; `formType` ↔ spec `type`).
17741774
* - **[ObjectUI extension]** — serializable renderer extras with no spec
17751775
* backing yet (button visibility/labels, `className`, `initialValues`,
1776+
* `drawerSide`/`drawerWidth` — tombstones on the spec form view, the React
1777+
* `ObjectForm` block's own props,
17761778
* `fields`/`customFields`, …). Candidates for upstreaming are tracked in
17771779
* #2545; until then they are sanctioned, documented extensions.
17781780
* - **[runtime-only]** — non-serializable runtime concerns that never belong

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

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1646,11 +1646,20 @@ export const GlobalFilterSchema = stripImportedDefaults(SpecGlobalFilterSchema);
16461646
* - `widgets` — objectui's element schema is its own ledger entry (the local
16471647
* widget still carries the legacy `component` envelope the spec has no room
16481648
* for); migration deferred.
1649-
* - `globalFilters` — re-added below over the exported {@link GlobalFilterSchema},
1650-
* which since objectui#7759 IS the spec's element schema by reference, so the
1651-
* accept set equals the spec member's; the separate const is kept because it
1652-
* is published API. (It used to be "deliberately looser than spec's" — the
1653-
* bare-string option shorthand and a relaxed `optionsFrom` — see that const.)
1649+
* - `globalFilters` — re-added below as the spec's own SLOT, by reference
1650+
* (`StrippedSpecDashboard.shape.globalFilters`): the array whose element is
1651+
* the exported {@link GlobalFilterSchema} (since objectui#7759 the spec's
1652+
* element schema by reference) and which carries the spec's array-level
1653+
* refinement, so the accept set equals the spec member's. objectstack#29284:
1654+
* until then this mirror rebuilt the array as `z.array(GlobalFilterSchema)`,
1655+
* which kept every element check and dropped the array's own — the refusal
1656+
* of two filters under one key (a filter's `name`, else its `field`;
1657+
* objectstack#29023) — so this validator went green on a dashboard every
1658+
* spec door refuses. It stays on the excluded list because that list is
1659+
* published API and the TypeScript twin in `../complex.ts` restates the key
1660+
* over the same element type. (It used to be "deliberately looser than
1661+
* spec's" — the bare-string option shorthand and a relaxed `optionsFrom` —
1662+
* see {@link GlobalFilterSchema}.)
16541663
*
16551664
* `dateRange` was a third member of that list until objectui#10334. Its local
16561665
* element (`defaultRange` a bare `z.string()`, a stripping object) admitted
@@ -1679,7 +1688,8 @@ export const DASHBOARD_SPEC_EXCLUDED = [
16791688
// TypeScript twin in `../complex.ts`, which extends `Omit< Dashboard, … >` over the same
16801689
// array — so the published validator and the published type project one spec
16811690
// surface and cannot drift apart again.
1682-
const SpecDashboardFields = specFieldsExcept(stripImportedDefaults(SpecDashboardSchema).shape, DASHBOARD_SPEC_EXCLUDED);
1691+
const StrippedSpecDashboard = stripImportedDefaults(SpecDashboardSchema);
1692+
const SpecDashboardFields = specFieldsExcept(StrippedSpecDashboard.shape, DASHBOARD_SPEC_EXCLUDED);
16831693

16841694
/**
16851695
* Dashboard Schema — the objectui dashboard renderer node, derived from
@@ -1703,7 +1713,9 @@ export const DashboardComponentSchema = BaseSchema.extend(SpecDashboardFields.sh
17031713
// (see `closeStrictUnionArms` in `./node-derivation.ts`).
17041714
widgets: z.array(z.union(closeStrictUnionArms([DashboardWidgetSlotComponentSchema, DashboardWidgetSchema] as const)))
17051715
.describe('Dashboard widgets'),
1706-
globalFilters: z.array(GlobalFilterSchema).optional().describe('Dashboard-level filters'),
1716+
// The spec's slot by reference, refinement included (objectstack#29284; see the
1717+
// `globalFilters` bullet on `DASHBOARD_SPEC_EXCLUDED`).
1718+
globalFilters: StrippedSpecDashboard.shape.globalFilters,
17071719
body: retirementTombstone(
17081720
'REFUSED (objectui#9256, ADR-0049) — `dashboard` reads NEITHER content channel: measured with the '
17091721
+ 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or '

0 commit comments

Comments
 (0)