@nocobase/client-v2 2.1.0-alpha.30 → 2.1.0-alpha.31

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 (64) hide show
  1. package/es/flow/actions/linkageRulesFormValueRefresh.d.ts +10 -0
  2. package/es/flow/index.d.ts +1 -0
  3. package/es/flow/models/actions/AssociateActionModel.d.ts +19 -0
  4. package/es/flow/models/actions/AssociationActionUtils.d.ts +17 -0
  5. package/es/flow/models/actions/DisassociateActionModel.d.ts +16 -0
  6. package/es/flow/models/actions/index.d.ts +3 -0
  7. package/es/flow/models/base/GridModel.d.ts +3 -1
  8. package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +15 -6
  9. package/es/flow/models/blocks/shared/filterOperators.d.ts +9 -0
  10. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +1 -0
  11. package/es/flow/models/fields/AssociationFieldModel/recordSelectSettingsUtils.d.ts +9 -0
  12. package/es/flow-compat/data.d.ts +9 -2
  13. package/es/flow-compat/index.d.ts +1 -1
  14. package/es/index.d.ts +1 -0
  15. package/es/index.mjs +90 -90
  16. package/lib/index.js +87 -87
  17. package/package.json +5 -5
  18. package/src/BaseApplication.tsx +1 -1
  19. package/src/__tests__/app.test.tsx +23 -6
  20. package/src/__tests__/globalDeps.test.ts +5 -0
  21. package/src/flow/actions/__tests__/linkageRules.formValueDrivenRefresh.test.ts +438 -0
  22. package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +42 -0
  23. package/src/flow/actions/linkageRules.tsx +8 -1
  24. package/src/flow/actions/linkageRulesFormValueRefresh.ts +492 -0
  25. package/src/flow/actions/linkageRulesRefresh.tsx +4 -2
  26. package/src/flow/actions/titleField.tsx +8 -3
  27. package/src/flow/components/FieldAssignValueInput.tsx +1 -0
  28. package/src/flow/components/filter/LinkageFilterItem.tsx +6 -5
  29. package/src/flow/components/filter/VariableFilterItem.tsx +14 -13
  30. package/src/flow/components/filter/__tests__/LinkageFilterItem.test.tsx +33 -0
  31. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +48 -5
  32. package/src/flow/index.ts +1 -0
  33. package/src/flow/internal/utils/__tests__/titleFieldQuickSync.test.ts +1 -0
  34. package/src/flow/internal/utils/titleFieldQuickSync.ts +2 -2
  35. package/src/flow/models/actions/AssociateActionModel.tsx +196 -0
  36. package/src/flow/models/actions/AssociationActionUtils.ts +90 -0
  37. package/src/flow/models/actions/DisassociateActionModel.tsx +57 -0
  38. package/src/flow/models/actions/FilterActionModel.tsx +17 -9
  39. package/src/flow/models/actions/__tests__/AssociationActionModel.test.ts +250 -0
  40. package/src/flow/models/actions/index.ts +3 -0
  41. package/src/flow/models/base/GridModel.tsx +21 -1
  42. package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +98 -0
  43. package/src/flow/models/blocks/details/DetailsItemModel.tsx +3 -0
  44. package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +200 -36
  45. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +270 -1
  46. package/src/flow/models/blocks/filter-form/__tests__/customFieldOperators.test.tsx +23 -0
  47. package/src/flow/models/blocks/filter-form/customFieldOperators.ts +12 -1
  48. package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +22 -8
  49. package/src/flow/models/blocks/filter-form/fields/__tests__/FilterFormCustomFieldModel.recordSelect.test.tsx +18 -0
  50. package/src/flow/models/blocks/filter-manager/FilterManager.ts +51 -1
  51. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +75 -0
  52. package/src/flow/models/blocks/form/FormItemModel.tsx +48 -28
  53. package/src/flow/models/blocks/shared/filterOperators.ts +14 -0
  54. package/src/flow/models/blocks/table/TableBlockModel.tsx +19 -3
  55. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +5 -1
  56. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +21 -5
  57. package/src/flow/models/fields/AssociationFieldModel/recordSelectSettingsUtils.ts +20 -0
  58. package/src/flow/models/fields/DividerItemModel.tsx +30 -15
  59. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +11 -3
  60. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +235 -0
  61. package/src/flow-compat/data.ts +25 -3
  62. package/src/flow-compat/index.ts +7 -1
  63. package/src/index.ts +1 -0
  64. package/src/utils/globalDeps.ts +6 -0
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { beforeEach, describe, expect, it } from 'vitest';
11
- import { FlowEngine } from '@nocobase/flow-engine';
11
+ import { FlowEngine, projectLayoutToLegacyRows } from '@nocobase/flow-engine';
12
12
  import '../../../../index';
13
13
  import { GRID_FLOW_KEY, GRID_STEP } from '../../../base';
14
14
  import { FilterFormGridModel } from '../FilterFormGridModel';
@@ -147,6 +147,71 @@ describe('FilterFormGridModel.toggleFormFieldsCollapse', () => {
147
147
  expect(model.props.layout.rows[0].cells[0].items).toEqual(['field-1', 'field-2', 'field-3']);
148
148
  });
149
149
 
150
+ it('keeps nested columns inside the first visible row when collapsing a v2 layout', () => {
151
+ const model = engine.createModel<FilterFormGridModel>({
152
+ uid: 'filter-grid-collapse-nested-v2',
153
+ use: 'FilterFormGridModel',
154
+ props: {
155
+ layout: {
156
+ version: 2,
157
+ rows: [
158
+ {
159
+ id: 'first',
160
+ cells: [
161
+ {
162
+ id: 'first-cell',
163
+ rows: [
164
+ {
165
+ id: 'first-nested-row',
166
+ cells: [
167
+ { id: 'first-nested-cell-1', items: ['field-1'] },
168
+ { id: 'first-nested-cell-2', items: ['field-2'] },
169
+ ],
170
+ sizes: [12, 12],
171
+ },
172
+ ],
173
+ },
174
+ ],
175
+ sizes: [24],
176
+ },
177
+ {
178
+ id: 'second',
179
+ cells: [{ id: 'second-cell', items: ['field-3'] }],
180
+ sizes: [24],
181
+ },
182
+ ],
183
+ },
184
+ },
185
+ structure: {} as any,
186
+ });
187
+ (model as any).subModels = {
188
+ items: [
189
+ engine.createModel({ use: 'FlowModel', uid: 'field-1' }),
190
+ engine.createModel({ use: 'FlowModel', uid: 'field-2' }),
191
+ engine.createModel({ use: 'FlowModel', uid: 'field-3' }),
192
+ ],
193
+ };
194
+
195
+ model.setStepParams(GRID_FLOW_KEY, GRID_STEP, {
196
+ layout: model.props.layout,
197
+ });
198
+
199
+ model.toggleFormFieldsCollapse(true, 1);
200
+
201
+ const collapsedNestedRow = model.props.layout.rows[0].cells[0].rows?.[0];
202
+ expect(collapsedNestedRow?.cells.map((cell) => cell.items)).toEqual([['field-1'], ['field-2']]);
203
+ expect(collapsedNestedRow?.sizes).toEqual([12, 12]);
204
+ expect(model.props.layout.rows).toHaveLength(1);
205
+
206
+ model.toggleFormFieldsCollapse(false, 1);
207
+
208
+ expect(model.props.layout.rows).toHaveLength(2);
209
+ expect(model.props.layout.rows[0].cells[0].rows?.[0].cells.map((cell) => cell.items)).toEqual([
210
+ ['field-1'],
211
+ ['field-2'],
212
+ ]);
213
+ });
214
+
150
215
  it('restores the persisted full layout when current props rows were already truncated', () => {
151
216
  const model = engine.createModel<FilterFormGridModel>({
152
217
  uid: 'filter-grid-collapse-restore',
@@ -185,4 +250,208 @@ describe('FilterFormGridModel.toggleFormFieldsCollapse', () => {
185
250
  });
186
251
  expect(model.props.rowOrder).toEqual(['first', 'second', 'third']);
187
252
  });
253
+
254
+ it('does not reinsert collapsed items when the render layout is normalized again', () => {
255
+ const model = engine.createModel<FilterFormGridModel>({
256
+ uid: 'filter-grid-collapse-visible-item-uids',
257
+ use: 'FilterFormGridModel',
258
+ props: {
259
+ rows: {
260
+ first: [['field-1']],
261
+ second: [['field-2']],
262
+ third: [['field-3']],
263
+ },
264
+ rowOrder: ['first', 'second', 'third'],
265
+ },
266
+ structure: {} as any,
267
+ });
268
+ (model as any).subModels = {
269
+ items: [
270
+ engine.createModel({ use: 'FlowModel', uid: 'field-1' }),
271
+ engine.createModel({ use: 'FlowModel', uid: 'field-2' }),
272
+ engine.createModel({ use: 'FlowModel', uid: 'field-3' }),
273
+ ],
274
+ };
275
+
276
+ model.setStepParams(GRID_FLOW_KEY, GRID_STEP, {
277
+ rows: {
278
+ first: [['field-1']],
279
+ second: [['field-2']],
280
+ third: [['field-3']],
281
+ },
282
+ rowOrder: ['first', 'second', 'third'],
283
+ });
284
+
285
+ model.toggleFormFieldsCollapse(true, 1);
286
+
287
+ expect(projectLayoutToLegacyRows(model.getGridLayout()).rows).toEqual({
288
+ first: [['field-1']],
289
+ });
290
+
291
+ model.toggleFormFieldsCollapse(false, 1);
292
+
293
+ expect(projectLayoutToLegacyRows(model.getGridLayout()).rows).toEqual({
294
+ first: [['field-1']],
295
+ second: [['field-2']],
296
+ third: [['field-3']],
297
+ });
298
+ });
299
+
300
+ it('keeps the persisted layout intact when resetRows runs during collapsed mode', () => {
301
+ const model = engine.createModel<FilterFormGridModel>({
302
+ uid: 'filter-grid-collapse-reset-rows',
303
+ use: 'FilterFormGridModel',
304
+ props: {
305
+ rows: {
306
+ first: [['field-1']],
307
+ second: [['field-2']],
308
+ third: [['field-3']],
309
+ },
310
+ rowOrder: ['first', 'second', 'third'],
311
+ },
312
+ structure: {} as any,
313
+ });
314
+ (model as any).subModels = {
315
+ items: [
316
+ engine.createModel({ use: 'FlowModel', uid: 'field-1' }),
317
+ engine.createModel({ use: 'FlowModel', uid: 'field-2' }),
318
+ engine.createModel({ use: 'FlowModel', uid: 'field-3' }),
319
+ ],
320
+ };
321
+
322
+ model.setStepParams(GRID_FLOW_KEY, GRID_STEP, {
323
+ rows: {
324
+ first: [['field-1']],
325
+ second: [['field-2']],
326
+ third: [['field-3']],
327
+ },
328
+ rowOrder: ['first', 'second', 'third'],
329
+ });
330
+
331
+ model.toggleFormFieldsCollapse(true, 1);
332
+ model.resetRows(true);
333
+
334
+ const persistedLayout = model.getStepParams(GRID_FLOW_KEY, GRID_STEP).layout;
335
+ const persistedRows = projectLayoutToLegacyRows(persistedLayout).rows;
336
+ const persistedItems = Object.values(persistedRows).flat().flat().sort();
337
+ expect(persistedItems).toEqual(['field-1', 'field-2', 'field-3']);
338
+ expect(projectLayoutToLegacyRows(model.getGridLayout()).rows).toEqual({
339
+ first: [['field-1']],
340
+ });
341
+ });
342
+
343
+ it('uses the full layout for flow-settings layout reads during collapsed mode', () => {
344
+ const model = engine.createModel<FilterFormGridModel>({
345
+ uid: 'filter-grid-collapse-settings-full-layout',
346
+ use: 'FilterFormGridModel',
347
+ props: {
348
+ layout: {
349
+ version: 2,
350
+ rows: [
351
+ {
352
+ id: 'first',
353
+ cells: [{ id: 'first-cell', items: ['field-1'] }],
354
+ sizes: [24],
355
+ },
356
+ {
357
+ id: 'second',
358
+ cells: [
359
+ { id: 'second-cell-1', items: ['field-2'] },
360
+ { id: 'second-cell-2', items: ['field-3'] },
361
+ ],
362
+ sizes: [12, 12],
363
+ },
364
+ ],
365
+ },
366
+ },
367
+ structure: {} as any,
368
+ });
369
+ (model as any).subModels = {
370
+ items: [
371
+ engine.createModel({ use: 'FlowModel', uid: 'field-1' }),
372
+ engine.createModel({ use: 'FlowModel', uid: 'field-2' }),
373
+ engine.createModel({ use: 'FlowModel', uid: 'field-3' }),
374
+ ],
375
+ };
376
+ (model.context as any).flowSettingsEnabled = true;
377
+
378
+ model.setStepParams(GRID_FLOW_KEY, GRID_STEP, {
379
+ layout: model.props.layout,
380
+ });
381
+
382
+ model.toggleFormFieldsCollapse(true, 1);
383
+
384
+ expect(projectLayoutToLegacyRows((model as any).normalizeLayoutFromSource()).rows).toEqual({
385
+ first: [['field-1']],
386
+ });
387
+ expect(projectLayoutToLegacyRows(model.getGridLayout()).rows).toEqual({
388
+ first: [['field-1']],
389
+ second: [['field-2'], ['field-3']],
390
+ });
391
+
392
+ model.saveGridLayout(model.getGridLayout());
393
+
394
+ expect(projectLayoutToLegacyRows(model.getStepParams(GRID_FLOW_KEY, GRID_STEP).layout).rows).toEqual({
395
+ first: [['field-1']],
396
+ second: [['field-2'], ['field-3']],
397
+ });
398
+ });
399
+
400
+ it('restores the latest saved full layout after editing while collapsed in flow settings', () => {
401
+ const model = engine.createModel<FilterFormGridModel>({
402
+ uid: 'filter-grid-collapse-settings-latest-layout',
403
+ use: 'FilterFormGridModel',
404
+ props: {
405
+ layout: {
406
+ version: 2,
407
+ rows: [
408
+ {
409
+ id: 'first',
410
+ cells: [{ id: 'first-cell', items: ['field-1'] }],
411
+ sizes: [24],
412
+ },
413
+ {
414
+ id: 'second',
415
+ cells: [{ id: 'second-cell', items: ['field-2'] }],
416
+ sizes: [24],
417
+ },
418
+ ],
419
+ },
420
+ },
421
+ structure: {} as any,
422
+ });
423
+ (model as any).subModels = {
424
+ items: [
425
+ engine.createModel({ use: 'FlowModel', uid: 'field-1' }),
426
+ engine.createModel({ use: 'FlowModel', uid: 'field-2' }),
427
+ ],
428
+ };
429
+ (model.context as any).flowSettingsEnabled = true;
430
+
431
+ model.setStepParams(GRID_FLOW_KEY, GRID_STEP, {
432
+ layout: model.props.layout,
433
+ });
434
+ model.toggleFormFieldsCollapse(true, 1);
435
+
436
+ const editedLayout = {
437
+ version: 2 as const,
438
+ rows: [
439
+ {
440
+ id: 'first',
441
+ cells: [
442
+ { id: 'first-cell-1', items: ['field-1'] },
443
+ { id: 'first-cell-2', items: ['field-2'] },
444
+ ],
445
+ sizes: [12, 12],
446
+ },
447
+ ],
448
+ };
449
+
450
+ model.saveGridLayout(editedLayout);
451
+ model.toggleFormFieldsCollapse(false, 1);
452
+
453
+ expect(projectLayoutToLegacyRows(model.props.layout).rows).toEqual({
454
+ first: [['field-1'], ['field-2']],
455
+ });
456
+ });
188
457
  });
@@ -37,6 +37,8 @@ function createEngineWithCollections() {
37
37
  fields: [
38
38
  { name: 'id', type: 'integer', interface: 'number', filterable: { operators: [] } },
39
39
  { name: 'uid', type: 'string', interface: 'input', filterable: { operators: [] } },
40
+ { name: 'status', type: 'string', interface: 'select', filterable: { operators: [] } },
41
+ { name: 'tags', type: 'array', interface: 'multipleSelect', filterable: { operators: [] } },
40
42
  { name: 'createdAt', type: 'date', interface: 'datetime', filterable: { operators: [] } },
41
43
  {
42
44
  name: 'pluginInSource',
@@ -106,6 +108,27 @@ describe('custom field operators', () => {
106
108
  expect(checkboxGroupOps.some((item) => item.value === '$match')).toBe(true);
107
109
  });
108
110
 
111
+ it('uses multi-value scalar operators for multiple select custom fields bound to scalar source fields', () => {
112
+ const engine = createEngineWithCollections();
113
+
114
+ const scalarSourceOps = resolveCustomFieldOperatorList({
115
+ flowEngine: engine,
116
+ fieldModel: 'SelectFieldModel',
117
+ fieldModelProps: { mode: 'multiple' },
118
+ source: ['main', 'users', 'status'],
119
+ });
120
+ expect(scalarSourceOps[0]?.value).toBe('$in');
121
+ expect(scalarSourceOps.some((item) => item.value === '$match')).toBe(false);
122
+
123
+ const arraySourceOps = resolveCustomFieldOperatorList({
124
+ flowEngine: engine,
125
+ fieldModel: 'SelectFieldModel',
126
+ fieldModelProps: { mode: 'multiple' },
127
+ source: ['main', 'users', 'tags'],
128
+ });
129
+ expect(arraySourceOps[0]?.value).toBe('$match');
130
+ });
131
+
109
132
  it('resolves record select operators by value field and multiple mode', () => {
110
133
  const engine = createEngineWithCollections();
111
134
 
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { operators } from '../../../../flow-compat';
11
+ import { isArrayLikeField } from '../shared/filterOperators';
11
12
 
12
13
  type OperatorMeta = {
13
14
  label: string;
@@ -186,12 +187,22 @@ function resolveByModelOrSource(params: ResolveOperatorParams): { operatorList:
186
187
  return resolveByRecordSelect(params);
187
188
  }
188
189
 
190
+ const sourceField = getSourceField(flowEngine, source);
191
+ if (fieldModel === 'SelectFieldModel' && fieldModelProps?.mode === 'multiple' && sourceField) {
192
+ const sourceOperators = getFieldOperators(sourceField);
193
+ return {
194
+ operatorList: isArrayLikeField(sourceField)
195
+ ? getOperatorListByModel(fieldModel, fieldModelProps)
196
+ : toMultiValueOperators(sourceOperators),
197
+ meta: sourceField,
198
+ };
199
+ }
200
+
189
201
  const modelOperators = getOperatorListByModel(fieldModel, fieldModelProps);
190
202
  if (modelOperators.length > 0) {
191
203
  return { operatorList: modelOperators, meta: { fieldModel } };
192
204
  }
193
205
 
194
- const sourceField = getSourceField(flowEngine, source);
195
206
  return {
196
207
  operatorList: getFieldOperators(sourceField),
197
208
  meta: sourceField,
@@ -14,7 +14,11 @@ import { Input, Radio, Checkbox, Space, Button, Select } from 'antd';
14
14
  import { PlusOutlined, CloseOutlined } from '@ant-design/icons';
15
15
  import { useTranslation } from 'react-i18next';
16
16
  import { FilterableItemModel, useFlowContext, useFlowEngine } from '@nocobase/flow-engine';
17
- import { isTitleField } from '../../../../../flow-compat';
17
+ import {
18
+ getFlowFieldInterfaceOptions,
19
+ hasFlowFieldInterfaceLookup,
20
+ isTitleFieldInterface,
21
+ } from '../../../../../flow-compat';
18
22
 
19
23
  const RECORD_SELECT_DATA_SOURCE_KEY = 'recordSelectDataSourceKey';
20
24
  const RECORD_SELECT_COLLECTION_KEY = 'recordSelectTargetCollection';
@@ -36,7 +40,12 @@ export const FieldComponentProps: React.FC<{ fieldModel: string; source: string[
36
40
  const resolvedFieldModel = fieldModel || propsValue?.fieldModel;
37
41
  const flowEngine = useFlowEngine();
38
42
  const ctx = useFlowContext();
39
- const appDataSourceManager = ctx?.app?.dataSourceManager;
43
+ const dataSourceManager = ctx?.dataSourceManager || flowEngine?.dataSourceManager || ctx?.app?.dataSourceManager;
44
+ const hasFieldInterfaceLookup = hasFlowFieldInterfaceLookup(dataSourceManager);
45
+ const getFieldInterface = useCallback(
46
+ (interfaceName: string | undefined) => getFlowFieldInterfaceOptions(interfaceName, dataSourceManager),
47
+ [dataSourceManager],
48
+ );
40
49
 
41
50
  const getCurrentValue = () => field.value || {};
42
51
  const updateProps = (key: string, value: any) => {
@@ -165,15 +174,20 @@ export const FieldComponentProps: React.FC<{ fieldModel: string; source: string[
165
174
  }, [activeDataSource, translateLabel]);
166
175
  const titleFieldOptions = useMemo(() => {
167
176
  if (!activeCollection?.getFields) return [];
168
- const shouldKeep = (fieldItem: any) =>
169
- appDataSourceManager ? isTitleField(appDataSourceManager, fieldItem.options) : true;
177
+ const shouldKeep = (fieldItem: any) => {
178
+ if (!hasFieldInterfaceLookup) {
179
+ return true;
180
+ }
181
+ const fieldOptions = fieldItem?.options || fieldItem;
182
+ return isTitleFieldInterface(getFieldInterface(fieldOptions?.interface));
183
+ };
170
184
  return (activeCollection.getFields() || [])
171
185
  .filter((fieldItem: any) => shouldKeep(fieldItem))
172
186
  .map((fieldItem: any) => ({
173
187
  label: translateLabel(fieldItem.options?.uiSchema?.title) || translateLabel(fieldItem.title) || fieldItem.name,
174
188
  value: fieldItem.name,
175
189
  }));
176
- }, [activeCollection, appDataSourceManager, translateLabel]);
190
+ }, [activeCollection, getFieldInterface, hasFieldInterfaceLookup, translateLabel]);
177
191
  const valueFieldOptions = useMemo(() => {
178
192
  if (!activeCollection?.getFields) return [];
179
193
  const shouldKeep = (fieldItem: any) => {
@@ -182,8 +196,8 @@ export const FieldComponentProps: React.FC<{ fieldModel: string; source: string[
182
196
  if (fieldOptions.filterable === false || !interfaceName) {
183
197
  return false;
184
198
  }
185
- if (appDataSourceManager) {
186
- const fieldInterface = appDataSourceManager.collectionFieldInterfaceManager?.getFieldInterface?.(interfaceName);
199
+ if (hasFieldInterfaceLookup) {
200
+ const fieldInterface = getFieldInterface(interfaceName);
187
201
  if (!fieldInterface?.filterable) {
188
202
  return false;
189
203
  }
@@ -197,7 +211,7 @@ export const FieldComponentProps: React.FC<{ fieldModel: string; source: string[
197
211
  label: translateLabel(fieldItem.options?.uiSchema?.title) || translateLabel(fieldItem.title) || fieldItem.name,
198
212
  value: fieldItem.name,
199
213
  }));
200
- }, [activeCollection, appDataSourceManager, translateLabel]);
214
+ }, [activeCollection, getFieldInterface, hasFieldInterfaceLookup, translateLabel]);
201
215
 
202
216
  useEffect(() => {
203
217
  if (resolvedFieldModel !== 'FilterFormCustomRecordSelectFieldModel') return;
@@ -222,6 +222,17 @@ describe('FilterForm custom field record select', () => {
222
222
  it('hides association fields from value field options', async () => {
223
223
  const engine = new FlowEngine();
224
224
  engine.registerModels({ HostModel, FilterFormCustomRecordSelectFieldModel });
225
+ engine.dataSourceManager.setCollectionFieldInterfaceManager({
226
+ getFieldInterface: vi.fn((name: string) => {
227
+ if (['input', 'number'].includes(name)) {
228
+ return { titleUsable: true, filterable: { operators: [] } };
229
+ }
230
+ if (name === 'm2m') {
231
+ return { filterable: { operators: [] } };
232
+ }
233
+ return undefined;
234
+ }),
235
+ });
225
236
 
226
237
  const ds = engine.dataSourceManager.getDataSource('main');
227
238
  ds?.addCollection({
@@ -242,6 +253,12 @@ describe('FilterForm custom field record select', () => {
242
253
  title: 'Hidden text',
243
254
  filterable: false,
244
255
  },
256
+ {
257
+ name: 'jsonPayload',
258
+ type: 'json',
259
+ interface: 'json',
260
+ title: 'JSON payload',
261
+ },
245
262
  {
246
263
  name: 'roles',
247
264
  type: 'belongsToMany',
@@ -298,6 +315,7 @@ describe('FilterForm custom field record select', () => {
298
315
  );
299
316
  expect(optionTexts).toContain('nickname');
300
317
  expect(optionTexts).not.toContain('Hidden text');
318
+ expect(optionTexts).not.toContain('JSON payload');
301
319
  expect(optionTexts).not.toContain('Roles relation');
302
320
  });
303
321
  });
@@ -10,6 +10,7 @@
10
10
  import { FilterGroup, FilterItem, FlowModel } from '@nocobase/flow-engine';
11
11
  import _ from 'lodash';
12
12
  import { CollectionBlockModel } from '../../base/CollectionBlockModel';
13
+ import { isArrayLikeField } from '../shared/filterOperators';
13
14
  import { getDefaultOperator, isFilterValueEmpty } from './utils';
14
15
 
15
16
  type FilterConfig = {
@@ -23,6 +24,13 @@ type FilterConfig = {
23
24
  operator?: string;
24
25
  };
25
26
 
27
+ const ARRAY_FIELD_OPERATORS_TO_SCALAR_OPERATORS: Record<string, string> = {
28
+ $match: '$in',
29
+ $anyOf: '$in',
30
+ $notMatch: '$notIn',
31
+ $noneOf: '$notIn',
32
+ };
33
+
26
34
  export type ConnectFieldsConfig = {
27
35
  targets: {
28
36
  /** 数据区块或者图表区块的 model uid */
@@ -32,6 +40,44 @@ export type ConnectFieldsConfig = {
32
40
  }[];
33
41
  };
34
42
 
43
+ function getTargetField(targetModel: any, fieldPath: string) {
44
+ const dataSourceManager = targetModel?.context?.dataSourceManager;
45
+ const collection = targetModel?.collection;
46
+ if (!dataSourceManager || !collection?.dataSourceKey || !collection?.name || !fieldPath) {
47
+ return;
48
+ }
49
+
50
+ let collectionName = collection.name;
51
+ const fieldNames = fieldPath.split('.').filter(Boolean);
52
+ for (let index = 0; index < fieldNames.length; index += 1) {
53
+ const field = dataSourceManager.getCollectionField?.(
54
+ `${collection.dataSourceKey}.${collectionName}.${fieldNames[index]}`,
55
+ );
56
+ if (!field || index === fieldNames.length - 1) {
57
+ return field;
58
+ }
59
+
60
+ collectionName = field.target || field.targetCollection?.name;
61
+ if (!collectionName) {
62
+ return;
63
+ }
64
+ }
65
+ }
66
+
67
+ function normalizeOperatorForTargetField(operator: string, targetModel: any, fieldPath: string) {
68
+ const scalarOperator = ARRAY_FIELD_OPERATORS_TO_SCALAR_OPERATORS[operator];
69
+ if (!scalarOperator) {
70
+ return operator;
71
+ }
72
+
73
+ const targetField = getTargetField(targetModel, fieldPath);
74
+ if (!targetField || isArrayLikeField(targetField)) {
75
+ return operator;
76
+ }
77
+
78
+ return scalarOperator;
79
+ }
80
+
35
81
  export class FilterManager {
36
82
  private filterConfigs: FilterConfig[];
37
83
  private readonly gridModel: FlowModel;
@@ -300,7 +346,11 @@ export class FilterManager {
300
346
  // 构建筛选条件
301
347
  const filterConditions = config.filterPaths.map((fieldPath) => ({
302
348
  path: fieldPath,
303
- operator: config.operator || getDefaultOperator(filterModel),
349
+ operator: normalizeOperatorForTargetField(
350
+ config.operator || getDefaultOperator(filterModel),
351
+ targetModel,
352
+ fieldPath,
353
+ ),
304
354
  value: filterValue,
305
355
  }));
306
356
 
@@ -496,6 +496,81 @@ describe('FilterManager', () => {
496
496
  expect(mockTargetModel2.resource.refresh).toHaveBeenCalledTimes(1);
497
497
  });
498
498
 
499
+ it('should normalize array operators when the target field is scalar', async () => {
500
+ const filterConfigs = [
501
+ {
502
+ filterId: 'filter-1',
503
+ targetId: 'target-scalar',
504
+ filterPaths: ['status'],
505
+ operator: '$match',
506
+ },
507
+ {
508
+ filterId: 'filter-1',
509
+ targetId: 'target-array',
510
+ filterPaths: ['tags'],
511
+ operator: '$match',
512
+ },
513
+ {
514
+ filterId: 'filter-1',
515
+ targetId: 'target-deep-array',
516
+ filterPaths: ['org.company.tags'],
517
+ operator: '$match',
518
+ },
519
+ ];
520
+
521
+ (filterManager as any).filterConfigs = filterConfigs;
522
+
523
+ const createTargetModel = (getCollectionField: any) => ({
524
+ collection: {
525
+ dataSourceKey: 'main',
526
+ name: 'users',
527
+ },
528
+ context: {
529
+ dataSourceManager: {
530
+ getCollectionField,
531
+ },
532
+ },
533
+ resource: {
534
+ addFilterGroup: vi.fn(),
535
+ removeFilterGroup: vi.fn(),
536
+ refresh: vi.fn().mockResolvedValue(undefined),
537
+ },
538
+ setFilterActive: vi.fn(),
539
+ getDataLoadingMode: vi.fn().mockReturnValue('auto'),
540
+ });
541
+ const mockScalarTargetModel = createTargetModel(vi.fn().mockReturnValue({ interface: 'select', type: 'string' }));
542
+ const mockArrayTargetModel = createTargetModel(
543
+ vi.fn().mockReturnValue({ interface: 'multipleSelect', type: 'array' }),
544
+ );
545
+ const mockDeepArrayTargetModel = createTargetModel(
546
+ vi.fn((key: string) => {
547
+ const fields = {
548
+ 'main.users.org': { target: 'orgs' },
549
+ 'main.orgs.company': { target: 'companies' },
550
+ 'main.companies.tags': { interface: 'multipleSelect', type: 'array' },
551
+ };
552
+ return fields[key];
553
+ }),
554
+ );
555
+ const mockFilterModel = {
556
+ getFilterValue: vi.fn().mockReturnValue(['a1']),
557
+ };
558
+
559
+ (mockFlowModel.flowEngine.getModel as any).mockImplementation((uid: string) => {
560
+ if (uid === 'target-scalar') return mockScalarTargetModel;
561
+ if (uid === 'target-array') return mockArrayTargetModel;
562
+ if (uid === 'target-deep-array') return mockDeepArrayTargetModel;
563
+ if (uid === 'filter-1') return mockFilterModel;
564
+ return null;
565
+ });
566
+
567
+ await filterManager.refreshTargetsByFilter('filter-1');
568
+
569
+ expect(mockScalarTargetModel.resource.addFilterGroup.mock.calls[0][1].options.operator).toBe('$in');
570
+ expect(mockArrayTargetModel.resource.addFilterGroup.mock.calls[0][1].options.operator).toBe('$match');
571
+ expect(mockDeepArrayTargetModel.resource.addFilterGroup.mock.calls[0][1].options.operator).toBe('$match');
572
+ });
573
+
499
574
  it('should process multiple filterIds successfully', async () => {
500
575
  // Setup filter configs
501
576
  const filterConfigs = [