@nocobase/client-v2 2.1.0-beta.44 → 2.1.0-beta.46

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 (88) hide show
  1. package/es/BaseApplication.d.ts +9 -0
  2. package/es/authRedirect.d.ts +12 -0
  3. package/es/collection-manager/filter-operators/index.d.ts +1 -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/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.d.ts +1 -0
  7. package/es/flow/admin-shell/admin-layout/AppListRender.d.ts +11 -0
  8. package/es/flow/admin-shell/admin-layout/index.d.ts +1 -0
  9. package/es/flow/admin-shell/admin-layout/useApplications.d.ts +3 -2
  10. package/es/flow/components/ConditionBuilder.d.ts +1 -0
  11. package/es/flow/components/FieldAssignRulesEditor.d.ts +3 -1
  12. package/es/flow/components/FieldAssignValueInput.d.ts +1 -0
  13. package/es/flow/components/fieldAssignOptions.d.ts +10 -0
  14. package/es/flow/components/filter/LinkageFilterItem.d.ts +1 -0
  15. package/es/flow/components/filter/VariableFilterItem.d.ts +1 -0
  16. package/es/flow/components/filter/metaTreeAssociationDepth.d.ts +13 -0
  17. package/es/flow/internal/utils/operatorSchemaHelper.d.ts +26 -3
  18. package/es/flow/models/base/AssociationFieldGroupModel.d.ts +2 -33
  19. package/es/flow/models/blocks/filter-manager/utils.d.ts +23 -1
  20. package/es/flow/models/blocks/table/TableColumnModel.d.ts +2 -0
  21. package/es/index.mjs +178 -100
  22. package/lib/index.js +187 -109
  23. package/package.json +7 -7
  24. package/src/BaseApplication.tsx +13 -0
  25. package/src/__tests__/browserChecker.test.ts +112 -0
  26. package/src/__tests__/dataSourceRuntime.test.tsx +22 -1
  27. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +5 -0
  28. package/src/authRedirect.ts +43 -0
  29. package/src/collection-field-interface/CollectionFieldInterface.ts +14 -7
  30. package/src/collection-manager/filter-operators/index.ts +29 -2
  31. package/src/components/form/filter/CollectionFilterItem.tsx +10 -2
  32. package/src/components/form/filter/FilterValueInput.tsx +27 -2
  33. package/src/components/form/filter/__tests__/FilterValueInput.test.tsx +39 -0
  34. package/src/flow/__tests__/FlowRoute.test.tsx +8 -1
  35. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +369 -2
  36. package/src/flow/actions/__tests__/sortingRules.test.ts +22 -0
  37. package/src/flow/actions/dataScope.tsx +8 -1
  38. package/src/flow/actions/linkageRules.tsx +56 -1
  39. package/src/flow/actions/setTargetDataScope.tsx +1 -1
  40. package/src/flow/actions/sortingRules.tsx +3 -4
  41. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +31 -3
  42. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.ts +1 -0
  43. package/src/flow/admin-shell/admin-layout/AppListRender.tsx +139 -0
  44. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +8 -1
  45. package/src/flow/admin-shell/admin-layout/index.ts +1 -0
  46. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +11 -0
  47. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +2 -4
  48. package/src/flow/admin-shell/admin-layout/useApplications.tsx +34 -1
  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/fieldAssignOptions.ts +23 -6
  57. package/src/flow/components/filter/LinkageFilterItem.tsx +26 -12
  58. package/src/flow/components/filter/VariableFilterItem.tsx +32 -13
  59. package/src/flow/components/filter/__tests__/fieldsToOptions.test.ts +48 -0
  60. package/src/flow/components/filter/__tests__/metaTreeAssociationDepth.test.ts +60 -0
  61. package/src/flow/components/filter/fieldsToOptions.ts +9 -4
  62. package/src/flow/components/filter/metaTreeAssociationDepth.ts +69 -0
  63. package/src/flow/components/placeholders/BlockPlaceholder.tsx +76 -15
  64. package/src/flow/components/placeholders/__tests__/BlockPlaceholder.test.tsx +105 -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/base/AssociationFieldGroupModel.tsx +20 -3
  68. package/src/flow/models/base/CollectionBlockModel.tsx +10 -6
  69. package/src/flow/models/base/__tests__/AssociationFieldGroupModel.test.ts +86 -0
  70. package/src/flow/models/base/__tests__/CollectionBlockModel.title.test.ts +33 -1
  71. package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +11 -0
  72. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +14 -12
  73. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +6 -1
  74. package/src/flow/models/blocks/filter-manager/__tests__/getDefaultOperator.test.ts +35 -1
  75. package/src/flow/models/blocks/filter-manager/flow-actions/__tests__/customizeFilterRender.test.tsx +148 -0
  76. package/src/flow/models/blocks/filter-manager/flow-actions/__tests__/defaultOperator.test.tsx +85 -0
  77. package/src/flow/models/blocks/filter-manager/flow-actions/connectFields.tsx +48 -6
  78. package/src/flow/models/blocks/filter-manager/flow-actions/customizeFilterRender.tsx +14 -44
  79. package/src/flow/models/blocks/filter-manager/flow-actions/defaultOperator.tsx +3 -5
  80. package/src/flow/models/blocks/filter-manager/flow-actions/operatorComponentProps.tsx +2 -12
  81. package/src/flow/models/blocks/filter-manager/utils.ts +143 -4
  82. package/src/flow/models/blocks/form/FormAssociationItemModel.tsx +9 -0
  83. package/src/flow/models/blocks/form/FormItemModel.tsx +9 -0
  84. package/src/flow/models/blocks/form/__tests__/FormItemModel.defineChildren.test.ts +27 -2
  85. package/src/flow/models/blocks/table/TableColumnModel.tsx +15 -5
  86. package/src/flow/models/blocks/table/TableCustomColumnModel.tsx +2 -2
  87. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +18 -0
  88. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +6 -7
@@ -0,0 +1,86 @@
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 { afterEach, describe, expect, it, vi } from 'vitest';
11
+ import { DisplayItemModel } from '@nocobase/flow-engine';
12
+ import { AssociationFieldGroupModel } from '../AssociationFieldGroupModel';
13
+
14
+ function createCollection(name: string, fields: any[]) {
15
+ return {
16
+ name,
17
+ dataSourceKey: 'main',
18
+ getFields: () => fields,
19
+ getToOneAssociationFields: () => fields.filter((field) => field?.type === 'belongsTo' || field?.type === 'hasOne'),
20
+ };
21
+ }
22
+
23
+ describe('AssociationFieldGroupModel', () => {
24
+ afterEach(() => {
25
+ vi.restoreAllMocks();
26
+ });
27
+
28
+ it('limits display association fields to relation / relation / field', () => {
29
+ vi.spyOn(DisplayItemModel, 'getDefaultBindingByField').mockReturnValue({ modelName: 'InputFieldModel' } as any);
30
+
31
+ const companies = createCollection('companies', [
32
+ { name: 'title', title: 'Company title', interface: 'input', type: 'string' },
33
+ ]);
34
+ const departments = createCollection('departments', [
35
+ { name: 'name', title: 'Department name', interface: 'input', type: 'string' },
36
+ {
37
+ name: 'company',
38
+ title: 'Company',
39
+ interface: 'm2o',
40
+ type: 'belongsTo',
41
+ target: 'companies',
42
+ targetCollection: companies,
43
+ isAssociationField: () => true,
44
+ },
45
+ ]);
46
+ const users = createCollection('users', [
47
+ {
48
+ name: 'department',
49
+ title: 'Department',
50
+ interface: 'm2o',
51
+ type: 'belongsTo',
52
+ target: 'departments',
53
+ targetCollection: departments,
54
+ isAssociationField: () => true,
55
+ },
56
+ ]);
57
+ const posts = createCollection('posts', [
58
+ {
59
+ name: 'user',
60
+ title: 'User',
61
+ interface: 'm2o',
62
+ type: 'belongsTo',
63
+ target: 'users',
64
+ targetCollection: users,
65
+ isAssociationField: () => true,
66
+ },
67
+ ]);
68
+
69
+ const ctx = {
70
+ collection: posts,
71
+ t: (key: string) => key,
72
+ } as any;
73
+
74
+ const firstLevel = AssociationFieldGroupModel.defineChildren(ctx) as any[];
75
+ const firstChildren = firstLevel[0].children();
76
+ const firstAssociationGroup = firstChildren.find((item) => item.key === 'user-children-associationField');
77
+ const secondLevel = firstAssociationGroup.children;
78
+ const secondChildren = secondLevel[0].children();
79
+ const secondFieldsGroup = secondChildren.find((item) => item.key === 'user.department-children-collectionField');
80
+
81
+ expect(secondChildren.map((item) => item.key)).toContain('user.department-children-collectionField');
82
+ expect(secondChildren.map((item) => item.key)).not.toContain('user.department-children-associationField');
83
+ expect(secondFieldsGroup.children.map((item) => item.key)).toContain('c-user.department.name');
84
+ expect(secondFieldsGroup.children.map((item) => item.key)).not.toContain('c-user.department.company');
85
+ });
86
+ });
@@ -7,8 +7,17 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { DataSource, FlowEngine } from '@nocobase/flow-engine';
10
+ import { DataSource, FlowEngine, MultiRecordResource } from '@nocobase/flow-engine';
11
11
  import { createDefaultCollectionBlockTitle } from '../../../utils/blockUtils';
12
+ import { CollectionBlockModel } from '../CollectionBlockModel';
13
+
14
+ class MissingCollectionTitleBlockModel extends CollectionBlockModel {
15
+ static meta = { label: 'Table' };
16
+
17
+ createResource(ctx: any, _params: any) {
18
+ return ctx.createResource(MultiRecordResource);
19
+ }
20
+ }
12
21
 
13
22
  function normalizeTitle(s: string) {
14
23
  return (s || '').replace(/\s+/g, ' ').trim();
@@ -92,4 +101,27 @@ describe('CollectionBlockModel default title rule', () => {
92
101
  expect(t).toContain('Table:');
93
102
  expect(t).toContain('Main > Users > Tags (Tags)');
94
103
  });
104
+
105
+ it('uses resource settings as title fallback when the configured data source is unavailable', () => {
106
+ const engine = new FlowEngine();
107
+ engine.registerModels({ MissingCollectionTitleBlockModel });
108
+ const model = engine.createModel<MissingCollectionTitleBlockModel>({
109
+ uid: 'missing-data-source-title-block',
110
+ use: 'MissingCollectionTitleBlockModel',
111
+ stepParams: {
112
+ resourceSettings: {
113
+ init: {
114
+ dataSourceKey: 'mysql',
115
+ collectionName: 'orders',
116
+ },
117
+ },
118
+ },
119
+ });
120
+
121
+ const title = normalizeTitle(model.title);
122
+
123
+ expect(title).toContain('Table:');
124
+ expect(title).toContain('orders');
125
+ expect(title).not.toContain('undefined');
126
+ });
95
127
  });
@@ -12,11 +12,16 @@ import { AddSubModelButton, FlowSettingsButton, tExpr, EditableItemModel } from
12
12
  import { SettingOutlined } from '@ant-design/icons';
13
13
  import { FormGridModel } from '../form/FormGridModel';
14
14
 
15
+ const MAX_ASSIGN_FORM_ASSOCIATION_FIELD_DEPTH = 2;
16
+
15
17
  // 使用范型准确标注 subModels.items 的类型
16
18
  export class AssignFormGridModel extends FormGridModel {
17
19
  renderAddSubModelButton() {
18
20
  const collection = (this.context as any)?.collection;
19
21
  const fields = collection?.getFields?.() || [];
22
+ const associationDepth = this.context.prefixFieldPath
23
+ ? this.context.prefixFieldPath.split('.').filter(Boolean).length
24
+ : 0;
20
25
  // 过滤主键/过滤键字段,避免产生“更新主键”的无效配置
21
26
  const pk = typeof collection?.getPrimaryKey === 'function' ? collection.getPrimaryKey() : undefined;
22
27
  const filterKey = collection?.filterTargetKey;
@@ -29,6 +34,12 @@ export class AssignFormGridModel extends FormGridModel {
29
34
  };
30
35
  const items = fields
31
36
  .filter((field: any) => !isForbidden(field?.name))
37
+ .filter((field: any) => {
38
+ if (associationDepth < MAX_ASSIGN_FORM_ASSOCIATION_FIELD_DEPTH) {
39
+ return true;
40
+ }
41
+ return !(field?.isAssociationField?.() || field?.target || field?.targetCollection);
42
+ })
32
43
  .map((field: any) => {
33
44
  const fullName = field.name as string;
34
45
  const label = field.title || field.name;
@@ -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
  });
@@ -40,7 +40,20 @@ export const connectFields = defineAction({
40
40
  });
41
41
 
42
42
  // 构建级联选项数据结构
43
- const buildCascaderOptions = (ctx, fields: any[], prefix = '', selectedPaths: string[] = [], labelPrefix = '') => {
43
+ const MAX_ASSOCIATION_FIELD_DEPTH = 2;
44
+
45
+ const isAssociationField = (field: any) => {
46
+ return Boolean(field?.target || field?.targetCollection);
47
+ };
48
+
49
+ const buildCascaderOptions = (
50
+ ctx,
51
+ fields: any[],
52
+ prefix = '',
53
+ selectedPaths: string[] = [],
54
+ labelPrefix = '',
55
+ associationDepth = 0,
56
+ ) => {
44
57
  const selectedPathsArray = selectedPaths.filter(Boolean);
45
58
 
46
59
  return fields
@@ -48,7 +61,13 @@ const buildCascaderOptions = (ctx, fields: any[], prefix = '', selectedPaths: st
48
61
  const currentPath = prefix ? `${prefix}.${field.name}` : field.name;
49
62
  const label = field.title || field.name;
50
63
  const fullLabel = labelPrefix ? `${labelPrefix} / ${label}` : label;
51
- const isLeaf = !field.target;
64
+ const isAssociation = isAssociationField(field);
65
+ const nextAssociationDepth = associationDepth + (isAssociation ? 1 : 0);
66
+ if (isAssociation && nextAssociationDepth > MAX_ASSOCIATION_FIELD_DEPTH) {
67
+ return null;
68
+ }
69
+
70
+ const isLeaf = !isAssociation;
52
71
 
53
72
  const option: any = {
54
73
  label,
@@ -59,6 +78,7 @@ const buildCascaderOptions = (ctx, fields: any[], prefix = '', selectedPaths: st
59
78
  field,
60
79
  fullPath: currentPath,
61
80
  fullLabel,
81
+ associationDepth: nextAssociationDepth,
62
82
  },
63
83
  };
64
84
 
@@ -68,18 +88,33 @@ const buildCascaderOptions = (ctx, fields: any[], prefix = '', selectedPaths: st
68
88
 
69
89
  if (shouldLoadChildren) {
70
90
  if (field.filterable?.children?.length) {
71
- option.children = buildCascaderOptions(ctx, field.filterable.children, currentPath, selectedPaths, fullLabel);
91
+ option.children = buildCascaderOptions(
92
+ ctx,
93
+ field.filterable.children,
94
+ currentPath,
95
+ selectedPaths,
96
+ fullLabel,
97
+ nextAssociationDepth,
98
+ );
72
99
  } else {
73
100
  const targetCollection = field.targetCollection;
74
101
  if (targetCollection) {
75
102
  const targetFields = targetCollection.getFields?.() || [];
76
- option.children = buildCascaderOptions(ctx, targetFields, currentPath, selectedPaths, fullLabel);
103
+ option.children = buildCascaderOptions(
104
+ ctx,
105
+ targetFields,
106
+ currentPath,
107
+ selectedPaths,
108
+ fullLabel,
109
+ nextAssociationDepth,
110
+ );
77
111
  }
78
112
  }
79
113
  }
80
114
 
81
115
  return option;
82
116
  })
117
+ .filter(Boolean)
83
118
  .sort((a, b) => {
84
119
  // isLeaf 为 true 的节点排在前面
85
120
  if (a.isLeaf && !b.isLeaf) return -1;
@@ -259,7 +294,7 @@ function ConnectFields(
259
294
  <li>{ctx.t('Available "target blocks" are all data blocks on the current page')}</li>
260
295
  <li>
261
296
  {ctx.t(
262
- 'Support filtering multiple blocks simultaneously, support deep selection of relationship fields (e.g.: User/Department/Name)',
297
+ 'Support filtering multiple blocks simultaneously, support selection of relationship fields up to two levels (e.g.: User/Department/Name)',
263
298
  )}
264
299
  </li>
265
300
  </ul>
@@ -469,7 +504,14 @@ function CascaderWrapper(
469
504
  return;
470
505
  }
471
506
 
472
- target.children = buildCascaderOptions(ctx, childFields, fullPath, [], meta.fullLabel || target.label);
507
+ target.children = buildCascaderOptions(
508
+ ctx,
509
+ childFields,
510
+ fullPath,
511
+ [],
512
+ meta.fullLabel || target.label,
513
+ meta.associationDepth || 0,
514
+ );
473
515
  setOptions((prev) => [...prev]);
474
516
  };
475
517