@nocobase/client-v2 2.2.0-alpha.6 → 2.2.0-alpha.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/es/BaseApplication.d.ts +1 -0
  2. package/es/collection-field-interface/CollectionFieldInterface.d.ts +1 -0
  3. package/es/collection-field-interface/CollectionFieldInterfaceManager.d.ts +1 -0
  4. package/es/flow/components/FieldAssignExactDatePicker.d.ts +1 -0
  5. package/es/flow/components/FieldAssignValueInput.d.ts +7 -0
  6. package/es/flow/components/RunJSValueEditor.d.ts +1 -0
  7. package/es/flow/components/filter/FilterGroup.d.ts +1 -0
  8. package/es/flow/components/filter/VariableFilterItem.d.ts +1 -0
  9. package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +17 -2
  10. package/es/index.mjs +89 -89
  11. package/lib/index.js +97 -97
  12. package/package.json +8 -7
  13. package/src/BaseApplication.tsx +11 -6
  14. package/src/__tests__/app.test.tsx +55 -0
  15. package/src/collection-field-interface/CollectionFieldInterface.ts +1 -0
  16. package/src/collection-field-interface/CollectionFieldInterfaceManager.ts +1 -0
  17. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +145 -2
  18. package/src/components/form/ScanInput/useCodeScanner.ts +154 -2
  19. package/src/components/form/TypedVariableInput.tsx +12 -8
  20. package/src/components/form/__tests__/TypedVariableInput.test.tsx +44 -3
  21. package/src/flow/FlowPage.tsx +9 -1
  22. package/src/flow/__tests__/FlowPage.test.tsx +50 -3
  23. package/src/flow/__tests__/FlowRoute.test.tsx +2 -2
  24. package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +7 -3
  25. package/src/flow/actions/linkageRulesRefresh.tsx +2 -6
  26. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +0 -1
  27. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +253 -6
  28. package/src/flow/components/FieldAssignExactDatePicker.tsx +25 -11
  29. package/src/flow/components/FieldAssignValueInput.tsx +81 -15
  30. package/src/flow/components/FlowRoute.tsx +7 -3
  31. package/src/flow/components/RunJSValueEditor.tsx +9 -1
  32. package/src/flow/components/__tests__/FieldAssignValueInput.context.test.tsx +216 -0
  33. package/src/flow/components/filter/FilterGroup.tsx +33 -5
  34. package/src/flow/components/filter/VariableFilterItem.tsx +139 -6
  35. package/src/flow/components/filter/__tests__/FilterGroup.test.tsx +45 -0
  36. package/src/flow/components/filter/__tests__/VariableFilterItem.leftMetaTree.test.tsx +9 -0
  37. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +21 -0
  38. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +1 -0
  39. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +27 -12
  40. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +36 -0
  41. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +34 -0
  42. package/src/flow/models/blocks/form/QuickEditFormModel.tsx +189 -36
  43. package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +350 -2
  44. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +2 -1
  45. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +1 -0
  46. package/src/flow/models/fields/VariableFieldFormModel.tsx +3 -3
  47. package/src/flow/models/fields/__tests__/VariableFieldFormModel.test.tsx +51 -0
  48. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +20 -10
  49. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +24 -12
@@ -62,6 +62,51 @@ async function buildCollectionLeftMetaTreeLocal(ctx: any): Promise<MetaTreeNode[
62
62
  return await resolve(subTree);
63
63
  }
64
64
 
65
+ function buildCollectionFieldMetaNode(collection: any, path: string[]): MetaTreeNode | null {
66
+ if (!collection || !path.length) {
67
+ return null;
68
+ }
69
+
70
+ const normalizedPath = path[0] === 'collection' ? path.slice(1) : path;
71
+ if (!normalizedPath.length) {
72
+ return null;
73
+ }
74
+
75
+ const parentTitles: string[] = [];
76
+ let currentCollection = collection;
77
+ let currentNode: MetaTreeNode | null = null;
78
+
79
+ for (const segment of normalizedPath) {
80
+ const field =
81
+ currentCollection?.getField?.(segment) ??
82
+ currentCollection?.getFields?.()?.find?.((candidate: any) => candidate?.name === segment);
83
+ if (!field) {
84
+ return null;
85
+ }
86
+
87
+ const title = field.title || field.uiSchema?.title || field.name || segment;
88
+ currentNode = {
89
+ name: field.name || segment,
90
+ title,
91
+ type: field.type,
92
+ interface: field.interface,
93
+ uiSchema: field.uiSchema,
94
+ options: field.options,
95
+ paths: ['collection', ...normalizedPath.slice(0, parentTitles.length + 1)],
96
+ parentTitles: [...parentTitles],
97
+ } as MetaTreeNode;
98
+
99
+ if (field.targetCollection) {
100
+ parentTitles.push(title);
101
+ currentCollection = field.targetCollection;
102
+ } else {
103
+ currentCollection = null;
104
+ }
105
+ }
106
+
107
+ return currentNode;
108
+ }
109
+
65
110
  export interface VariableFilterItemValue {
66
111
  path: string;
67
112
  operator: string;
@@ -75,6 +120,7 @@ export interface VariableFilterItemProps {
75
120
  /** 筛选条件值对象 */
76
121
  value: VariableFilterItemValue;
77
122
  model: FlowModel;
123
+ disabled?: boolean;
78
124
  /**
79
125
  * 是否启用右侧 VariableInput(变量或静态值二合一)。
80
126
  * 默认 false:保持原有行为,右侧仅静态输入组件。
@@ -265,11 +311,48 @@ function normalizeRightValueInput(input: unknown) {
265
311
  return input;
266
312
  }
267
313
 
314
+ function findMetaTreeNodeByPath(metaTree: MetaTreeNode[], targetPath: string[]): MetaTreeNode | null {
315
+ if (!Array.isArray(metaTree) || !targetPath.length) {
316
+ return null;
317
+ }
318
+
319
+ const traverseByName = (nodes: MetaTreeNode[], path: string[]): MetaTreeNode | null => {
320
+ if (!path.length) {
321
+ return null;
322
+ }
323
+
324
+ const [head, ...rest] = path;
325
+ const matched = nodes.find((node) => String(node.name) === String(head));
326
+ if (!matched) {
327
+ return null;
328
+ }
329
+ if (!rest.length) {
330
+ return matched;
331
+ }
332
+ if (!Array.isArray(matched.children)) {
333
+ return null;
334
+ }
335
+ return traverseByName(matched.children as MetaTreeNode[], rest);
336
+ };
337
+
338
+ const byNames = traverseByName(metaTree, targetPath);
339
+ if (byNames) {
340
+ return byNames;
341
+ }
342
+
343
+ const topNames = new Set(metaTree.map((node) => String(node.name)));
344
+ if (!topNames.has(String(targetPath[0]))) {
345
+ return traverseByName(metaTree, targetPath.slice(1));
346
+ }
347
+
348
+ return null;
349
+ }
350
+
268
351
  /**
269
352
  * 上下文筛选项组件
270
353
  */
271
354
  export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
272
- ({ value, model, rightAsVariable, rightMetaTree, ignoreFieldNames, maxAssociationFieldDepth }) => {
355
+ ({ value, model, disabled = false, rightAsVariable, rightMetaTree, ignoreFieldNames, maxAssociationFieldDepth }) => {
273
356
  // 使用 View 上下文,确保可访问 ctx.view 的异步子树
274
357
  const ctx = useFlowViewContext();
275
358
  const t = model.translate;
@@ -334,6 +417,9 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
334
417
  // 处理左侧值变化(值由 converters 决定如何解析)
335
418
  const handleLeftChange = useCallback(
336
419
  (variableValue: string, meta?: MetaTreeNode) => {
420
+ if (disabled) {
421
+ return;
422
+ }
337
423
  const prevPath = value.path || '';
338
424
  const nextPath = variableValue || '';
339
425
  const changed = nextPath !== prevPath;
@@ -348,7 +434,7 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
348
434
  setLeftMeta(meta);
349
435
  }
350
436
  },
351
- [value],
437
+ [disabled, value],
352
438
  );
353
439
 
354
440
  // 自定义转换器来捕获 MetaTreeNode,并在选择左值时设置默认操作符
@@ -376,6 +462,9 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
376
462
  // 处理操作符变化
377
463
  const handleOperatorChange = useCallback(
378
464
  (operatorValue: string) => {
465
+ if (disabled) {
466
+ return;
467
+ }
379
468
  value.operator = operatorValue;
380
469
  const cur = operatorMetaList.find((op) => op.value === operatorValue);
381
470
  if (cur?.noValue) {
@@ -386,7 +475,7 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
386
475
  value.noValue = false;
387
476
  }
388
477
  },
389
- [operatorMetaList, value],
478
+ [disabled, operatorMetaList, value],
390
479
  );
391
480
 
392
481
  // 使用公共静态输入渲染器(抽取到 utils)
@@ -464,9 +553,12 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
464
553
 
465
554
  const setRightValue = useCallback(
466
555
  (next: unknown) => {
556
+ if (disabled) {
557
+ return;
558
+ }
467
559
  value.value = normalizeRightValueInput(next) as VariableFilterItemValue['value'];
468
560
  },
469
- [value],
561
+ [disabled, value],
470
562
  );
471
563
 
472
564
  // 右侧静态输入(无变量模式)与右侧 VariableInput 的静态渲染组件,统一复用
@@ -520,6 +612,7 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
520
612
  {...nextProps}
521
613
  style={{ width: '100%', ...pickStyle(nextProps.style) }}
522
614
  value={normalized}
615
+ disabled={disabled}
523
616
  onChange={(vals: any) => setRightValue(vals)}
524
617
  />
525
618
  </div>
@@ -529,10 +622,19 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
529
622
  const Comp = staticInputRenderer;
530
623
  return (
531
624
  <div style={{ flex: '1 1 40%', minWidth: 160, maxWidth: '100%' }}>
532
- <Comp value={rightValue} onChange={(val) => setRightValue(val)} />
625
+ <Comp value={rightValue} onChange={(val) => setRightValue(val)} disabled={disabled} />
533
626
  </div>
534
627
  );
535
- }, [operator, operatorMetaList, rightValue, staticInputRenderer, model.context.app, setRightValue, enumOptions]);
628
+ }, [
629
+ disabled,
630
+ operator,
631
+ operatorMetaList,
632
+ rightValue,
633
+ staticInputRenderer,
634
+ model.context.app,
635
+ setRightValue,
636
+ enumOptions,
637
+ ]);
536
638
 
537
639
  // Null 占位组件(仿照 DefaultValue.tsx 的实现)
538
640
  const NullComponent = useMemo(() => {
@@ -652,6 +754,34 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
652
754
  };
653
755
  }, [getFieldInterface, model, maxAssociationFieldDepth]);
654
756
 
757
+ useEffect(() => {
758
+ if (leftMeta || !value.path) {
759
+ return;
760
+ }
761
+
762
+ let cancelled = false;
763
+ const restore = async () => {
764
+ const nodes = await enhancedMetaTree();
765
+ if (cancelled) {
766
+ return;
767
+ }
768
+ const path = customConverters.resolvePathFromValue(value.path);
769
+ if (!path?.length) {
770
+ return;
771
+ }
772
+ const matched =
773
+ findMetaTreeNodeByPath(nodes, path) || buildCollectionFieldMetaNode(model.context.collection, path);
774
+ if (matched) {
775
+ setLeftMeta(matched);
776
+ }
777
+ };
778
+
779
+ restore();
780
+ return () => {
781
+ cancelled = true;
782
+ };
783
+ }, [customConverters, enhancedMetaTree, leftMeta, model.context.collection, value.path]);
784
+
655
785
  return (
656
786
  <Space wrap style={{ width: '100%' }}>
657
787
  <VariableInput
@@ -659,6 +789,7 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
659
789
  metaTree={enhancedMetaTree}
660
790
  onChange={handleLeftChange}
661
791
  converters={customConverters}
792
+ disabled={disabled}
662
793
  showValueComponent={false}
663
794
  style={{ flex: '1 1 40%', minWidth: 160, maxWidth: '100%' }}
664
795
  onlyLeafSelectable={true}
@@ -670,6 +801,7 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
670
801
  style={{ flex: '0 0 140px', minWidth: 120, maxWidth: '100%' }}
671
802
  placeholder={t('Comparison')}
672
803
  value={operator || undefined}
804
+ disabled={disabled}
673
805
  onChange={handleOperatorChange}
674
806
  >
675
807
  {operatorOptions.map((op) => (
@@ -686,6 +818,7 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
686
818
  onChange={(v) => setRightValue(v)}
687
819
  metaTree={mergedRightMetaTree}
688
820
  converters={rightConverters}
821
+ disabled={disabled}
689
822
  showValueComponent
690
823
  style={{ flex: '1 1 40%', minWidth: 160, maxWidth: '100%' }}
691
824
  placeholder={t('Enter value')}
@@ -140,4 +140,49 @@ describe('FilterGroup closeIcon', () => {
140
140
  expect(onChange).toHaveBeenCalledTimes(1);
141
141
  expect(value.items[0].items).toHaveLength(1);
142
142
  });
143
+
144
+ it('disables close and add actions when the filter group is disabled', () => {
145
+ const value = {
146
+ logic: '$and',
147
+ items: [
148
+ {
149
+ path: 'name',
150
+ operator: 'eq',
151
+ value: 'test',
152
+ },
153
+ ],
154
+ };
155
+ const onRemove = vi.fn();
156
+ const onChange = vi.fn();
157
+
158
+ renderWithProviders(
159
+ <FilterGroup
160
+ value={value}
161
+ FilterItem={DummyFilterItem}
162
+ showBorder
163
+ onRemove={onRemove}
164
+ onChange={onChange}
165
+ disabled
166
+ />,
167
+ );
168
+
169
+ const closeButtons = screen
170
+ .getAllByLabelText('icon-close')
171
+ .map((element) => element.closest('button'))
172
+ .filter(Boolean) as HTMLButtonElement[];
173
+ expect(closeButtons).toHaveLength(2);
174
+ expect(closeButtons[0]).toBeDisabled();
175
+ expect(closeButtons[1]).toBeDisabled();
176
+ expect(screen.getByText('Add condition').closest('button')).toBeDisabled();
177
+ expect(screen.getByText('Add condition group').closest('button')).toBeDisabled();
178
+
179
+ fireEvent.click(closeButtons[0]);
180
+ fireEvent.click(closeButtons[1]);
181
+ fireEvent.click(screen.getByText('Add condition').closest('button') as HTMLButtonElement);
182
+ fireEvent.click(screen.getByText('Add condition group').closest('button') as HTMLButtonElement);
183
+
184
+ expect(onRemove).not.toHaveBeenCalled();
185
+ expect(onChange).not.toHaveBeenCalled();
186
+ expect(value.items).toHaveLength(1);
187
+ });
143
188
  });
@@ -193,4 +193,13 @@ describe('VariableFilterItem with leftMetaTree', () => {
193
193
  fireEvent.click(screen.getByTestId('variable-input'));
194
194
  expect(value.path).toBe('title');
195
195
  });
196
+
197
+ it('restores the saved left meta so the operator select shows its label on reopen', async () => {
198
+ const value = { path: 'title', operator: '$eq', value: 'x' } as any;
199
+ const model = createModelWithCollection();
200
+
201
+ render(<VariableFilterItem value={value} model={model} rightAsVariable={false} />);
202
+
203
+ expect(await screen.findByText('Equals')).toBeInTheDocument();
204
+ });
196
205
  });
@@ -158,6 +158,27 @@ describe('VariableFilterItem', () => {
158
158
  expect(value.value).toBe('abc');
159
159
  });
160
160
 
161
+ it('passes disabled through to the left selector, operator select, and right variable input', async () => {
162
+ const value: VariableFilterItemValue = { path: '', operator: '', value: '' };
163
+ const model = CreateModel();
164
+
165
+ render(<VariableFilterItem value={value} model={model} rightAsVariable disabled />);
166
+
167
+ const leftVariableInputProps = (globalThis as any).__LAST_VARIABLE_INPUT_PROPS__;
168
+ expect(leftVariableInputProps.disabled).toBe(true);
169
+
170
+ fireEvent.click(screen.getAllByTestId('variable-input')[0]);
171
+
172
+ const variableInputs = screen.getAllByTestId('variable-input');
173
+ expect(variableInputs.length).toBeGreaterThanOrEqual(2);
174
+ const rightVariableInputProps = (globalThis as any).__LAST_VARIABLE_INPUT_PROPS__;
175
+ expect(rightVariableInputProps.disabled).toBe(true);
176
+
177
+ const operatorSelect = document.body.querySelector('.ant-select') as HTMLDivElement | null;
178
+ expect(operatorSelect).not.toBeNull();
179
+ expect(operatorSelect).toHaveClass('ant-select-disabled');
180
+ });
181
+
161
182
  it('uses scoped context dataSourceManager when app dataSourceManager has no field interface manager', async () => {
162
183
  const value = observable({ path: '', operator: '', value: '' }) as any;
163
184
  const model = CreateModel();
@@ -431,6 +431,7 @@ export class FilterFormBlockModel extends FilterBlockModel<{
431
431
 
432
432
  private canApplyFormDefaultValue(name: string, current: any, force?: boolean) {
433
433
  if (force) return true;
434
+ if (this.userEditedFieldNames.has(name)) return false;
434
435
  if (isEmptyValue(current)) return true;
435
436
  if (!this.lastDefaultValueByFieldName.has(name)) return false;
436
437
  return isEqual(current, this.lastDefaultValueByFieldName.get(name));
@@ -62,9 +62,25 @@ function isRecord(value: unknown): value is Record<string, unknown> {
62
62
  return !!value && typeof value === 'object' && !Array.isArray(value);
63
63
  }
64
64
 
65
+ interface CollectionFieldWithAssociationMarker {
66
+ isAssociationField?: () => boolean;
67
+ target?: unknown;
68
+ }
69
+
70
+ function hasAssociationMarker(value: unknown): value is CollectionFieldWithAssociationMarker {
71
+ return isRecord(value) && (typeof value.isAssociationField === 'function' || 'target' in value);
72
+ }
73
+
74
+ const isAssociationCollectionField = (collectionField: unknown) => {
75
+ if (!hasAssociationMarker(collectionField)) {
76
+ return false;
77
+ }
78
+ return collectionField.isAssociationField ? collectionField.isAssociationField() : Boolean(collectionField.target);
79
+ };
80
+
65
81
  const normalizeAssociationDefaultFilterValue = (value: any, fieldModel: any) => {
66
82
  const collectionField = fieldModel?.context?.collectionField;
67
- if (!collectionField?.isAssociationField?.()) {
83
+ if (!isAssociationCollectionField(collectionField)) {
68
84
  return value;
69
85
  }
70
86
 
@@ -171,8 +187,7 @@ const buildFilterFormFieldItem = ({
171
187
  if (!binding) {
172
188
  return;
173
189
  }
174
- const isAssociation =
175
- typeof field?.isAssociationField === 'function' ? field.isAssociationField() : Boolean(field?.target);
190
+ const isAssociation = isAssociationCollectionField(field);
176
191
  const fieldModel =
177
192
  isAssociation && ctxWithFlags.engine?.getModelClass?.('FilterFormRecordSelectFieldModel')
178
193
  ? 'FilterFormRecordSelectFieldModel'
@@ -507,10 +522,7 @@ export class FilterFormItemModel extends FilterableItemModel<{
507
522
  const formValue = this.context.form?.getFieldValue(this.props.name);
508
523
  const modelValue = fieldModel.getFilterValue ? fieldModel.getFilterValue() : formValue;
509
524
  const collectionField = (fieldModel as any)?.context?.collectionField;
510
- const isAssociationField =
511
- typeof collectionField?.isAssociationField === 'function'
512
- ? collectionField.isAssociationField()
513
- : !!collectionField?.target;
525
+ const isAssociationField = isAssociationCollectionField(collectionField);
514
526
  const shouldUseFormValue =
515
527
  isAssociationField &&
516
528
  !this.mounted &&
@@ -547,11 +559,7 @@ export class FilterFormItemModel extends FilterableItemModel<{
547
559
  return value;
548
560
  }
549
561
  const collectionField = (fieldModel as any)?.context?.collectionField;
550
- const isAssociation =
551
- typeof collectionField?.isAssociationField === 'function'
552
- ? collectionField.isAssociationField()
553
- : !!collectionField?.target;
554
- if (!isAssociation) {
562
+ if (!isAssociationCollectionField(collectionField)) {
555
563
  return value;
556
564
  }
557
565
  const normalizedFieldNames = normalizeAssociationFieldNames(
@@ -798,6 +806,13 @@ FilterFormItemModel.registerFlow({
798
806
  },
799
807
  defaultOperator: {
800
808
  use: 'defaultOperator',
809
+ hideInSettings(ctx) {
810
+ const collectionField =
811
+ ctx.collectionField ||
812
+ ctx.model?.context?.collectionField ||
813
+ ctx.model?.subModels?.field?.context?.collectionField;
814
+ return isAssociationCollectionField(collectionField);
815
+ },
801
816
  },
802
817
  operatorComponentProps: {
803
818
  use: 'operatorComponentProps',
@@ -33,6 +33,42 @@ class DummyCollectionBlockModel extends CollectionBlockModel {
33
33
  }
34
34
 
35
35
  describe('FilterFormItemModel defineChildren association fields', () => {
36
+ it('hides default operator setting for association filter fields', () => {
37
+ const engine = new FlowEngine();
38
+ engine.registerModels({
39
+ FilterFormItemModel,
40
+ });
41
+
42
+ const filterItem = engine.createModel<FilterFormItemModel>({
43
+ uid: 'association-filter-item-settings',
44
+ use: 'FilterFormItemModel',
45
+ });
46
+
47
+ const defaultOperatorStep = filterItem.getFlow('filterFormItemSettings')?.steps?.defaultOperator as {
48
+ hideInSettings?: (ctx: {
49
+ collectionField?: unknown;
50
+ model?: {
51
+ subModels?: {
52
+ field?: {
53
+ context?: {
54
+ collectionField?: unknown;
55
+ };
56
+ };
57
+ };
58
+ };
59
+ }) => boolean;
60
+ };
61
+ expect(defaultOperatorStep?.hideInSettings?.({ collectionField: { isAssociationField: () => true } })).toBe(true);
62
+ expect(
63
+ defaultOperatorStep?.hideInSettings?.({
64
+ model: { subModels: { field: { context: { collectionField: { target: 'departments' } } } } },
65
+ }),
66
+ ).toBe(true);
67
+ expect(defaultOperatorStep?.hideInSettings?.({ collectionField: { interface: 'input', type: 'string' } })).toBe(
68
+ false,
69
+ );
70
+ });
71
+
36
72
  it('groups association target fields and supports recursive paths', async () => {
37
73
  const engine = new FlowEngine();
38
74
  engine.registerModels({
@@ -301,6 +301,40 @@ describe('filter-form defaultValues wiring', () => {
301
301
  expect(values.username_user).toBe('Manual');
302
302
  });
303
303
 
304
+ it('does not reapply a filter form default value after user clears the field', async () => {
305
+ const { model, values } = createFilterFormDefaultValuesModel([
306
+ {
307
+ key: 'username-default',
308
+ enable: true,
309
+ targetPath: 'username',
310
+ mode: 'default',
311
+ value: 'admin',
312
+ },
313
+ ]);
314
+
315
+ await FilterFormBlockModel.prototype.applyFormDefaultValues.call(model as any);
316
+ expect(values.username_user).toBe('admin');
317
+
318
+ values.username_user = undefined;
319
+ (model as any).handleFilterFormValuesChange({ username_user: undefined }, { username_user: undefined });
320
+
321
+ await waitFor(() => {
322
+ expect(model.dispatchEvent).toHaveBeenCalledWith(
323
+ 'formValuesChange',
324
+ {
325
+ changedValues: {
326
+ username_user: undefined,
327
+ },
328
+ allValues: {
329
+ username_user: undefined,
330
+ },
331
+ },
332
+ { debounce: true },
333
+ );
334
+ });
335
+ expect(values.username_user).toBeUndefined();
336
+ });
337
+
304
338
  it('applies fixed values even when the target filter field already has a value', async () => {
305
339
  const { model, values } = createFilterFormDefaultValuesModel(
306
340
  [