Repository navigation
fix(components): filter-builder renderer hands onChange the declared FilterGroup - #26590
Conversation
…FilterGroup
The renderer typed its onChange as (event: any) => void and called it with a
form-style { target: { name, value } } event, while FilterBuilderSchema.onChange
declares (filter: FilterGroup) => void. It now takes the schema's own type and
passes the group.
Claude-Session: https://claude.ai/code/session_01ADHkmNhEw4jVVQ4UTxrxMZ
Co-authored-by: Claude <noreply@anthropic.com>
…-builder renderer Claude-Session: https://claude.ai/code/session_01ADHkmNhEw4jVVQ4UTxrxMZ Co-authored-by: Claude <noreply@anthropic.com>
…terGroup Claude-Session: https://claude.ai/code/session_01ADHkmNhEw4jVVQ4UTxrxMZ Co-authored-by: Claude <noreply@anthropic.com>
…s out of the builder The node declares no extraOperators, and what they grant (exists / notExists) is outside the FilterGroup onChange declares. The off-contract branch, now reachable only through a stored value that skipped validation, skips the emit and logs instead of throwing in a click handler. Claude-Session: https://claude.ai/code/session_01ADHkmNhEw4jVVQ4UTxrxMZ Co-authored-by: Claude <noreply@anthropic.com>
席位核对 · ACCEPT · seat
|
Contract reviewServed-tier: Director seat (summon #52, ledger objectstack#12708), by the maintainer's word 「你负责 pr 契约复审」 — the record is on the lane PR, read-only: the diff at the head above, card #26382, the cited code on ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Requested by Steve · project thread
Fixes #26382
Clause-②: no
Metadata-protocol: none
维护者速读
filter-builder渲染器调用onChange时直接传FilterGroup,不再传{ target: { name, value } }表单事件;渲染器自己的onChange类型改为FilterBuilderSchema['onChange'],去掉了那个any。onChange(filter: FilterGroup),渲染器却传事件对象,按声明写的处理函数读.logic/.conditions只会拿到undefined,而any让编译器看不见。仓内没有任何宿主按事件形状绑定这个节点,所以以声明为准。event.target.value,要改成直接读参数(changeset 已写明)。宿主经节点转发的extraOperators不再传进构建器,所以本节点永远不提供exists/notExists;若存储的 value 绕过校验仍带这类运算符,渲染器不调用onChange,并在 console 报出该运算符,不会抛错,也不会传出错的形状。回滚即 revert 本 PR。ui/packages/components/src/renderers/complex/filter-builder.tsx(extraOperators的解构、isAuthoredFilterGroup与handleChange);ui/packages/components/src/__tests__/filter-builder-renderer-onchange-26382.test.tsx。pnpm --dir ui exec vitest run packages/components/src/__tests__/filter-builder-renderer-onchange-26382.test.tsx,预期 5 个测试全绿。What changes
ui/packages/components/src/renderers/complex/filter-builder.tsx: the renderer'sonChangeprop is typedFilterBuilderSchema['onChange']instead of(event: any) => void, andhandleChangeis typed on the builder'sFilterBuilderRowGroupinstead ofany. It callsonChange(group)with the group itself.exists/notExists) thatFilterGroupcannot carry, soFilterBuilderRowGroupis not assignable toFilterGroup(measured, below). The renderer narrows through a type predicate,isAuthoredFilterGroup, that checks every row's operator against the spec'sVIEW_FILTER_OPERATORS.extraOperatorsout of the host spread and never forwards it toFilterBuilder. It is the builder's only operator-granting prop,FilterBuilderSchemadeclares no such key, and what it grants (exists/notExists) is outside the declaredFilterGroup. So the node's dropdown offers only protocol operators, and the off-contract branch cannot be reached from the node.valuerow that skipped theFilterGroupzod mirror, which refuses such an operator. There the renderer does not callonChangeand logs aconsole.errornaming the operator. It does not hand on a group the declared type does not describe, and it does not throw inside a click handler.isAuthoredFilterGroupstays as the type guard. Noas anyor other cast remains on the path.FilterBuilderSchemain@object-ui/typesis unchanged: the declaration is the contract..changeset/26382-filter-builder-onchange-group.md(@object-ui/componentspatch).Step 1: which side is the contract (the triage's measurement)
Searched
ui/andpackages/onorigin/mainfor every host that could bind afilter-buildernode'sonChangethrough the event shape. Nothing does, so the renderer adopts the declaredFilterGroup(Clause-②: no).renderFieldComponentinui/packages/components/src/renderers/form/form.tsxresolves onlyfield:-namespaced widgets for a form field. Thefilter-buildernode is registered in theuinamespace, and nofield:filter-builderregistration exists. A form field never reaches this renderer.SchemaRendererspreads the host's...propslast, so a host'sonChangewould reach the renderer. The only in-repo hosts that passonChangetoSchemaRendererare two cases inplugin-view'shandlerEventNameThroughRenderer-10616.test.tsx, and neither renders afilter-buildernode.filter-builderwidget: the spec's form rows (view.form.ts,page.form.ts,dataset.form.ts) namewidget: 'filter-builder'. That key resolves toFilterBuilderWidgetin app-shell'smetadata-admin/widgets.tsx, which drawsFilterBuilderFieldoverFilterRuleLite[], andObjectListViewInspectorreads the sameWIDGETSentry. Neither renders thefilter-builderSDUI node, so neither reaches thisonChange.ui/content/docs/components/complex/filter-builder.mdxdeclaresonChange?: (filter: FilterGroup) => void, the same as the type. The fiveui/examples/schema-catalogJSON documents carry no function, and their tests (filter-builder-mirror-6939,filter-builder-operator-alias-trigger-7561,filter-builder-operator-vocabulary-6939) read noonChange.@object-ui/typestests,inline-locale-label-read-sites.test.tsx,known-schema-types.ts,widget-dom-leak-sweep.test.tsxandshadcn-components.jsonname the type but bind noonChangeand read notarget.objectstack-ai/cloudandhotcrmrepositories.Tests
ui/packages/components/src/__tests__/filter-builder-renderer-onchange-26382.test.tsxinvokes the registered renderer directly:onChangegets the group, with notarget, the group'slogic, the existing row and the added row;FilterBuilderSchema's ownonChange(checked withsatisfies), the channel a node-authoredonChangearrives on, gets the same group;extraOperators: ['exists', 'notExists']through the node gets an operator dropdown that offers neitherIs setnorIs not set(and does offerIs empty, so the dropdown really opened);exists(off contract) makes the renderer skiponChangeand log oneconsole.errornaming the operator;expectTypeOfpinsFilterBuilderSchema['onChange']'s parameter EQUAL toFilterGroup, and a@ts-expect-errorpins that a handler taking the old{ target: { name, value } }event is not assignable to it. The file is compiled by the componentstsconfig.test.jsonproject, whichtype-checkruns.Acceptance notes
extraOperators. On the remaining branch, the choice was a throw kept as an invariant, orconsole.errorand skip the emit. This PR picksconsole.errorand skip. The branch is not fully unreachable (a storedvaluethat skipped validation still reaches it), so a throw there would still be a crash in the user's hands.FilterBuilder, as before.extraOperatorsis the only one that grants operators, and it is now held back. No in-repo host forwarded it.Verification
components type-check (tsc --noEmit, then tsc -p tsconfig.test.json)
os-verify-lock: VERDICT command-exit 0, with the new test file in the test project.vitest: new file plus inline-locale-label-read-sites
Test Files 2 passed (2),Tests 10 passed (10); after the extraOperators round:Test Files 2 passed (2),Tests 12 passed (12)(the new file: 5).Ablation 1: call reverted to the event shape
scripts/ablation-replace.mjsreplacedonChange(group);with the old{ target: { name, value } }call (anchor 1 → 0, blob96162878fc0f→ece34562e098). Two tests went red:expected { target: { name: 'filters', …(1) } } to not have property "target"andTarget cannot be null or undefined.(Tests 2 failed | 2 passed). Restored: blob equal to HEAD,git diff HEADempty.Ablation 3: extraOperators forwarded again
extraOperators: _refusedExtraOperators,removed from the destructure, so the spread forwards it again (blob5645f25cbc23→57047d781a2f):expected [ 'Equals', 'Does not equal', …(11) ] to not include 'Is set',Tests 1 failed | 4 passed (5). Restored: blob equal to HEAD,git diff HEADempty.Ablation 4: skip-emit return removed
The
return;after theconsole.errorreplaced with a no-op marker (blob5645f25cbc23→8a937573edee):expected "vi.fn()" to not be called at all, but actually been called 1 times,Tests 1 failed | 4 passed (5). Restored: blob equal to HEAD. A first attempt was refused byablation-replacebefore any run, because its replacement text was a substring of the anchor (the count did not rise), so it was a no-op and not a reading.Ablation 2: type guard removed (is it load-bearing?)
With the predicate replaced by
false as boolean,tsc --noEmitreportedTS2345: Argument of type 'FilterBuilderRowGroup' is not assignable to parameter of type 'FilterGroup', down toType '"exists"' is not assignable to …. Restored: blob equal to HEAD.Full components suite
pnpm --filter @object-ui/components test --maxWorkers=2under the lock at070b433f:Test Files 438 passed | 1 skipped (439),Tests 4273 passed | 24 skipped (4297),VERDICT command-exit 0.After the extraOperators round, narrowed and declared as such at
904e58cf: everyfilter-builder*file inpackages/components/src/__tests__/(22) plusinline-locale-label-read-sites:Test Files 23 passed (23),Tests 392 passed (392). componentstype-checkat904e58cf:VERDICT command-exit 0. The full suite is left to CI's Test UI.Derived gates at 904e58c (dispatch-gates --commands, then --ran)
Re-run in full at the final head, after merging
origin/main(the derivation flagged a stale tree; main touched none of this PR's files and the derived list was the same 34). All 34 exit 0;dispatch-gates --ran:34 derived famil(ies) accounted for — 34 run, 0 NOT-MEASURED (a DERIVED zero — all 34 recorded an exit code and none of them is 3).pnpm --dir ui exec vitest run scripts/:Test Files 178 passed | 2 skipped (180).check-adr-0087-registration: no declared-breaking changeset.Generated by Claude Code