@nocobase/client-v2 2.1.0-alpha.45 → 2.1.0-alpha.47

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 (99) hide show
  1. package/es/authRedirect.d.ts +12 -0
  2. package/es/collection-manager/filter-operators/index.d.ts +1 -0
  3. package/es/components/form/TypedVariableInput.d.ts +75 -0
  4. package/es/components/form/filter/CollectionFilterItem.d.ts +5 -1
  5. package/es/components/form/filter/FilterValueInput.d.ts +5 -0
  6. package/es/components/form/index.d.ts +1 -0
  7. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +1 -0
  8. package/es/flow/components/ConditionBuilder.d.ts +1 -0
  9. package/es/flow/components/FieldAssignRulesEditor.d.ts +3 -1
  10. package/es/flow/components/FieldAssignValueInput.d.ts +1 -0
  11. package/es/flow/components/fieldAssignOptions.d.ts +10 -0
  12. package/es/flow/components/filter/LinkageFilterItem.d.ts +1 -0
  13. package/es/flow/components/filter/VariableFilterItem.d.ts +1 -0
  14. package/es/flow/components/filter/metaTreeAssociationDepth.d.ts +13 -0
  15. package/es/flow/internal/utils/operatorSchemaHelper.d.ts +26 -3
  16. package/es/flow/models/base/AssociationFieldGroupModel.d.ts +2 -33
  17. package/es/flow/models/blocks/assign-form/assignFieldValuesFlow.d.ts +8 -0
  18. package/es/flow/models/blocks/filter-manager/utils.d.ts +23 -1
  19. package/es/flow/models/blocks/table/TableColumnModel.d.ts +2 -0
  20. package/es/index.mjs +106 -106
  21. package/lib/index.js +106 -106
  22. package/package.json +7 -7
  23. package/src/__tests__/browserChecker.test.ts +103 -0
  24. package/src/__tests__/dataSourceRuntime.test.tsx +22 -1
  25. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +5 -0
  26. package/src/authRedirect.ts +43 -0
  27. package/src/collection-field-interface/CollectionFieldInterface.ts +14 -7
  28. package/src/collection-manager/filter-operators/index.ts +29 -2
  29. package/src/components/README.md +48 -0
  30. package/src/components/README.zh-CN.md +48 -0
  31. package/src/components/form/TypedVariableInput.tsx +441 -0
  32. package/src/components/form/__tests__/TypedVariableInput.test.tsx +152 -0
  33. package/src/components/form/filter/CollectionFilterItem.tsx +10 -2
  34. package/src/components/form/filter/FilterValueInput.tsx +27 -2
  35. package/src/components/form/filter/__tests__/FilterValueInput.test.tsx +39 -0
  36. package/src/components/form/index.tsx +1 -0
  37. package/src/flow/__tests__/FlowRoute.test.tsx +8 -1
  38. package/src/flow/actions/__tests__/linkageRules.hiddenSync.restore.test.ts +100 -0
  39. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +369 -2
  40. package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +23 -1
  41. package/src/flow/actions/dataScope.tsx +8 -1
  42. package/src/flow/actions/linkageRules.tsx +61 -5
  43. package/src/flow/actions/linkageRulesRefresh.tsx +2 -3
  44. package/src/flow/actions/setTargetDataScope.tsx +1 -1
  45. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +15 -1
  46. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +75 -1
  47. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +11 -0
  48. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +2 -4
  49. package/src/flow/common/Markdown/Edit.tsx +32 -4
  50. package/src/flow/components/ConditionBuilder.tsx +12 -2
  51. package/src/flow/components/DefaultValue.tsx +14 -8
  52. package/src/flow/components/FieldAssignRulesEditor.tsx +37 -46
  53. package/src/flow/components/FieldAssignValueInput.tsx +14 -14
  54. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +112 -1
  55. package/src/flow/components/__tests__/fieldAssignOptions.test.ts +71 -1
  56. package/src/flow/components/code-editor/__tests__/runjsCompletions.test.ts +272 -27
  57. package/src/flow/components/code-editor/runjsCompletions.ts +490 -9
  58. package/src/flow/components/fieldAssignOptions.ts +23 -6
  59. package/src/flow/components/filter/LinkageFilterItem.tsx +26 -12
  60. package/src/flow/components/filter/VariableFilterItem.tsx +32 -13
  61. package/src/flow/components/filter/__tests__/fieldsToOptions.test.ts +48 -0
  62. package/src/flow/components/filter/__tests__/metaTreeAssociationDepth.test.ts +60 -0
  63. package/src/flow/components/filter/fieldsToOptions.ts +9 -4
  64. package/src/flow/components/filter/metaTreeAssociationDepth.ts +69 -0
  65. package/src/flow/internal/utils/operatorSchemaHelper.ts +112 -5
  66. package/src/flow/models/actions/FilterActionModel.tsx +15 -3
  67. package/src/flow/models/actions/__tests__/AssignFormRefill.test.ts +90 -0
  68. package/src/flow/models/base/AssociationFieldGroupModel.tsx +20 -3
  69. package/src/flow/models/base/GridModel.tsx +1 -1
  70. package/src/flow/models/base/PageModel/ChildPageModel.tsx +5 -1
  71. package/src/flow/models/base/PageModel/__tests__/ChildPageModel.test.ts +12 -0
  72. package/src/flow/models/base/__tests__/AssociationFieldGroupModel.test.ts +86 -0
  73. package/src/flow/models/base/__tests__/GridModel.render.test.tsx +53 -0
  74. package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +12 -3
  75. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +14 -4
  76. package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +216 -0
  77. package/src/flow/models/blocks/assign-form/assignFieldValuesFlow.tsx +142 -40
  78. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +14 -12
  79. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +6 -1
  80. package/src/flow/models/blocks/filter-manager/__tests__/getDefaultOperator.test.ts +35 -1
  81. package/src/flow/models/blocks/filter-manager/flow-actions/__tests__/customizeFilterRender.test.tsx +148 -0
  82. package/src/flow/models/blocks/filter-manager/flow-actions/__tests__/defaultOperator.test.tsx +85 -0
  83. package/src/flow/models/blocks/filter-manager/flow-actions/connectFields.tsx +48 -6
  84. package/src/flow/models/blocks/filter-manager/flow-actions/customizeFilterRender.tsx +14 -44
  85. package/src/flow/models/blocks/filter-manager/flow-actions/defaultOperator.tsx +3 -5
  86. package/src/flow/models/blocks/filter-manager/flow-actions/operatorComponentProps.tsx +2 -12
  87. package/src/flow/models/blocks/filter-manager/utils.ts +143 -4
  88. package/src/flow/models/blocks/form/FormAssociationItemModel.tsx +9 -0
  89. package/src/flow/models/blocks/form/FormItemModel.tsx +9 -0
  90. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +17 -0
  91. package/src/flow/models/blocks/form/__tests__/FormItemModel.defineChildren.test.ts +27 -2
  92. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +29 -0
  93. package/src/flow/models/blocks/form/value-runtime/runtime.ts +5 -1
  94. package/src/flow/models/blocks/table/TableColumnModel.tsx +15 -5
  95. package/src/flow/models/blocks/table/TableCustomColumnModel.tsx +2 -2
  96. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +18 -0
  97. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +6 -7
  98. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +5 -3
  99. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +70 -0
@@ -15,7 +15,7 @@ import {
15
15
  useFlowEngine,
16
16
  useFlowSettingsContext,
17
17
  } from '@nocobase/flow-engine';
18
- import React, { useEffect, useRef } from 'react';
18
+ import React, { useEffect } from 'react';
19
19
  import { CollectionActionModel } from '../../base/CollectionActionModel';
20
20
  import { RecordActionModel } from '../../base/RecordActionModel';
21
21
  import { AssignFormModel } from './AssignFormModel';
@@ -38,6 +38,9 @@ type AssignFieldValuesModel = {
38
38
  uid: string;
39
39
  assignFormUid?: string;
40
40
  context?: AssignFieldValuesContext;
41
+ subModels?: {
42
+ assignForm?: AssignFormModel;
43
+ };
41
44
  getStepParams?: (flowKey: string, stepKey: string) => { assignedValues?: AssignedValues } | undefined;
42
45
  setStepParams?: (flowKey: string, stepKey: string, params: { assignedValues: AssignedValues }) => void;
43
46
  };
@@ -62,6 +65,81 @@ function getResourceInit(ctx: AssignFieldValuesContext): { dataSourceKey: string
62
65
  return dsKey && collName ? { dataSourceKey: dsKey, collectionName: collName } : undefined;
63
66
  }
64
67
 
68
+ function resolveInitFromFlowSettings(
69
+ blockModel: unknown,
70
+ actionContext: AssignFieldValuesContext | undefined,
71
+ ): { dataSourceKey: string; collectionName: string } | undefined {
72
+ const maybeBlockCollection = (blockModel as { collection?: { dataSourceKey?: string; name?: string } } | undefined)
73
+ ?.collection;
74
+ const collection = maybeBlockCollection || getContextCollection(actionContext);
75
+ const dsKey = collection?.dataSourceKey;
76
+ const collName = collection?.name;
77
+ return dsKey && collName ? { dataSourceKey: dsKey, collectionName: collName } : undefined;
78
+ }
79
+
80
+ function isValidResourceInit(init: unknown): init is {
81
+ dataSourceKey: string;
82
+ collectionName: string;
83
+ } {
84
+ if (!init || typeof init !== 'object') return false;
85
+ const obj = init as { dataSourceKey?: unknown; collectionName?: unknown };
86
+ return (
87
+ typeof obj.dataSourceKey === 'string' &&
88
+ !!obj.dataSourceKey &&
89
+ typeof obj.collectionName === 'string' &&
90
+ !!obj.collectionName
91
+ );
92
+ }
93
+
94
+ type LocalCacheContext = {
95
+ _observableCache?: Record<string, unknown>;
96
+ _cache?: Record<string, unknown>;
97
+ _pending?: Record<string, unknown>;
98
+ };
99
+
100
+ function toLocalCacheContext(context: unknown): LocalCacheContext | undefined {
101
+ return context && typeof context === 'object' ? (context as LocalCacheContext) : undefined;
102
+ }
103
+
104
+ function clearOwnContextCache(context: unknown, key: string) {
105
+ const localContext = toLocalCacheContext(context);
106
+ if (!localContext) {
107
+ return;
108
+ }
109
+ delete localContext._observableCache?.[key];
110
+ delete localContext._cache?.[key];
111
+ delete localContext._pending?.[key];
112
+ }
113
+
114
+ function clearResourceContextCache(model: { context?: unknown } | undefined) {
115
+ clearOwnContextCache(model?.context, 'dataSource');
116
+ clearOwnContextCache(model?.context, 'collection');
117
+ clearOwnContextCache(model?.context, 'resource');
118
+ clearOwnContextCache(model?.context, 'association');
119
+ }
120
+
121
+ function resolveAssignFormModel(ctx: {
122
+ model: AssignFieldValuesModel;
123
+ engine: {
124
+ getModel?: (uid: string, fromRoot?: boolean) => AssignFormModel | undefined;
125
+ findModelByParentId?: (parentId: string, subKey: string) => AssignFormModel | undefined | null;
126
+ };
127
+ }) {
128
+ if (ctx.model.assignFormUid) {
129
+ const form = ctx.engine.getModel?.(ctx.model.assignFormUid, true);
130
+ if (form) {
131
+ return form;
132
+ }
133
+ }
134
+
135
+ const localForm = ctx.model.subModels?.assignForm;
136
+ if (localForm) {
137
+ return localForm;
138
+ }
139
+
140
+ return ctx.engine.findModelByParentId?.(ctx.model.uid, 'assignForm') || undefined;
141
+ }
142
+
65
143
  export function createAssignFormSubModelOptions(ctx: AssignFieldValuesContext) {
66
144
  return {
67
145
  async: true,
@@ -114,21 +192,52 @@ function AssignFieldsEditor(props: { settingsFlowKey: string; clearRecordContext
114
192
  const { model, blockModel } = useFlowSettingsContext();
115
193
  const action = model as AssignFieldValuesModel;
116
194
  const engine = useFlowEngine();
117
- const initializedRef = useRef(false);
118
195
  const [formModel, setFormModel] = React.useState<AssignFormModel | null>(null);
119
196
 
120
197
  useEffect(() => {
121
198
  let cancelled = false;
122
199
  const loadAssignForm = async () => {
200
+ const init = resolveInitFromFlowSettings(blockModel, action?.context);
123
201
  const loaded = (await engine.loadOrCreateModel(
124
- {
125
- parentId: action.uid,
126
- subKey: 'assignForm',
127
- use: 'AssignFormModel',
128
- },
202
+ init
203
+ ? {
204
+ parentId: action.uid,
205
+ subKey: 'assignForm',
206
+ use: 'AssignFormModel',
207
+ stepParams: { resourceSettings: { init } },
208
+ }
209
+ : {
210
+ parentId: action.uid,
211
+ subKey: 'assignForm',
212
+ use: 'AssignFormModel',
213
+ },
129
214
  { skipSave: !model.context.flowSettingsEnabled },
130
215
  )) as AssignFormModel;
131
216
  if (cancelled) return;
217
+
218
+ if (isValidResourceInit(init)) {
219
+ const existingInit = loaded.getStepParams?.('resourceSettings', 'init');
220
+ // Ensure `resourceSettings.init` is available before first render to avoid cached undefined `collection`
221
+ if (
222
+ !isValidResourceInit(existingInit) ||
223
+ existingInit.dataSourceKey !== init.dataSourceKey ||
224
+ existingInit.collectionName !== init.collectionName
225
+ ) {
226
+ loaded.setStepParams?.('resourceSettings', 'init', init);
227
+ }
228
+ clearResourceContextCache(loaded);
229
+ clearResourceContextCache(loaded.subModels?.grid);
230
+ }
231
+
232
+ const prev = action.getStepParams?.(props.settingsFlowKey, ASSIGN_FIELD_VALUES_STEP_KEY) || {};
233
+ loaded.setInitialAssignedValues(prev?.assignedValues || {});
234
+
235
+ const isBulkCollectionAction = model instanceof CollectionActionModel && !(model instanceof RecordActionModel);
236
+ const isBulk = props.clearRecordContext || isBulkCollectionAction;
237
+ if (isBulk && loaded.context?.defineProperty) {
238
+ loaded.context.defineProperty('record', { get: () => undefined, cache: false });
239
+ }
240
+
132
241
  setFormModel(loaded);
133
242
  action.assignFormUid = loaded?.uid || action.assignFormUid;
134
243
  };
@@ -136,31 +245,15 @@ function AssignFieldsEditor(props: { settingsFlowKey: string; clearRecordContext
136
245
  return () => {
137
246
  cancelled = true;
138
247
  };
139
- }, [action, engine, model.context.flowSettingsEnabled]);
140
-
141
- useEffect(() => {
142
- if (initializedRef.current) return;
143
- if (!formModel) return;
144
-
145
- const prev = action.getStepParams?.(props.settingsFlowKey, ASSIGN_FIELD_VALUES_STEP_KEY) || {};
146
- const coll = blockModel?.collection || getContextCollection(action?.context);
147
- const dsKey = coll?.dataSourceKey;
148
- const collName = coll?.name;
149
- if (dsKey && collName) {
150
- formModel.setStepParams('resourceSettings', 'init', {
151
- dataSourceKey: dsKey,
152
- collectionName: collName,
153
- });
154
- }
155
- formModel.setInitialAssignedValues(prev?.assignedValues || {});
156
-
157
- const isBulk =
158
- props.clearRecordContext || (action instanceof CollectionActionModel && !(action instanceof RecordActionModel));
159
- if (isBulk && formModel.context?.defineProperty) {
160
- formModel.context.defineProperty('record', { get: () => undefined, cache: false });
161
- }
162
- initializedRef.current = true;
163
- }, [action, blockModel?.collection, formModel, props.clearRecordContext, props.settingsFlowKey]);
248
+ }, [
249
+ action,
250
+ blockModel,
251
+ engine,
252
+ model,
253
+ model.context.flowSettingsEnabled,
254
+ props.clearRecordContext,
255
+ props.settingsFlowKey,
256
+ ]);
164
257
 
165
258
  return formModel ? <FlowModelRenderer model={formModel} showFlowSettings={false} /> : null;
166
259
  }
@@ -187,14 +280,23 @@ export function createAssignFieldValuesStep(options: AssignFieldValuesStepOption
187
280
  },
188
281
  };
189
282
  },
190
- async beforeParamsSave(ctx: {
191
- model: AssignFieldValuesModel;
192
- engine: {
193
- getModel?: (uid: string, fromRoot?: boolean) => AssignFormModel | undefined;
194
- };
195
- }) {
196
- const form = ctx.model.assignFormUid ? ctx.engine.getModel?.(ctx.model.assignFormUid, true) : undefined;
197
- if (!form) return;
283
+ async beforeParamsSave(
284
+ ctx: {
285
+ model: AssignFieldValuesModel;
286
+ engine: {
287
+ getModel?: (uid: string, fromRoot?: boolean) => AssignFormModel | undefined;
288
+ findModelByParentId?: (parentId: string, subKey: string) => AssignFormModel | undefined | null;
289
+ };
290
+ },
291
+ params?: { assignedValues?: AssignedValues },
292
+ previousParams?: { assignedValues?: AssignedValues },
293
+ ) {
294
+ const form = resolveAssignFormModel(ctx);
295
+ if (!form) {
296
+ const assignedValues = params?.assignedValues || previousParams?.assignedValues || {};
297
+ ctx.model.setStepParams?.(options.settingsFlowKey, ASSIGN_FIELD_VALUES_STEP_KEY, { assignedValues });
298
+ return;
299
+ }
198
300
  if (options.validateBeforeSave) {
199
301
  await form.form?.validateFields?.();
200
302
  }
@@ -22,7 +22,12 @@ import { CollectionBlockModel, FieldModel } from '../../base';
22
22
  import { RecordSelectFieldModel } from '../../fields/AssociationFieldModel/RecordSelectFieldModel';
23
23
  import { normalizeAssociationFieldNames } from '../../fields/AssociationFieldModel/recordSelectShared';
24
24
  import { FilterManager } from '../filter-manager';
25
- import { getAllDataModels, getDefaultOperator, isFilterValueEmpty } from '../filter-manager/utils';
25
+ import {
26
+ getAllDataModels,
27
+ getDefaultOperator,
28
+ getFilterFormOperatorMeta,
29
+ isFilterValueEmpty,
30
+ } from '../filter-manager/utils';
26
31
  import { FilterFormFieldModel } from './fields';
27
32
  import { normalizeFilterValueByOperator } from './valueNormalization';
28
33
 
@@ -51,7 +56,11 @@ const getTargetFilterableFields = (field: any, collection?: Collection, model?:
51
56
  return (targetCollection.getFields() || []).filter((childField: any) => childField?.filterable);
52
57
  };
53
58
 
54
- const MAX_ASSOCIATION_DEPTH = 5;
59
+ const MAX_ASSOCIATION_DEPTH = 2;
60
+
61
+ function isRecord(value: unknown): value is Record<string, unknown> {
62
+ return !!value && typeof value === 'object' && !Array.isArray(value);
63
+ }
55
64
 
56
65
  const normalizeAssociationDefaultFilterValue = (value: any, fieldModel: any) => {
57
66
  const collectionField = fieldModel?.context?.collectionField;
@@ -449,15 +458,7 @@ export class FilterFormItemModel extends FilterableItemModel<{
449
458
 
450
459
  private getCurrentOperatorMeta() {
451
460
  const operator = getDefaultOperator(this);
452
- if (!operator) return null;
453
-
454
- const operatorList = this.collectionField?.filterable?.operators;
455
-
456
- if (!Array.isArray(operatorList)) {
457
- return null;
458
- }
459
-
460
- return operatorList.find((op) => op.value === operator) || null;
461
+ return getFilterFormOperatorMeta(this, operator);
461
462
  }
462
463
 
463
464
  onInit(options: any) {
@@ -530,7 +531,8 @@ export class FilterFormItemModel extends FilterableItemModel<{
530
531
  rawValue = this.normalizeAssociationFilterValue(rawValue, fieldModel);
531
532
  const operatorMeta = this.getCurrentOperatorMeta();
532
533
  if (operatorMeta?.noValue) {
533
- const options = operatorMeta?.schema?.['x-component-props']?.options;
534
+ const componentProps = operatorMeta?.schema?.['x-component-props'];
535
+ const options = isRecord(componentProps) ? componentProps.options : undefined;
534
536
  if (Array.isArray(options)) {
535
537
  return rawValue;
536
538
  }
@@ -136,9 +136,13 @@ describe('FilterFormItemModel defineChildren association fields', () => {
136
136
  );
137
137
  const managerGroups = await managerAssociation.children();
138
138
  const managerFieldsGroup = managerGroups.find((group: any) => group.key === 'department.manager-fields');
139
+ const managerAssociationGroup = managerGroups.find(
140
+ (group: any) => group.key === 'department.manager-relation-fields',
141
+ );
139
142
 
140
143
  const managerFieldKeys = (managerFieldsGroup?.children || []).map((item: any) => item.key);
141
144
  expect(managerFieldKeys).toContain('department.manager.name');
145
+ expect(managerAssociationGroup).toBeUndefined();
142
146
 
143
147
  const targetItem = managerFieldsGroup?.children?.find((item: any) => item.key === 'department.manager.name');
144
148
  const createOptions = await targetItem.createModelOptions();
@@ -200,7 +204,8 @@ describe('FilterFormItemModel defineChildren association fields', () => {
200
204
  });
201
205
 
202
206
  const ds = engine.dataSourceManager.getDataSource('main');
203
- ds!.addCollection({
207
+ expect(ds).toBeTruthy();
208
+ ds?.addCollection({
204
209
  name: 'users',
205
210
  filterTargetKey: 'id',
206
211
  fields: [{ name: 'id', type: 'integer', interface: 'number', filterable: { operators: [] } }],
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { describe, expect, it, vi } from 'vitest';
11
11
 
12
- import { getDefaultOperator } from '../utils';
12
+ import { getDefaultOperator, getFilterFormOperatorList } from '../utils';
13
13
 
14
14
  describe('getDefaultOperator', () => {
15
15
  it('returns model.operator when it exists', () => {
@@ -66,4 +66,38 @@ describe('getDefaultOperator', () => {
66
66
  expect(result).toBe('$includes');
67
67
  expect(getStepParams).toHaveBeenCalledWith('filterFormItemSettings', 'defaultOperator');
68
68
  });
69
+
70
+ it('falls back to field interface operators when field operators are empty', () => {
71
+ const getStepParams = vi.fn().mockReturnValue(undefined);
72
+ const model = {
73
+ getStepParams,
74
+ subModels: {},
75
+ collectionField: {
76
+ interface: 'id',
77
+ type: 'bigInt',
78
+ filterable: { operators: [] },
79
+ },
80
+ context: {
81
+ dataSourceManager: {
82
+ collectionFieldInterfaceManager: {
83
+ getFieldInterface: vi.fn(() => ({
84
+ filterable: {
85
+ operators: [
86
+ { label: 'is', value: '$eq', selected: true },
87
+ {
88
+ label: 'is any of',
89
+ value: '$in',
90
+ schema: { 'x-component': 'MultipleKeywordsInput' },
91
+ },
92
+ ],
93
+ },
94
+ })),
95
+ },
96
+ },
97
+ },
98
+ } as any;
99
+
100
+ expect(getDefaultOperator(model)).toBe('$eq');
101
+ expect(getFilterFormOperatorList(model).map((item) => item.value)).toEqual(['$eq', '$in']);
102
+ });
69
103
  });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import React from 'react';
11
+ import { describe, expect, it, vi } from 'vitest';
12
+ import { FilterFormItemModel } from '../../../filter-form/FilterFormItemModel';
13
+ import { customizeFilterRender } from '../customizeFilterRender';
14
+
15
+ type TestFieldModel = {
16
+ props: Record<string, unknown>;
17
+ render: () => React.ReactNode;
18
+ setupReactiveRender: ReturnType<typeof vi.fn>;
19
+ _reactiveWrapperCache?: unknown;
20
+ __originalRender?: () => React.ReactNode;
21
+ };
22
+
23
+ const MultipleKeywordsInput = (props: Record<string, unknown>) => {
24
+ return <div data-testid="multiple-keywords-input" {...props} />;
25
+ };
26
+
27
+ function createFieldModel() {
28
+ return {
29
+ props: {
30
+ value: ['1', '2'],
31
+ placeholder: 'runtime placeholder',
32
+ onCompositionStart: vi.fn(),
33
+ onCompositionEnd: vi.fn(),
34
+ },
35
+ render: () => <span data-testid="original-field" />,
36
+ setupReactiveRender: vi.fn(),
37
+ };
38
+ }
39
+
40
+ function createFilterItemModel({
41
+ fieldModel,
42
+ operator = '$in',
43
+ collectionField,
44
+ }: {
45
+ fieldModel: TestFieldModel;
46
+ operator?: string;
47
+ collectionField: Record<string, unknown>;
48
+ }) {
49
+ return {
50
+ uid: 'filter-item',
51
+ operator,
52
+ collectionField,
53
+ context: {
54
+ app: {
55
+ getComponent: (name: string) => (name === 'MultipleKeywordsInput' ? MultipleKeywordsInput : undefined),
56
+ },
57
+ collectionField,
58
+ },
59
+ subModels: {
60
+ field: fieldModel,
61
+ },
62
+ setProps: vi.fn(),
63
+ } as unknown as FilterFormItemModel;
64
+ }
65
+
66
+ describe('customizeFilterRender action', () => {
67
+ it('uses operator schema components for number filter fields', () => {
68
+ const fieldModel = createFieldModel();
69
+ const model = createFilterItemModel({
70
+ fieldModel,
71
+ collectionField: {
72
+ interface: 'number',
73
+ type: 'integer',
74
+ filterable: {
75
+ operators: [
76
+ { label: 'equals', value: '$eq' },
77
+ {
78
+ label: 'is any of',
79
+ value: '$in',
80
+ schema: {
81
+ 'x-component': 'MultipleKeywordsInput',
82
+ 'x-component-props': {
83
+ fieldInterface: 'number',
84
+ placeholder: 'schema placeholder',
85
+ },
86
+ },
87
+ },
88
+ ],
89
+ },
90
+ },
91
+ });
92
+
93
+ customizeFilterRender.handler?.({ model } as any);
94
+
95
+ const element = fieldModel.render();
96
+ expect(React.isValidElement(element)).toBe(true);
97
+ expect((element as React.ReactElement).type).toBe(MultipleKeywordsInput);
98
+ expect((element as React.ReactElement).props.fieldInterface).toBe('number');
99
+ expect((element as React.ReactElement).props.placeholder).toBe('runtime placeholder');
100
+ expect((element as React.ReactElement).props.onCompositionStart).toBeNull();
101
+ expect((element as React.ReactElement).props.onCompositionEnd).toBeNull();
102
+ expect(fieldModel.setupReactiveRender).toHaveBeenCalled();
103
+ expect(model.setProps).toHaveBeenCalledWith({ key: 'filter-item-$in' });
104
+
105
+ fieldModel.props = {
106
+ ...fieldModel.props,
107
+ value: ['3'],
108
+ placeholder: 'updated runtime placeholder',
109
+ };
110
+ const rerenderedElement = fieldModel.render();
111
+ expect((rerenderedElement as React.ReactElement).props.value).toEqual(['3']);
112
+ expect((rerenderedElement as React.ReactElement).props.placeholder).toBe('updated runtime placeholder');
113
+ });
114
+
115
+ it('restores original render when switching to an operator without a schema component', () => {
116
+ const fieldModel = createFieldModel();
117
+ const originalRender = fieldModel.render;
118
+ const model = createFilterItemModel({
119
+ fieldModel,
120
+ collectionField: {
121
+ interface: 'number',
122
+ type: 'integer',
123
+ filterable: {
124
+ operators: [
125
+ { label: 'equals', value: '$eq' },
126
+ {
127
+ label: 'is any of',
128
+ value: '$in',
129
+ schema: {
130
+ 'x-component': 'MultipleKeywordsInput',
131
+ 'x-component-props': {
132
+ fieldInterface: 'number',
133
+ },
134
+ },
135
+ },
136
+ ],
137
+ },
138
+ },
139
+ });
140
+
141
+ customizeFilterRender.handler?.({ model } as any);
142
+ model.operator = '$eq';
143
+ customizeFilterRender.handler?.({ model } as any);
144
+
145
+ expect(fieldModel.render).toBe(originalRender);
146
+ expect(fieldModel.render()).toEqual(<span data-testid="original-field" />);
147
+ });
148
+ });
@@ -9,6 +9,8 @@
9
9
 
10
10
  import { describe, it, expect, beforeEach } from 'vitest';
11
11
  import { FlowEngine, FlowModel } from '@nocobase/flow-engine';
12
+ import { createMockClient, IdFieldInterface, InputFieldInterface } from '@nocobase/client-v2';
13
+ import React from 'react';
12
14
  import { defaultOperator } from '../defaultOperator';
13
15
 
14
16
  function createModel() {
@@ -17,6 +19,12 @@ function createModel() {
17
19
  return model;
18
20
  }
19
21
 
22
+ type OperatorOption = {
23
+ value: string;
24
+ label?: unknown;
25
+ schema?: Record<string, unknown>;
26
+ };
27
+
20
28
  describe('defaultOperator action', () => {
21
29
  beforeEach(() => {
22
30
  document.body.innerHTML = '';
@@ -55,4 +63,81 @@ describe('defaultOperator action', () => {
55
63
  expect(options2.find((o: any) => o.value === '$a')).toBeTruthy();
56
64
  expect(options2.find((o: any) => o.value === '$b')).toBeTruthy();
57
65
  });
66
+
67
+ it('resolves default operator options from field interfaces when collection field metadata is absent', () => {
68
+ const app = createMockClient();
69
+ app.addFieldInterfaces([InputFieldInterface]);
70
+ app.addFieldInterfaceOperator('input', {
71
+ label: 'plugin in',
72
+ value: '$in',
73
+ schema: { 'x-component': 'MultipleKeywordsInput' },
74
+ });
75
+ app.addFieldInterfaceOperator('input', {
76
+ label: 'plugin not in',
77
+ value: '$notIn',
78
+ schema: { 'x-component': 'MultipleKeywordsInput' },
79
+ });
80
+
81
+ const model = new FlowModel({ uid: 'm-default-operator-interface', flowEngine: app.flowEngine });
82
+ model.context.defineProperty('filterField', {
83
+ value: { name: 'name', title: 'Name', interface: 'input', type: 'string' },
84
+ });
85
+
86
+ const ui = defaultOperator.uiMode?.({ model } as any);
87
+ const options = (ui?.props?.options || []) as OperatorOption[];
88
+ expect(options.find((item) => item.value === '$in')?.schema?.['x-component']).toBe('MultipleKeywordsInput');
89
+ expect(options.find((item) => item.value === '$notIn')?.schema?.['x-component']).toBe('MultipleKeywordsInput');
90
+ });
91
+
92
+ it('preserves React operator labels', () => {
93
+ const model = createModel();
94
+ const label = <span data-testid="operator-label">plugin label</span>;
95
+
96
+ model.context.defineProperty('collectionField', {
97
+ value: {
98
+ filterable: {
99
+ operators: [{ value: '$in', label, schema: { 'x-component': 'MultipleKeywordsInput' } }],
100
+ },
101
+ },
102
+ });
103
+
104
+ const ui = defaultOperator.uiMode?.({ model } as any);
105
+ const options = (ui?.props?.options || []) as OperatorOption[];
106
+ expect(options.find((item) => item.value === '$in')?.label).toBe(label);
107
+ });
108
+
109
+ it('falls back to id field interface operators when collection field operators are empty', () => {
110
+ const app = createMockClient();
111
+ app.addFieldInterfaces([IdFieldInterface]);
112
+ app.addFieldInterfaceOperator('id', {
113
+ label: 'plugin in',
114
+ value: '$in',
115
+ schema: { 'x-component': 'MultipleKeywordsInput' },
116
+ });
117
+ app.addFieldInterfaceOperator('id', {
118
+ label: 'plugin not in',
119
+ value: '$notIn',
120
+ schema: { 'x-component': 'MultipleKeywordsInput' },
121
+ });
122
+
123
+ const model = new FlowModel({ uid: 'm-default-operator-id-interface', flowEngine: app.flowEngine });
124
+ model.context.defineProperty('collectionField', {
125
+ value: {
126
+ name: 'id',
127
+ title: 'ID',
128
+ interface: 'id',
129
+ type: 'bigInt',
130
+ filterable: { operators: [] },
131
+ },
132
+ });
133
+ model.context.defineProperty('filterField', {
134
+ value: { name: 'id', title: 'ID', interface: 'id', type: 'bigInt' },
135
+ });
136
+
137
+ const ui = defaultOperator.uiMode?.({ model } as any);
138
+ const options = (ui?.props?.options || []) as OperatorOption[];
139
+ expect(options.find((item) => item.value === '$eq')).toBeTruthy();
140
+ expect(options.find((item) => item.value === '$in')?.schema?.['x-component']).toBe('MultipleKeywordsInput');
141
+ expect(options.find((item) => item.value === '$notIn')?.schema?.['x-component']).toBe('MultipleKeywordsInput');
142
+ });
58
143
  });