@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.
- package/es/BaseApplication.d.ts +9 -0
- package/es/authRedirect.d.ts +12 -0
- package/es/collection-manager/filter-operators/index.d.ts +1 -0
- package/es/components/form/filter/CollectionFilterItem.d.ts +5 -1
- package/es/components/form/filter/FilterValueInput.d.ts +5 -0
- package/es/flow/admin-shell/admin-layout/AppListRender.d.ts +11 -0
- package/es/flow/admin-shell/admin-layout/index.d.ts +1 -0
- package/es/flow/admin-shell/admin-layout/useApplications.d.ts +3 -2
- package/es/flow/components/ConditionBuilder.d.ts +1 -0
- package/es/flow/components/FieldAssignRulesEditor.d.ts +3 -1
- package/es/flow/components/FieldAssignValueInput.d.ts +1 -0
- package/es/flow/components/fieldAssignOptions.d.ts +10 -0
- package/es/flow/components/filter/LinkageFilterItem.d.ts +1 -0
- package/es/flow/components/filter/VariableFilterItem.d.ts +1 -0
- package/es/flow/components/filter/metaTreeAssociationDepth.d.ts +13 -0
- package/es/flow/internal/utils/operatorSchemaHelper.d.ts +26 -3
- package/es/flow/models/base/AssociationFieldGroupModel.d.ts +2 -33
- package/es/flow/models/blocks/filter-manager/utils.d.ts +23 -1
- package/es/flow/models/blocks/table/TableColumnModel.d.ts +2 -0
- package/es/index.mjs +178 -100
- package/lib/index.js +187 -109
- package/package.json +7 -7
- package/src/BaseApplication.tsx +13 -0
- package/src/__tests__/browserChecker.test.ts +103 -0
- package/src/__tests__/dataSourceRuntime.test.tsx +22 -1
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +5 -0
- package/src/authRedirect.ts +43 -0
- package/src/collection-field-interface/CollectionFieldInterface.ts +14 -7
- package/src/collection-manager/filter-operators/index.ts +29 -2
- package/src/components/form/filter/CollectionFilterItem.tsx +10 -2
- package/src/components/form/filter/FilterValueInput.tsx +27 -2
- package/src/components/form/filter/__tests__/FilterValueInput.test.tsx +39 -0
- package/src/flow/__tests__/FlowRoute.test.tsx +8 -1
- package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +369 -2
- package/src/flow/actions/dataScope.tsx +8 -1
- package/src/flow/actions/linkageRules.tsx +56 -1
- package/src/flow/actions/setTargetDataScope.tsx +1 -1
- package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +31 -3
- package/src/flow/admin-shell/admin-layout/AppListRender.tsx +139 -0
- package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +8 -1
- package/src/flow/admin-shell/admin-layout/index.ts +1 -0
- package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +11 -0
- package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +2 -4
- package/src/flow/admin-shell/admin-layout/useApplications.tsx +34 -1
- package/src/flow/common/Markdown/Edit.tsx +32 -4
- package/src/flow/components/ConditionBuilder.tsx +12 -2
- package/src/flow/components/DefaultValue.tsx +14 -8
- package/src/flow/components/FieldAssignRulesEditor.tsx +37 -46
- package/src/flow/components/FieldAssignValueInput.tsx +14 -14
- package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +112 -1
- package/src/flow/components/__tests__/fieldAssignOptions.test.ts +71 -1
- package/src/flow/components/fieldAssignOptions.ts +23 -6
- package/src/flow/components/filter/LinkageFilterItem.tsx +26 -12
- package/src/flow/components/filter/VariableFilterItem.tsx +32 -13
- package/src/flow/components/filter/__tests__/fieldsToOptions.test.ts +48 -0
- package/src/flow/components/filter/__tests__/metaTreeAssociationDepth.test.ts +60 -0
- package/src/flow/components/filter/fieldsToOptions.ts +9 -4
- package/src/flow/components/filter/metaTreeAssociationDepth.ts +69 -0
- package/src/flow/internal/utils/operatorSchemaHelper.ts +112 -5
- package/src/flow/models/actions/FilterActionModel.tsx +15 -3
- package/src/flow/models/base/AssociationFieldGroupModel.tsx +20 -3
- package/src/flow/models/base/__tests__/AssociationFieldGroupModel.test.ts +86 -0
- package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +11 -0
- package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +14 -12
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +6 -1
- package/src/flow/models/blocks/filter-manager/__tests__/getDefaultOperator.test.ts +35 -1
- package/src/flow/models/blocks/filter-manager/flow-actions/__tests__/customizeFilterRender.test.tsx +148 -0
- package/src/flow/models/blocks/filter-manager/flow-actions/__tests__/defaultOperator.test.tsx +85 -0
- package/src/flow/models/blocks/filter-manager/flow-actions/connectFields.tsx +48 -6
- package/src/flow/models/blocks/filter-manager/flow-actions/customizeFilterRender.tsx +14 -44
- package/src/flow/models/blocks/filter-manager/flow-actions/defaultOperator.tsx +3 -5
- package/src/flow/models/blocks/filter-manager/flow-actions/operatorComponentProps.tsx +2 -12
- package/src/flow/models/blocks/filter-manager/utils.ts +143 -4
- package/src/flow/models/blocks/form/FormAssociationItemModel.tsx +9 -0
- package/src/flow/models/blocks/form/FormItemModel.tsx +9 -0
- package/src/flow/models/blocks/form/__tests__/FormItemModel.defineChildren.test.ts +27 -2
- package/src/flow/models/blocks/table/TableColumnModel.tsx +15 -5
- package/src/flow/models/blocks/table/TableCustomColumnModel.tsx +2 -2
- package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +18 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +6 -7
|
@@ -0,0 +1,60 @@
|
|
|
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 { describe, expect, it } from 'vitest';
|
|
11
|
+
import type { MetaTreeNode } from '@nocobase/flow-engine';
|
|
12
|
+
import { limitAssociationMetaTree } from '../metaTreeAssociationDepth';
|
|
13
|
+
|
|
14
|
+
describe('limitAssociationMetaTree', () => {
|
|
15
|
+
it('keeps relation / relation / normal field and removes deeper relation fields', async () => {
|
|
16
|
+
const tree: MetaTreeNode[] = [
|
|
17
|
+
{
|
|
18
|
+
name: 'user',
|
|
19
|
+
title: 'User',
|
|
20
|
+
type: 'object',
|
|
21
|
+
interface: 'm2o',
|
|
22
|
+
options: { target: 'users' },
|
|
23
|
+
paths: ['collection', 'user'],
|
|
24
|
+
children: async () => [
|
|
25
|
+
{
|
|
26
|
+
name: 'department',
|
|
27
|
+
title: 'Department',
|
|
28
|
+
type: 'object',
|
|
29
|
+
interface: 'm2o',
|
|
30
|
+
options: { target: 'departments' },
|
|
31
|
+
paths: ['collection', 'user', 'department'],
|
|
32
|
+
children: async () => [
|
|
33
|
+
{
|
|
34
|
+
name: 'name',
|
|
35
|
+
title: 'Name',
|
|
36
|
+
type: 'string',
|
|
37
|
+
interface: 'input',
|
|
38
|
+
paths: ['collection', 'user', 'department', 'name'],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'company',
|
|
42
|
+
title: 'Company',
|
|
43
|
+
type: 'object',
|
|
44
|
+
interface: 'm2o',
|
|
45
|
+
options: { target: 'companies' },
|
|
46
|
+
paths: ['collection', 'user', 'department', 'company'],
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const limited = limitAssociationMetaTree(tree);
|
|
55
|
+
const userChildren = await (limited[0].children as () => Promise<MetaTreeNode[]>)();
|
|
56
|
+
const departmentChildren = await (userChildren[0].children as () => Promise<MetaTreeNode[]>)();
|
|
57
|
+
|
|
58
|
+
expect(departmentChildren.map((node) => node.name)).toEqual(['name']);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -59,10 +59,15 @@ const field2option = (field: CollectionField, depth, nonfilterable, t) => {
|
|
|
59
59
|
option['children'] = children;
|
|
60
60
|
}
|
|
61
61
|
if (nested) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
let targetFields: CollectionField[] = [];
|
|
63
|
+
try {
|
|
64
|
+
targetFields = field.getFields().filter((f) => {
|
|
65
|
+
// 过滤掉附件字段,因为会报错:Target collection attachments not found for field xxx
|
|
66
|
+
return f.target !== 'attachments' && f.interface !== 'formula';
|
|
67
|
+
});
|
|
68
|
+
} catch {
|
|
69
|
+
targetFields = [];
|
|
70
|
+
}
|
|
66
71
|
const options = fieldsToOptions(targetFields, depth + 1, nonfilterable, t).filter(Boolean);
|
|
67
72
|
option['children'] = option['children'] || [];
|
|
68
73
|
option['children'].push(...options);
|
|
@@ -0,0 +1,69 @@
|
|
|
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 type { MetaTreeNode } from '@nocobase/flow-engine';
|
|
11
|
+
|
|
12
|
+
const ASSOCIATION_FIELD_INTERFACES = new Set(['m2o', 'o2o', 'oho', 'obo', 'o2m', 'm2m', 'linkTo']);
|
|
13
|
+
|
|
14
|
+
function isAssociationMetaNode(node: MetaTreeNode) {
|
|
15
|
+
const options = (node as any)?.options || {};
|
|
16
|
+
return Boolean(
|
|
17
|
+
options.target ||
|
|
18
|
+
options.targetCollection ||
|
|
19
|
+
options.foreignKey ||
|
|
20
|
+
options.through ||
|
|
21
|
+
ASSOCIATION_FIELD_INTERFACES.has(String(node?.interface || '')),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function limitNodeAssociationDepth(node: MetaTreeNode, associationDepth: number, maxAssociationDepth: number) {
|
|
26
|
+
const isAssociation = isAssociationMetaNode(node);
|
|
27
|
+
const nextAssociationDepth = associationDepth + (isAssociation ? 1 : 0);
|
|
28
|
+
|
|
29
|
+
if (isAssociation && nextAssociationDepth > maxAssociationDepth) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const wrapChildren = (children: MetaTreeNode[] | (() => Promise<MetaTreeNode[]>)) => {
|
|
34
|
+
if (Array.isArray(children)) {
|
|
35
|
+
return limitAssociationMetaTree(children, {
|
|
36
|
+
associationDepth: nextAssociationDepth,
|
|
37
|
+
maxAssociationDepth,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return async () => {
|
|
42
|
+
const loaded = await children();
|
|
43
|
+
return limitAssociationMetaTree(Array.isArray(loaded) ? loaded : [], {
|
|
44
|
+
associationDepth: nextAssociationDepth,
|
|
45
|
+
maxAssociationDepth,
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
...node,
|
|
52
|
+
children: node.children ? wrapChildren(node.children as any) : undefined,
|
|
53
|
+
} as MetaTreeNode;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function limitAssociationMetaTree(
|
|
57
|
+
nodes: MetaTreeNode[] | undefined,
|
|
58
|
+
options: {
|
|
59
|
+
associationDepth?: number;
|
|
60
|
+
maxAssociationDepth?: number;
|
|
61
|
+
} = {},
|
|
62
|
+
): MetaTreeNode[] {
|
|
63
|
+
const { associationDepth = 0, maxAssociationDepth = 2 } = options;
|
|
64
|
+
if (!Array.isArray(nodes)) return [];
|
|
65
|
+
|
|
66
|
+
return nodes
|
|
67
|
+
.map((node) => limitNodeAssociationDepth(node, associationDepth, maxAssociationDepth))
|
|
68
|
+
.filter(Boolean) as MetaTreeNode[];
|
|
69
|
+
}
|
|
@@ -7,24 +7,131 @@
|
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import React, { type ComponentType, type CSSProperties, type ReactNode } from 'react';
|
|
10
11
|
import { DateFilterDynamicComponent } from '../../models/blocks/filter-form/fields/date-time/components/DateFilterDynamicComponent';
|
|
11
12
|
|
|
13
|
+
type OperatorMeta = {
|
|
14
|
+
value?: string;
|
|
15
|
+
schema?: {
|
|
16
|
+
'x-component'?: string;
|
|
17
|
+
'x-component-props'?: Record<string, unknown>;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type ComponentRegistry = {
|
|
22
|
+
getComponent?: (name: string) => ComponentType<Record<string, unknown>> | undefined;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type OperatorComponentFieldModel = {
|
|
26
|
+
props?: Record<string, unknown>;
|
|
27
|
+
render?: () => ReactNode;
|
|
28
|
+
__originalRender?: () => ReactNode;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type OperatorComponentRenderOptions = {
|
|
32
|
+
app?: ComponentRegistry;
|
|
33
|
+
fieldModel: unknown;
|
|
34
|
+
operator: string;
|
|
35
|
+
operators?: OperatorMeta[];
|
|
36
|
+
propsPriority?: 'field' | 'operator';
|
|
37
|
+
style?: CSSProperties;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
41
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function pickOperatorStyle(value: unknown): CSSProperties | undefined {
|
|
45
|
+
return isRecord(value) ? (value as CSSProperties) : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getFieldModel(fieldModel: unknown) {
|
|
49
|
+
return fieldModel as OperatorComponentFieldModel & {
|
|
50
|
+
_reactiveWrapperCache?: unknown;
|
|
51
|
+
setupReactiveRender?: () => void;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function rewrapReactiveRender(fieldModel: unknown) {
|
|
56
|
+
const mutableFieldModel = getFieldModel(fieldModel);
|
|
57
|
+
mutableFieldModel._reactiveWrapperCache = undefined;
|
|
58
|
+
mutableFieldModel.setupReactiveRender?.();
|
|
59
|
+
}
|
|
60
|
+
|
|
12
61
|
/**
|
|
13
62
|
* 根据操作符的 schema 定位组件及其 props。
|
|
14
63
|
*/
|
|
15
|
-
export function resolveOperatorComponent(
|
|
64
|
+
export function resolveOperatorComponent(
|
|
65
|
+
app: ComponentRegistry | undefined,
|
|
66
|
+
operator: string,
|
|
67
|
+
operators?: OperatorMeta[],
|
|
68
|
+
) {
|
|
16
69
|
if (!operators || !Array.isArray(operators)) return null;
|
|
17
70
|
const op = operators.find((item) => item?.value === operator);
|
|
18
71
|
const schema = op?.schema;
|
|
19
72
|
const xComp = schema?.['x-component'];
|
|
20
73
|
if (!xComp) return null;
|
|
21
|
-
let Comp;
|
|
74
|
+
let Comp: ComponentType<Record<string, unknown>> | undefined;
|
|
22
75
|
if (xComp === 'DateFilterDynamicComponent') {
|
|
23
|
-
Comp = DateFilterDynamicComponent
|
|
76
|
+
Comp = DateFilterDynamicComponent as ComponentType<Record<string, unknown>>;
|
|
24
77
|
} else {
|
|
25
|
-
Comp = app?.getComponent?.(xComp
|
|
78
|
+
Comp = app?.getComponent?.(xComp);
|
|
26
79
|
}
|
|
27
80
|
if (!Comp) return null;
|
|
28
|
-
const props = (schema?.['x-component-props']
|
|
81
|
+
const props = isRecord(schema?.['x-component-props']) ? schema['x-component-props'] : {};
|
|
29
82
|
return { Comp, props };
|
|
30
83
|
}
|
|
84
|
+
|
|
85
|
+
export function restoreOperatorComponentRender(fieldModel?: unknown) {
|
|
86
|
+
const mutableFieldModel = getFieldModel(fieldModel);
|
|
87
|
+
if (!mutableFieldModel || typeof mutableFieldModel.__originalRender !== 'function') {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
mutableFieldModel.render = mutableFieldModel.__originalRender;
|
|
92
|
+
rewrapReactiveRender(mutableFieldModel);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function applyOperatorComponentRender({
|
|
97
|
+
app,
|
|
98
|
+
fieldModel,
|
|
99
|
+
operator,
|
|
100
|
+
operators,
|
|
101
|
+
propsPriority = 'field',
|
|
102
|
+
style,
|
|
103
|
+
}: OperatorComponentRenderOptions) {
|
|
104
|
+
const mutableFieldModel = getFieldModel(fieldModel);
|
|
105
|
+
const resolved = resolveOperatorComponent(app, operator, operators);
|
|
106
|
+
if (!resolved) {
|
|
107
|
+
restoreOperatorComponentRender(mutableFieldModel);
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!mutableFieldModel.__originalRender && typeof mutableFieldModel.render === 'function') {
|
|
112
|
+
mutableFieldModel.__originalRender = mutableFieldModel.render;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const { Comp, props: operatorProps } = resolved;
|
|
116
|
+
const operatorStyle = pickOperatorStyle(operatorProps.style);
|
|
117
|
+
|
|
118
|
+
mutableFieldModel.render = () => {
|
|
119
|
+
const fieldProps = mutableFieldModel.props || {};
|
|
120
|
+
const fieldStyle = pickOperatorStyle(fieldProps.style);
|
|
121
|
+
const componentProps =
|
|
122
|
+
propsPriority === 'operator' ? { ...fieldProps, ...operatorProps } : { ...operatorProps, ...fieldProps };
|
|
123
|
+
const componentStyle =
|
|
124
|
+
propsPriority === 'operator' ? { ...fieldStyle, ...operatorStyle } : { ...operatorStyle, ...fieldStyle };
|
|
125
|
+
const mergedStyle = { ...(style || {}), ...componentStyle };
|
|
126
|
+
|
|
127
|
+
return React.createElement(Comp, {
|
|
128
|
+
...componentProps,
|
|
129
|
+
...(Object.keys(mergedStyle).length > 0 ? { style: mergedStyle } : {}),
|
|
130
|
+
onCompositionStart: null,
|
|
131
|
+
onCompositionEnd: null,
|
|
132
|
+
});
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
rewrapReactiveRender(mutableFieldModel);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
@@ -75,7 +75,12 @@ export class FilterActionModel extends ActionModel {
|
|
|
75
75
|
value={this.props.filterValue || this.props.defaultFilterValue}
|
|
76
76
|
ctx={this.context}
|
|
77
77
|
FilterItem={(props) => (
|
|
78
|
-
<VariableFilterItem
|
|
78
|
+
<VariableFilterItem
|
|
79
|
+
{...props}
|
|
80
|
+
model={this}
|
|
81
|
+
ignoreFieldNames={this.getIgnoreFieldNames()}
|
|
82
|
+
maxAssociationFieldDepth={2}
|
|
83
|
+
/>
|
|
79
84
|
)}
|
|
80
85
|
/>
|
|
81
86
|
<div style={{ height: 150 }}></div>
|
|
@@ -94,7 +99,12 @@ export class FilterActionModel extends ActionModel {
|
|
|
94
99
|
value={this.props.filterValue || this.props.defaultFilterValue}
|
|
95
100
|
ctx={this.context}
|
|
96
101
|
FilterItem={(props) => (
|
|
97
|
-
<VariableFilterItem
|
|
102
|
+
<VariableFilterItem
|
|
103
|
+
{...props}
|
|
104
|
+
model={this}
|
|
105
|
+
ignoreFieldNames={this.getIgnoreFieldNames()}
|
|
106
|
+
maxAssociationFieldDepth={2}
|
|
107
|
+
/>
|
|
98
108
|
)}
|
|
99
109
|
/>
|
|
100
110
|
}
|
|
@@ -187,7 +197,9 @@ FilterActionModel.registerFlow({
|
|
|
187
197
|
return (
|
|
188
198
|
<FilterGroup
|
|
189
199
|
value={props.value || {}}
|
|
190
|
-
FilterItem={(props) =>
|
|
200
|
+
FilterItem={(props) => (
|
|
201
|
+
<VariableFilterItem {...props} model={modelInstance} maxAssociationFieldDepth={2} />
|
|
202
|
+
)}
|
|
191
203
|
/>
|
|
192
204
|
);
|
|
193
205
|
},
|
|
@@ -11,13 +11,20 @@ import { Collection, DisplayItemModel, FlowModel, FlowModelContext } from '@noco
|
|
|
11
11
|
|
|
12
12
|
export class AssociationFieldGroupModel extends FlowModel {
|
|
13
13
|
static itemModelName = 'DetailsItemModel';
|
|
14
|
+
static maxAssociationFieldDepth = 2;
|
|
14
15
|
static defineChildren(ctx: FlowModelContext) {
|
|
15
16
|
const itemModel = this.itemModelName;
|
|
16
|
-
const
|
|
17
|
+
const maxAssociationFieldDepth = this.maxAssociationFieldDepth ?? 2;
|
|
18
|
+
const displayAssociationFields = (targetCollection: Collection, fieldPath = '', associationDepth = 0) => {
|
|
19
|
+
if (associationDepth >= maxAssociationFieldDepth) {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
|
|
17
23
|
return targetCollection
|
|
18
24
|
.getToOneAssociationFields()
|
|
19
25
|
.map((field) => {
|
|
20
26
|
const fPath = fieldPath ? `${fieldPath}.${field.name}` : field.name;
|
|
27
|
+
const nextAssociationDepth = associationDepth + 1;
|
|
21
28
|
if (!field.targetCollection) {
|
|
22
29
|
console.error(
|
|
23
30
|
`AssociationFieldGroupModel: target collection ${field.target} not found for field ${field.name}`,
|
|
@@ -37,6 +44,12 @@ export class AssociationFieldGroupModel extends FlowModel {
|
|
|
37
44
|
children: field.targetCollection
|
|
38
45
|
.getFields()
|
|
39
46
|
.map((f) => {
|
|
47
|
+
if (
|
|
48
|
+
nextAssociationDepth >= maxAssociationFieldDepth &&
|
|
49
|
+
(f.isAssociationField?.() || f.target || f.targetCollection)
|
|
50
|
+
) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
40
53
|
const fp = `${fPath}.${f.name}`;
|
|
41
54
|
const binding = DisplayItemModel.getDefaultBindingByField(ctx, f, {
|
|
42
55
|
fallbackToTargetTitleField: true,
|
|
@@ -81,9 +94,13 @@ export class AssociationFieldGroupModel extends FlowModel {
|
|
|
81
94
|
key: `${fPath}-children-associationField`,
|
|
82
95
|
label: 'Display association fields',
|
|
83
96
|
type: 'group',
|
|
84
|
-
children: (
|
|
97
|
+
children: (
|
|
98
|
+
displayAssociationFields(field.targetCollection, fPath, nextAssociationDepth) || []
|
|
99
|
+
).filter(Boolean),
|
|
85
100
|
},
|
|
86
|
-
]
|
|
101
|
+
].filter((item) => {
|
|
102
|
+
return item.key !== `${fPath}-children-associationField` || (item.children as any[])?.length > 0;
|
|
103
|
+
});
|
|
87
104
|
},
|
|
88
105
|
};
|
|
89
106
|
})
|
|
@@ -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
|
+
});
|
|
@@ -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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts
CHANGED
|
@@ -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
|
|
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
|
});
|