Skip to content

Commit c8ed4c9

Browse files
fix(components): filter-builder renderer hands onChange the declared FilterGroup (#26590)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi979uT1nAyKwVX3CFRQGb43w)_ Fixes #26382 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:`filter-builder` 渲染器调用 `onChange` 时直接传 `FilterGroup`,不再传 `{ target: { name, value } }` 表单事件;渲染器自己的 `onChange` 类型改为 `FilterBuilderSchema['onChange']`,去掉了那个 `any`。 - **为什么改**:schema 声明 `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's `onChange` prop is typed `FilterBuilderSchema['onChange']` instead of `(event: any) => void`, and `handleChange` is typed on the builder's `FilterBuilderRowGroup` instead of `any`. It calls `onChange(group)` with the group itself. - The builder's row type admits two opt-in operators (`exists` / `notExists`) that `FilterGroup` cannot carry, so `FilterBuilderRowGroup` is not assignable to `FilterGroup` (measured, below). The renderer narrows through a type predicate, `isAuthoredFilterGroup`, that checks every row's operator against the spec's `VIEW_FILTER_OPERATORS`. - The renderer takes `extraOperators` out of the host spread and never forwards it to `FilterBuilder`. It is the builder's only operator-granting prop, `FilterBuilderSchema` declares no such key, and what it grants (`exists` / `notExists`) is outside the declared `FilterGroup`. So the node's dropdown offers only protocol operators, and the off-contract branch cannot be reached from the node. - The one way left into that branch is a stored `value` row that skipped the `FilterGroup` zod mirror, which refuses such an operator. There the renderer does not call `onChange` and logs a `console.error` naming the operator. It does not hand on a group the declared type does not describe, and it does not throw inside a click handler. `isAuthoredFilterGroup` stays as the type guard. No `as any` or other cast remains on the path. - `FilterBuilderSchema` in `@object-ui/types` is unchanged: the declaration is the contract. - One root changeset, `.changeset/26382-filter-builder-onchange-group.md` (`@object-ui/components` patch). ### Step 1: which side is the contract (the triage's measurement) Searched `ui/` and `packages/` on `origin/main` for every host that could bind a `filter-builder` node's `onChange` through the event shape. Nothing does, so the renderer adopts the declared `FilterGroup` (`Clause-②: no`). - **Form field hosts:** `renderFieldComponent` in `ui/packages/components/src/renderers/form/form.tsx` resolves only `field:`-namespaced widgets for a form field. The `filter-builder` node is registered in the `ui` namespace, and no `field:filter-builder` registration exists. A form field never reaches this renderer. - **SchemaRenderer host props:** `SchemaRenderer` spreads the host's `...props` last, so a host's `onChange` would reach the renderer. The only in-repo hosts that pass `onChange` to `SchemaRenderer` are two cases in `plugin-view`'s `handlerEventNameThroughRenderer-10616.test.tsx`, and neither renders a `filter-builder` node. - **Metadata-admin `filter-builder` widget:** the spec's form rows (`view.form.ts`, `page.form.ts`, `dataset.form.ts`) name `widget: 'filter-builder'`. That key resolves to `FilterBuilderWidget` in app-shell's `metadata-admin/widgets.tsx`, which draws `FilterBuilderField` over `FilterRuleLite[]`, and `ObjectListViewInspector` reads the same `WIDGETS` entry. Neither renders the `filter-builder` SDUI node, so neither reaches this `onChange`. - **Docs and examples:** `ui/content/docs/components/complex/filter-builder.mdx` declares `onChange?: (filter: FilterGroup) => void`, the same as the type. The five `ui/examples/schema-catalog` JSON documents carry no function, and their tests (`filter-builder-mirror-6939`, `filter-builder-operator-alias-trigger-7561`, `filter-builder-operator-vocabulary-6939`) read no `onChange`. - **Other hits:** the `@object-ui/types` tests, `inline-locale-label-read-sites.test.tsx`, `known-schema-types.ts`, `widget-dom-leak-sweep.test.tsx` and `shadcn-components.json` name the type but bind no `onChange` and read no `target`. - Not searchable from here: the `objectstack-ai/cloud` and `hotcrm` repositories. ## Tests - New `ui/packages/components/src/__tests__/filter-builder-renderer-onchange-26382.test.tsx` invokes the registered renderer directly: - a host `onChange` gets the group, with no `target`, the group's `logic`, the existing row and the added row; - a handler typed from `FilterBuilderSchema`'s own `onChange` (checked with `satisfies`), the channel a node-authored `onChange` arrives on, gets the same group; - a host forwarding `extraOperators: ['exists', 'notExists']` through the node gets an operator dropdown that offers neither `Is set` nor `Is not set` (and does offer `Is empty`, so the dropdown really opened); - a stored row holding `exists` (off contract) makes the renderer skip `onChange` and log one `console.error` naming the operator; - type level: `expectTypeOf` pins `FilterBuilderSchema['onChange']`'s parameter EQUAL to `FilterGroup`, and a `@ts-expect-error` pins that a handler taking the old `{ target: { name, value } }` event is not assignable to it. The file is compiled by the components `tsconfig.test.json` project, which `type-check` runs. - No existing test needed an edit. ## Acceptance notes - Seat ruling on the off-contract branch: close it at its source, so the renderer stops forwarding `extraOperators`. On the remaining branch, the choice was a throw kept as an invariant, or `console.error` and skip the emit. This PR picks `console.error` and skip. The branch is not fully unreachable (a stored `value` that skipped validation still reaches it), so a throw there would still be a crash in the user's hands. - The renderer still spreads the other undeclared host props into `FilterBuilder`, as before. `extraOperators` is the only one that grants operators, and it is now held back. No in-repo host forwarded it. ## Verification <details><summary>components type-check (tsc --noEmit, then tsc -p tsconfig.test.json)</summary> `os-verify-lock: VERDICT command-exit 0`, with the new test file in the test project. </details> <details><summary>vitest: new file plus inline-locale-label-read-sites</summary> `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). </details> <details><summary>Ablation 1: call reverted to the event shape</summary> `scripts/ablation-replace.mjs` replaced `onChange(group);` with the old `{ target: { name, value } }` call (anchor 1 → 0, blob `96162878fc0f` → `ece34562e098`). Two tests went red: `expected { target: { name: 'filters', …(1) } } to not have property "target"` and `Target cannot be null or undefined.` (`Tests 2 failed | 2 passed`). Restored: blob equal to HEAD, `git diff HEAD` empty. </details> <details><summary>Ablation 3: extraOperators forwarded again</summary> `extraOperators: _refusedExtraOperators,` removed from the destructure, so the spread forwards it again (blob `5645f25cbc23` → `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 HEAD` empty. </details> <details><summary>Ablation 4: skip-emit return removed</summary> The `return;` after the `console.error` replaced with a no-op marker (blob `5645f25cbc23` → `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 by `ablation-replace` before 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. </details> <details><summary>Ablation 2: type guard removed (is it load-bearing?)</summary> With the predicate replaced by `false as boolean`, `tsc --noEmit` reported `TS2345: Argument of type 'FilterBuilderRowGroup' is not assignable to parameter of type 'FilterGroup'`, down to `Type '"exists"' is not assignable to …`. Restored: blob equal to HEAD. </details> <details><summary>Full components suite</summary> `pnpm --filter @object-ui/components test --maxWorkers=2` under the lock at `070b433f`: `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`: every `filter-builder*` file in `packages/components/src/__tests__/` (22) plus `inline-locale-label-read-sites`: `Test Files 23 passed (23)`, `Tests 392 passed (392)`. components `type-check` at `904e58cf`: `VERDICT command-exit 0`. The full suite is left to CI's Test UI. </details> <details><summary>Derived gates at 904e58c (dispatch-gates --commands, then --ran)</summary> 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. ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmNhEw4jVVQ4UTxrxMZ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c68869c commit c8ed4c9

3 files changed

Lines changed: 207 additions & 11 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/components': patch
3+
---
4+
5+
fix(components): the `filter-builder` renderer calls `onChange` with the `FilterGroup` that `FilterBuilderSchema.onChange` declares
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`FilterBuilderSchema.onChange` declares `(filter: FilterGroup) => void`, but the `filter-builder` renderer typed its own `onChange` as `(event: any) => void` and called it with a form-style `{ target: { name, value } }` event. A handler written from the declaration read `.logic` and `.conditions` off that event and got `undefined`, and the `any` kept the compiler from reporting the mismatch. The renderer now takes the schema's own `onChange` type and passes the group it holds, so the declaration is the contract on both sides. No in-repo host bound the event shape. A host that read `event.target.value` reads the argument itself instead.
11+
12+
The builder's own rows also admit the opt-in `exists` / `notExists` operators, which a `FilterGroup` cannot carry. The renderer no longer forwards a host's `extraOperators` to the builder, since `FilterBuilderSchema` declares no such key, so the node never offers them. The `FilterGroup` zod mirror refuses them in an authored `value`. If a stored value that skipped that validation still carries one, the renderer does not call `onChange` and logs a console error naming the operator, instead of handing on a group the declared type does not describe.
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* The `filter-builder` renderer calls `onChange` with the shape
11+
* `FilterBuilderSchema.onChange` declares: the `FilterGroup` itself (#26382).
12+
*
13+
* It used to call it with a form-style `{ target: { name, value } }` event
14+
* while the schema declared `(filter: FilterGroup) => void`, and an `any` on
15+
* the renderer's own `onChange` kept the compiler from seeing the gap. A
16+
* handler written from the declaration read `.logic` / `.conditions` off the
17+
* event and got `undefined`.
18+
*
19+
* The renderer is invoked DIRECTLY from the registry, as the sibling
20+
* `inline-locale-label-read-sites.test.tsx` does, so the assertion reads what
21+
* the renderer itself hands over and not what `SchemaRenderer` adds around it.
22+
*/
23+
24+
import * as React from 'react';
25+
import { describe, it, expect, expectTypeOf, vi } from 'vitest';
26+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
27+
import { I18nProvider } from '@object-ui/i18n';
28+
import { ComponentRegistry } from '@object-ui/core';
29+
import type { FilterBuilderSchema, FilterGroup } from '@object-ui/types';
30+
// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
31+
// cold transform is billed to `hookTimeout`. See
32+
// object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
33+
import '../renderers';
34+
35+
const FIELDS = [
36+
{ value: 'name', label: 'Name', type: 'text' },
37+
{ value: 'age', label: 'Age', type: 'number' },
38+
];
39+
40+
const VALUE: FilterGroup = {
41+
id: 'root',
42+
logic: 'or',
43+
conditions: [{ id: 'c1', field: 'name', operator: 'equals', value: 'Ada' }],
44+
};
45+
46+
function renderDirect(props: Record<string, unknown>) {
47+
const C = ComponentRegistry.get('filter-builder') as React.ComponentType<any>;
48+
return render(
49+
<I18nProvider persistLanguage={false}>
50+
<C {...props} />
51+
</I18nProvider>,
52+
);
53+
}
54+
55+
describe('filter-builder renderer hands onChange the declared FilterGroup (#26382)', () => {
56+
it('calls onChange with the group, not with a { target } event', () => {
57+
const onChange = vi.fn<(filter: FilterGroup) => void>();
58+
renderDirect({
59+
schema: { type: 'filter-builder', name: 'filters', fields: FIELDS, value: VALUE },
60+
onChange,
61+
});
62+
63+
fireEvent.click(screen.getByRole('button', { name: /add filter/i }));
64+
65+
expect(onChange).toHaveBeenCalledTimes(1);
66+
const [group] = onChange.mock.calls[0];
67+
expect(group).not.toHaveProperty('target');
68+
expect(group.logic).toBe('or');
69+
expect(group.conditions.map((c) => c.id)[0]).toBe('c1');
70+
expect(group.conditions).toHaveLength(2);
71+
expect(group.conditions[1]).toMatchObject({ field: 'name', operator: 'equals' });
72+
});
73+
74+
it('calls a handler authored on the node itself the same way', () => {
75+
const onChange = vi.fn<(filter: FilterGroup) => void>();
76+
renderDirect({
77+
schema: { type: 'filter-builder', name: 'filters', fields: FIELDS, value: VALUE },
78+
// `SchemaRenderer` spreads the node's own keys as props, so a typed node
79+
// that authors `onChange` reaches the renderer through the same prop.
80+
...({ onChange } satisfies Pick<FilterBuilderSchema, 'onChange'>),
81+
});
82+
83+
fireEvent.click(screen.getByRole('button', { name: /add filter/i }));
84+
85+
expect(onChange.mock.calls[0][0].conditions).toHaveLength(2);
86+
});
87+
88+
it('keeps a host-forwarded extraOperators out of the builder, so exists / notExists are never offered', async () => {
89+
// `extraOperators` is the builder's only operator-granting prop, and what it
90+
// grants is outside the FilterGroup that onChange declares. A host that
91+
// forwards it through the node must not widen the dropdown.
92+
renderDirect({
93+
schema: { type: 'filter-builder', name: 'filters', fields: FIELDS, value: VALUE },
94+
extraOperators: ['exists', 'notExists'],
95+
onChange: vi.fn<(filter: FilterGroup) => void>(),
96+
});
97+
98+
// The row's controls are Radix comboboxes in field / operator / value
99+
// order, so index 1 is the operator.
100+
fireEvent.keyDown(screen.getAllByRole('combobox')[1], { key: 'ArrowDown' });
101+
const labels = await waitFor(() => {
102+
const options = screen.getAllByRole('option').map((o) => o.textContent);
103+
expect(options, 'the operator dropdown did not open').toContain('Is empty');
104+
return options;
105+
});
106+
expect(labels).not.toContain('Is set');
107+
expect(labels).not.toContain('Is not set');
108+
});
109+
110+
it('skips the emit, and names the operator on the console, for a stored row the FilterGroup cannot carry', () => {
111+
const onChange = vi.fn<(filter: FilterGroup) => void>();
112+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
113+
// Off contract on purpose: `exists` is the builder's opt-in operator, not
114+
// a protocol one, and the zod mirror refuses it in an authored `value`.
115+
// A stored value that skipped that validation is the one way in left.
116+
const offContract = {
117+
...VALUE,
118+
conditions: [{ id: 'c1', field: 'name', operator: 'exists' }],
119+
};
120+
try {
121+
renderDirect({
122+
schema: { type: 'filter-builder', name: 'filters', fields: FIELDS, value: offContract },
123+
onChange,
124+
});
125+
126+
fireEvent.click(screen.getByRole('button', { name: /add filter/i }));
127+
128+
expect(onChange).not.toHaveBeenCalled();
129+
const messages = consoleError.mock.calls.map((args) => String(args[0]));
130+
expect(messages.filter((m) => m.startsWith('filter-builder: onChange not called'))).toHaveLength(1);
131+
expect(messages.join('\n')).toMatch(/operator "exists", which is outside the protocol/);
132+
} finally {
133+
consoleError.mockRestore();
134+
}
135+
});
136+
137+
it('pins the declared signature at the type level', () => {
138+
expectTypeOf<NonNullable<FilterBuilderSchema['onChange']>>().parameter(0).toEqualTypeOf<FilterGroup>();
139+
140+
// @ts-expect-error — the form-style event the renderer used to send is not the declared argument.
141+
const eventHandler: NonNullable<FilterBuilderSchema['onChange']> = (event: { target: { name?: string; value: unknown } }) => {
142+
void event.target.value;
143+
};
144+
expect(typeof eventHandler).toBe('function');
145+
});
146+
});

‎ui/packages/components/src/renderers/complex/filter-builder.tsx‎

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,28 +13,66 @@ import { useDisplayLocale } from '@object-ui/i18n';
1313
// use of it): this repo has its OWN `resolveKeyedI18nLabel` over a DIFFERENT
1414
// vocabulary, and neither resolver accepts the other's shape. `schema.label` is
1515
// the spec's INLINE locale map — see `BaseSchema.label` (objectui#4580).
16-
import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui';
17-
import { FilterBuilder } from '../../custom/filter-builder';
16+
import { resolveI18nLabel as resolveInlineI18nLabel, VIEW_FILTER_OPERATORS } from '@objectstack/spec/ui';
17+
import { FilterBuilder, type FilterBuilderRowGroup } from '../../custom/filter-builder';
1818
import type { RendererHostProps } from '../../lib/renderer-host-props';
1919

20+
const PROTOCOL_OPERATOR_IDS: ReadonlySet<string> = new Set(VIEW_FILTER_OPERATORS);
21+
22+
/**
23+
* Is every row of the builder's group on the protocol's operator vocabulary,
24+
* i.e. is it the `FilterGroup` that `FilterBuilderSchema.onChange` declares?
25+
*
26+
* The builder's own row also admits the opt-in `exists` / `notExists`, which a
27+
* `FilterGroup` cannot carry. This node never grants them: `FilterBuilderSchema`
28+
* declares no `extraOperators`, and the renderer below keeps a host's
29+
* `extraOperators` out of the builder. Its `value` is a `FilterGroup`, whose zod
30+
* mirror refuses an operator outside the protocol. A group that fails here
31+
* therefore came from a stored `value` that skipped that validation.
32+
*/
33+
function isAuthoredFilterGroup(group: FilterBuilderRowGroup): group is FilterBuilderRowGroup & FilterGroup {
34+
return group.conditions.every((condition) => PROTOCOL_OPERATOR_IDS.has(condition.operator));
35+
}
36+
2037
ComponentRegistry.register('filter-builder',
2138
// `value` is declared because it is read by name: a host that binds the node
2239
// (a form field) forwards the group it holds, the same authored `FilterGroup`
2340
// the node's own `value` is.
24-
({ schema, className, onChange, ...props }: { schema: FilterBuilderSchema; className?: string; onChange?: (event: any) => void; value?: FilterGroup; [key: string]: unknown } & RendererHostProps) => {
41+
//
42+
// `onChange` is typed by the schema's own declaration and is called with the
43+
// group (#26382). It was typed `(event: any) => void` and called with a
44+
// form-style `{ target: { name, value } }` event, so a handler written from
45+
// `FilterBuilderSchema.onChange` read `.logic` / `.conditions` off the event
46+
// and got `undefined`, and the `any` kept the compiler from saying so.
47+
//
48+
// `extraOperators` is taken out of the host spread and never reaches
49+
// `FilterBuilder`. It is the builder's only operator-granting prop, and what
50+
// it grants (`exists` / `notExists`) is outside the `FilterGroup` that
51+
// `onChange` declares. `FilterBuilderSchema` declares no such key, so a host
52+
// that forwards one is off contract, and the builder offers only protocol
53+
// operators.
54+
({ schema, className, onChange, extraOperators: _refusedExtraOperators, ...props }: { schema: FilterBuilderSchema; className?: string; onChange?: FilterBuilderSchema['onChange']; value?: FilterGroup; [key: string]: unknown } & RendererHostProps) => {
2555
// Read-time resolution against the display locale (objectui#4580 revised
2656
// Q1-A). `BaseSchema.label` accepts `string | I18nLabel`; rendering the map
2757
// straight into a text node THREW "Objects are not valid as a React child".
2858
const locale = useDisplayLocale();
29-
const handleChange = (value: any) => {
30-
if (onChange) {
31-
onChange({
32-
target: {
33-
name: schema.name,
34-
value: value,
35-
},
36-
});
59+
const handleChange = (group: FilterBuilderRowGroup) => {
60+
if (!onChange) return;
61+
// No dropdown on this node offers an operator outside the protocol, so
62+
// only a stored `value` that skipped its zod validation reaches this
63+
// branch. Handing the group on would make the declared `FilterGroup`
64+
// false, and a throw here would crash a click in the user's hands, so the
65+
// emit is skipped and the console names the operator.
66+
if (!isAuthoredFilterGroup(group)) {
67+
const offending = group.conditions.find((condition) => !PROTOCOL_OPERATOR_IDS.has(condition.operator));
68+
console.error(
69+
`filter-builder: onChange not called. A row holds the operator "${offending?.operator}", which is ` +
70+
"outside the protocol's view-filter operators, so the FilterGroup that onChange hands over cannot carry it. " +
71+
'Fix the stored filter value.',
72+
);
73+
return;
3774
}
75+
onChange(group);
3876
};
3977

4078
return (

0 commit comments

Comments
 (0)