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

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 (80) 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/AppListRender.d.ts +11 -0
  7. package/es/flow/admin-shell/admin-layout/index.d.ts +1 -0
  8. package/es/flow/admin-shell/admin-layout/useApplications.d.ts +3 -2
  9. package/es/flow/components/ConditionBuilder.d.ts +1 -0
  10. package/es/flow/components/FieldAssignRulesEditor.d.ts +3 -1
  11. package/es/flow/components/FieldAssignValueInput.d.ts +1 -0
  12. package/es/flow/components/fieldAssignOptions.d.ts +10 -0
  13. package/es/flow/components/filter/LinkageFilterItem.d.ts +1 -0
  14. package/es/flow/components/filter/VariableFilterItem.d.ts +1 -0
  15. package/es/flow/components/filter/metaTreeAssociationDepth.d.ts +13 -0
  16. package/es/flow/internal/utils/operatorSchemaHelper.d.ts +26 -3
  17. package/es/flow/models/base/AssociationFieldGroupModel.d.ts +2 -33
  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 +178 -100
  21. package/lib/index.js +187 -109
  22. package/package.json +7 -7
  23. package/src/BaseApplication.tsx +13 -0
  24. package/src/__tests__/browserChecker.test.ts +103 -0
  25. package/src/__tests__/dataSourceRuntime.test.tsx +22 -1
  26. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +5 -0
  27. package/src/authRedirect.ts +43 -0
  28. package/src/collection-field-interface/CollectionFieldInterface.ts +14 -7
  29. package/src/collection-manager/filter-operators/index.ts +29 -2
  30. package/src/components/form/filter/CollectionFilterItem.tsx +10 -2
  31. package/src/components/form/filter/FilterValueInput.tsx +27 -2
  32. package/src/components/form/filter/__tests__/FilterValueInput.test.tsx +39 -0
  33. package/src/flow/__tests__/FlowRoute.test.tsx +8 -1
  34. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +369 -2
  35. package/src/flow/actions/dataScope.tsx +8 -1
  36. package/src/flow/actions/linkageRules.tsx +56 -1
  37. package/src/flow/actions/setTargetDataScope.tsx +1 -1
  38. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +31 -3
  39. package/src/flow/admin-shell/admin-layout/AppListRender.tsx +139 -0
  40. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +8 -1
  41. package/src/flow/admin-shell/admin-layout/index.ts +1 -0
  42. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +11 -0
  43. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +2 -4
  44. package/src/flow/admin-shell/admin-layout/useApplications.tsx +34 -1
  45. package/src/flow/common/Markdown/Edit.tsx +32 -4
  46. package/src/flow/components/ConditionBuilder.tsx +12 -2
  47. package/src/flow/components/DefaultValue.tsx +14 -8
  48. package/src/flow/components/FieldAssignRulesEditor.tsx +37 -46
  49. package/src/flow/components/FieldAssignValueInput.tsx +14 -14
  50. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +112 -1
  51. package/src/flow/components/__tests__/fieldAssignOptions.test.ts +71 -1
  52. package/src/flow/components/fieldAssignOptions.ts +23 -6
  53. package/src/flow/components/filter/LinkageFilterItem.tsx +26 -12
  54. package/src/flow/components/filter/VariableFilterItem.tsx +32 -13
  55. package/src/flow/components/filter/__tests__/fieldsToOptions.test.ts +48 -0
  56. package/src/flow/components/filter/__tests__/metaTreeAssociationDepth.test.ts +60 -0
  57. package/src/flow/components/filter/fieldsToOptions.ts +9 -4
  58. package/src/flow/components/filter/metaTreeAssociationDepth.ts +69 -0
  59. package/src/flow/internal/utils/operatorSchemaHelper.ts +112 -5
  60. package/src/flow/models/actions/FilterActionModel.tsx +15 -3
  61. package/src/flow/models/base/AssociationFieldGroupModel.tsx +20 -3
  62. package/src/flow/models/base/__tests__/AssociationFieldGroupModel.test.ts +86 -0
  63. package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +11 -0
  64. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +14 -12
  65. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +6 -1
  66. package/src/flow/models/blocks/filter-manager/__tests__/getDefaultOperator.test.ts +35 -1
  67. package/src/flow/models/blocks/filter-manager/flow-actions/__tests__/customizeFilterRender.test.tsx +148 -0
  68. package/src/flow/models/blocks/filter-manager/flow-actions/__tests__/defaultOperator.test.tsx +85 -0
  69. package/src/flow/models/blocks/filter-manager/flow-actions/connectFields.tsx +48 -6
  70. package/src/flow/models/blocks/filter-manager/flow-actions/customizeFilterRender.tsx +14 -44
  71. package/src/flow/models/blocks/filter-manager/flow-actions/defaultOperator.tsx +3 -5
  72. package/src/flow/models/blocks/filter-manager/flow-actions/operatorComponentProps.tsx +2 -12
  73. package/src/flow/models/blocks/filter-manager/utils.ts +143 -4
  74. package/src/flow/models/blocks/form/FormAssociationItemModel.tsx +9 -0
  75. package/src/flow/models/blocks/form/FormItemModel.tsx +9 -0
  76. package/src/flow/models/blocks/form/__tests__/FormItemModel.defineChildren.test.ts +27 -2
  77. package/src/flow/models/blocks/table/TableColumnModel.tsx +15 -5
  78. package/src/flow/models/blocks/table/TableCustomColumnModel.tsx +2 -2
  79. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +18 -0
  80. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +6 -7
@@ -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
 
@@ -7,25 +7,13 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import React from 'react';
11
10
  import { defineAction } from '@nocobase/flow-engine';
12
11
  import { FilterFormItemModel } from '../../filter-form/FilterFormItemModel';
13
- import { InputFieldModel } from '../../../fields/InputFieldModel';
14
- import { resolveOperatorComponent } from '../../../../internal/utils/operatorSchemaHelper';
15
-
16
- const KEYWORD_OPERATORS = new Set(['$in', '$notIn']);
17
-
18
- function isOperatorSupported(model: FilterFormItemModel, operator: string) {
19
- const ops = model.collectionField?.filterable?.operators || [];
20
- return ops.some((op) => op.value === operator);
21
- }
22
-
23
- // 重新包裹 render,确保仍由 flow-engine 的响应式包装驱动
24
- function rewrapReactiveRender(fieldModel: any) {
25
- if (!fieldModel) return;
26
- fieldModel._reactiveWrapperCache = undefined;
27
- fieldModel.setupReactiveRender?.();
28
- }
12
+ import {
13
+ applyOperatorComponentRender,
14
+ restoreOperatorComponentRender,
15
+ } from '../../../../internal/utils/operatorSchemaHelper';
16
+ import { getFilterFormOperatorList } from '../utils';
29
17
 
30
18
  function applyCustomizeFilterRender(model: FilterFormItemModel) {
31
19
  const operator = model.operator;
@@ -37,37 +25,19 @@ function applyCustomizeFilterRender(model: FilterFormItemModel) {
37
25
  key: `${model.uid}-${operator || ''}`,
38
26
  });
39
27
 
40
- // 非关键词操作符,恢复原始渲染
41
- if (!KEYWORD_OPERATORS.has(operator)) {
42
- const originalRender = fieldModel['__originalRender'];
43
- if (typeof originalRender === 'function') {
44
- // 清理缓存,防止沿用上一轮的 reactive wrapper
45
- (fieldModel as any)._reactiveWrapperCache = undefined;
46
- fieldModel.render = originalRender;
47
- }
28
+ if (!operator) {
29
+ restoreOperatorComponentRender(fieldModel);
48
30
  return;
49
31
  }
50
32
 
51
- if (!(fieldModel instanceof InputFieldModel)) return;
52
- if (!isOperatorSupported(model, operator)) return;
53
-
54
- const resolved = resolveOperatorComponent(
55
- model.context.app,
33
+ const operatorList = getFilterFormOperatorList(model);
34
+ applyOperatorComponentRender({
35
+ app: model.context.app,
36
+ fieldModel,
56
37
  operator,
57
- model.collectionField?.filterable?.operators || [],
58
- );
59
- if (!resolved) return;
60
- const { Comp, props: xProps } = resolved;
61
-
62
- // 缓存一次原始 render/props,用于回退
63
- if (!fieldModel['__originalRender']) {
64
- fieldModel['__originalRender'] = fieldModel.render;
65
- }
66
-
67
- fieldModel.render = () => (
68
- <Comp {...xProps} {...fieldModel.props} onCompositionStart={null} onCompositionEnd={null} />
69
- );
70
- rewrapReactiveRender(fieldModel);
38
+ operators: operatorList,
39
+ propsPriority: 'field',
40
+ });
71
41
  }
72
42
 
73
43
  export const customizeFilterRender = defineAction<FilterFormItemModel>({
@@ -8,8 +8,8 @@
8
8
  */
9
9
 
10
10
  import { defineAction, tExpr, FlowModel } from '@nocobase/flow-engine';
11
- import { operators } from '../../../../../flow-compat';
12
11
  import { FilterFormFieldModel } from '../../filter-form/fields';
12
+ import { getFilterFormOperatorList } from '../utils';
13
13
 
14
14
  export const defaultOperator: any = defineAction<FilterFormFieldModel>({
15
15
  name: 'defaultOperator',
@@ -37,12 +37,10 @@ export const defaultOperator: any = defineAction<FilterFormFieldModel>({
37
37
 
38
38
  function getOperatorOptions(model: FlowModel) {
39
39
  const meta = model.context.collectionField || model.context.filterField;
40
- const operatorList =
41
- model.context.collectionField?.filterable?.operators || operators[model.context.filterField.type];
42
- return (operatorList || [])
40
+ return getFilterFormOperatorList(model)
43
41
  .filter((op) => !op.visible || op.visible(meta))
44
42
  .map((op) => ({
45
43
  ...op,
46
- label: model.translate(op.label),
44
+ label: typeof op.label === 'string' ? model.translate(op.label) : op.label,
47
45
  }));
48
46
  }
@@ -9,20 +9,10 @@
9
9
 
10
10
  import { defineAction } from '@nocobase/flow-engine';
11
11
  import { FilterFormItemModel } from '../../filter-form/FilterFormItemModel';
12
-
13
- function getOperatorMeta(model: FilterFormItemModel) {
14
- const ops = model.collectionField?.filterable?.operators || [];
15
- if (!ops.length) {
16
- return null;
17
- }
18
- if (!model.operator) {
19
- return null;
20
- }
21
- return ops.find((op) => op.value === model.operator) || null;
22
- }
12
+ import { getFilterFormOperatorMeta } from '../utils';
23
13
 
24
14
  function applyOperatorComponentProps(model: FilterFormItemModel) {
25
- const meta = getOperatorMeta(model);
15
+ const meta = getFilterFormOperatorMeta(model, model.operator);
26
16
  if (!meta) {
27
17
  return;
28
18
  }
@@ -8,14 +8,153 @@
8
8
  */
9
9
 
10
10
  import { FlowModel } from '@nocobase/flow-engine';
11
+ import {
12
+ normalizeFilterableOperators,
13
+ type FieldFilterable,
14
+ type FieldFilterOperator,
15
+ } from '../../../../collection-manager/filter-operators';
16
+ import { operators } from '../../../../flow-compat';
11
17
  import { BlockGridModel, CollectionBlockModel } from '../../base';
12
18
 
13
- export function getDefaultOperator(model: any) {
19
+ type FilterFormFieldMeta = {
20
+ filterable?: FieldFilterable<unknown> | false;
21
+ interface?: string;
22
+ type?: string;
23
+ };
24
+
25
+ type FilterFormOperatorModel = FlowModel & {
26
+ operator?: string;
27
+ collectionField?: FilterFormFieldMeta;
28
+ filterField?: FilterFormFieldMeta;
29
+ subModels?: {
30
+ field?: {
31
+ operator?: string;
32
+ };
33
+ };
34
+ getStepParams?: (flowKey: string, stepKey: string) => { value?: string } | undefined;
35
+ };
36
+
37
+ function getModelContext(model: FlowModel) {
38
+ return model.context as unknown as Record<string, unknown>;
39
+ }
40
+
41
+ function getModelField(model: FlowModel, key: 'collectionField' | 'filterField') {
42
+ const modelWithFields = model as FilterFormOperatorModel;
43
+ const field = modelWithFields[key] || getModelContext(model)?.[key];
44
+ return field && typeof field === 'object' ? (field as FilterFormFieldMeta) : undefined;
45
+ }
46
+
47
+ function getDataSourceManager(model: FlowModel) {
48
+ const contextManager = getModelContext(model)?.dataSourceManager;
49
+ if (contextManager) {
50
+ return contextManager as Record<string, unknown>;
51
+ }
52
+ return model.flowEngine?.context?.dataSourceManager as unknown as Record<string, unknown> | undefined;
53
+ }
54
+
55
+ function cloneFilterable(filterable: FieldFilterable<unknown>): FieldFilterable<unknown> {
56
+ return {
57
+ operatorGroup: filterable.operatorGroup,
58
+ operators: Array.isArray(filterable.operators) ? [...filterable.operators] : filterable.operators,
59
+ operatorOverrides: Array.isArray(filterable.operatorOverrides)
60
+ ? [...filterable.operatorOverrides]
61
+ : filterable.operatorOverrides,
62
+ };
63
+ }
64
+
65
+ function getFilterableOperators(filterable?: FieldFilterable<unknown> | false) {
66
+ if (filterable === false) {
67
+ return [];
68
+ }
69
+
70
+ if (!filterable || typeof filterable !== 'object') {
71
+ return;
72
+ }
73
+
74
+ const normalized = normalizeFilterableOperators(cloneFilterable(filterable));
75
+ if (Array.isArray(normalized?.operators) && normalized.operators.length > 0) {
76
+ return normalized.operators;
77
+ }
78
+
79
+ if (Array.isArray(normalized?.operatorOverrides) && normalized.operatorOverrides.length > 0) {
80
+ return normalized.operatorOverrides;
81
+ }
82
+ }
83
+
84
+ function getFieldInterfaceFilterable(model: FlowModel, field?: FilterFormFieldMeta) {
85
+ const interfaceName = typeof field?.interface === 'string' ? field.interface : undefined;
86
+ if (!interfaceName) {
87
+ return;
88
+ }
89
+
90
+ const dataSourceManager = getDataSourceManager(model);
91
+ const fieldInterfaceManager = dataSourceManager?.collectionFieldInterfaceManager as
92
+ | {
93
+ getFieldInterface?: (name: string) => { filterable?: FieldFilterable<unknown> | false } | undefined;
94
+ }
95
+ | undefined;
96
+ const fieldInterface = fieldInterfaceManager?.getFieldInterface?.(interfaceName);
97
+ return fieldInterface?.filterable;
98
+ }
99
+
100
+ function getFallbackOperators(field?: FilterFormFieldMeta) {
101
+ const fallbackKeys = [field?.interface, field?.type].filter((item): item is string => typeof item === 'string');
102
+ const standardOperators = operators as Record<string, FieldFilterOperator<unknown>[] | undefined>;
103
+ for (const key of fallbackKeys) {
104
+ const operatorList = standardOperators[key];
105
+ if (operatorList?.length) {
106
+ return operatorList;
107
+ }
108
+ }
109
+ return [];
110
+ }
111
+
112
+ export function getFilterFormOperatorList(model: FlowModel) {
113
+ const collectionField = getModelField(model, 'collectionField');
114
+ const filterField = getModelField(model, 'filterField');
115
+ const field = collectionField || filterField;
116
+
117
+ if (field?.filterable === false) {
118
+ return [];
119
+ }
120
+
121
+ const fieldOperators = getFilterableOperators(field?.filterable);
122
+ if (fieldOperators?.length) {
123
+ return fieldOperators;
124
+ }
125
+
126
+ const fieldInterfaceFilterable = getFieldInterfaceFilterable(model, field);
127
+ if (fieldInterfaceFilterable === false) {
128
+ return [];
129
+ }
130
+
131
+ const fieldInterfaceOperators = getFilterableOperators(fieldInterfaceFilterable);
132
+ if (fieldInterfaceOperators?.length) {
133
+ return fieldInterfaceOperators;
134
+ }
135
+
136
+ return getFallbackOperators(field);
137
+ }
138
+
139
+ export function getFilterFormOperatorMeta(model: FlowModel, operator = getDefaultOperator(model)) {
140
+ if (!operator) {
141
+ return null;
142
+ }
143
+
144
+ const meta = getModelField(model, 'collectionField') || getModelField(model, 'filterField');
145
+ return (
146
+ getFilterFormOperatorList(model).find((op) => op.value === operator && (!op.visible || op.visible(meta))) || null
147
+ );
148
+ }
149
+
150
+ export function getDefaultOperator(model: FilterFormOperatorModel) {
151
+ const operatorList = getFilterFormOperatorList(model);
14
152
  return (
15
153
  model.operator ||
16
- model.getStepParams('filterFormItemSettings', 'defaultOperator')?.value ||
17
- model.subModels.field?.operator ||
18
- model.collectionField?.filterable?.operators?.[0]?.value ||
154
+ model.getStepParams?.('filterFormItemSettings', 'defaultOperator')?.value ||
155
+ model.subModels?.field?.operator ||
156
+ operatorList.find((op) => op.selected)?.value ||
157
+ operatorList[0]?.value ||
19
158
  '$includes'
20
159
  );
21
160
  }
@@ -22,6 +22,8 @@ import { FieldModel } from '../../base/FieldModel';
22
22
  import { rebuildFieldSubModel } from '../../../internal/utils/rebuildFieldSubModel';
23
23
  import { useJsonTemplateResolver } from '../../../utils/useJsonTemplateResolver';
24
24
 
25
+ const MAX_FORM_ASSOCIATION_FIELD_DEPTH = 2;
26
+
25
27
  function mergeArrays(a, b) {
26
28
  if (!b) {
27
29
  return a;
@@ -84,10 +86,17 @@ const AssociationItem = (props) => {
84
86
  export class FormAssociationItemModel extends DisplayItemModel {
85
87
  static defineChildren(ctx: FlowModelContext) {
86
88
  const collection = ctx.collection as Collection;
89
+ const associationDepth = ctx.prefixFieldPath ? ctx.prefixFieldPath.split('.').filter(Boolean).length : 0;
87
90
 
88
91
  return collection
89
92
  .getFields()
90
93
  .map((field) => {
94
+ if (
95
+ associationDepth >= MAX_FORM_ASSOCIATION_FIELD_DEPTH &&
96
+ (field.isAssociationField?.() || field.target || field.targetCollection)
97
+ ) {
98
+ return null;
99
+ }
91
100
  const binding = this.getDefaultBindingByField(ctx, field, { fallbackToTargetTitleField: true });
92
101
  if (!binding) return null;
93
102
  const fieldModel = binding.modelName;
@@ -42,12 +42,21 @@ const interfacesOfUnsupportedDefaultValue = [
42
42
  'formula',
43
43
  ];
44
44
 
45
+ const MAX_FORM_ASSOCIATION_FIELD_DEPTH = 2;
46
+
45
47
  export class FormItemModel<T extends DefaultStructure = DefaultStructure> extends EditableItemModel<T> {
46
48
  static defineChildren(ctx: FlowModelContext) {
47
49
  const collection = ctx.collection as Collection;
50
+ const associationDepth = ctx.prefixFieldPath ? ctx.prefixFieldPath.split('.').filter(Boolean).length : 0;
48
51
  return collection
49
52
  .getFields()
50
53
  .map((field) => {
54
+ if (
55
+ associationDepth >= MAX_FORM_ASSOCIATION_FIELD_DEPTH &&
56
+ (field.isAssociationField?.() || field.target || field.targetCollection)
57
+ ) {
58
+ return;
59
+ }
51
60
  const binding = this.getDefaultBindingByField(ctx, field);
52
61
  if (!binding) {
53
62
  return;