@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
|
@@ -36,10 +36,11 @@ import {
|
|
|
36
36
|
isToManyAssociationField,
|
|
37
37
|
} from '../internal/utils/modelUtils';
|
|
38
38
|
import { RunJSValueEditor } from './RunJSValueEditor';
|
|
39
|
-
import { resolveOperatorComponent } from '../internal/utils/operatorSchemaHelper';
|
|
39
|
+
import { pickOperatorStyle as pickStyle, resolveOperatorComponent } from '../internal/utils/operatorSchemaHelper';
|
|
40
40
|
import { InputFieldModel } from '../models/fields/InputFieldModel';
|
|
41
41
|
import { normalizeFilterValueByOperator } from '../models/blocks/filter-form/valueNormalization';
|
|
42
42
|
import { FieldAssignExactDatePicker, type ExactDatePickerMode } from './FieldAssignExactDatePicker';
|
|
43
|
+
import { limitAssociationMetaTree } from './filter/metaTreeAssociationDepth';
|
|
43
44
|
|
|
44
45
|
const DATE_FIELD_INTERFACES = new Set(['date', 'datetime', 'datetimeNoTz', 'createdAt', 'updatedAt', 'unixTimestamp']);
|
|
45
46
|
|
|
@@ -340,6 +341,7 @@ interface Props {
|
|
|
340
341
|
* 默认 false,保持历史行为。
|
|
341
342
|
*/
|
|
342
343
|
enableDateVariableAsConstant?: boolean;
|
|
344
|
+
maxAssociationFieldDepth?: number;
|
|
343
345
|
}
|
|
344
346
|
|
|
345
347
|
type ResolvedFieldContext = {
|
|
@@ -352,14 +354,6 @@ type ResolvedFieldContext = {
|
|
|
352
354
|
collectionField: CollectionField | null;
|
|
353
355
|
};
|
|
354
356
|
|
|
355
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
356
|
-
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
function pickStyle(value: unknown): React.CSSProperties | undefined {
|
|
360
|
-
return isPlainObject(value) ? (value as React.CSSProperties) : undefined;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
357
|
function withFullWidthStyle(style?: React.CSSProperties): React.CSSProperties {
|
|
364
358
|
return { ...style, width: '100%', minWidth: 0 };
|
|
365
359
|
}
|
|
@@ -718,6 +712,7 @@ export const FieldAssignValueInput: React.FC<Props> = ({
|
|
|
718
712
|
preferFormItemFieldModel,
|
|
719
713
|
associationFieldNamesOverride,
|
|
720
714
|
enableDateVariableAsConstant = false,
|
|
715
|
+
maxAssociationFieldDepth = 2,
|
|
721
716
|
}) => {
|
|
722
717
|
const flowCtx = useFlowContext<FlowModelContext>();
|
|
723
718
|
const normalizeEventValue = React.useCallback((eventOrValue: unknown) => {
|
|
@@ -859,7 +854,7 @@ export const FieldAssignValueInput: React.FC<Props> = ({
|
|
|
859
854
|
fieldName,
|
|
860
855
|
collectionField: nested.collectionField || null,
|
|
861
856
|
};
|
|
862
|
-
}, [flowCtx
|
|
857
|
+
}, [flowCtx, itemModel, resolveNestedAssociationField, targetPath]);
|
|
863
858
|
|
|
864
859
|
const { collection, dataSource, blockModel, fieldPath, fieldName, collectionField: cf } = resolved;
|
|
865
860
|
const itemCollectionField = (resolved?.itemModel as any)?.context?.collectionField;
|
|
@@ -1172,7 +1167,11 @@ export const FieldAssignValueInput: React.FC<Props> = ({
|
|
|
1172
1167
|
<Comp
|
|
1173
1168
|
{...fieldModel.props}
|
|
1174
1169
|
{...xProps}
|
|
1175
|
-
style={{
|
|
1170
|
+
style={{
|
|
1171
|
+
width: '100%',
|
|
1172
|
+
...pickStyle((fieldModel.props as Record<string, unknown>)?.style),
|
|
1173
|
+
...pickStyle(xProps?.style),
|
|
1174
|
+
}}
|
|
1176
1175
|
/>
|
|
1177
1176
|
);
|
|
1178
1177
|
rewrapReactiveRender(fieldModel);
|
|
@@ -1232,7 +1231,7 @@ export const FieldAssignValueInput: React.FC<Props> = ({
|
|
|
1232
1231
|
);
|
|
1233
1232
|
};
|
|
1234
1233
|
return C;
|
|
1235
|
-
}, [placeholder, tempRoot, coerceEmptyValueForRenderer, normalizeEventValue]);
|
|
1234
|
+
}, [placeholder, tempRoot, coerceEmptyValueForRenderer, normalizeEventValue, operator]);
|
|
1236
1235
|
|
|
1237
1236
|
const DateVariableConstantEditor = React.useMemo(() => {
|
|
1238
1237
|
const C: React.FC<any> = (inputProps) => {
|
|
@@ -1408,6 +1407,7 @@ export const FieldAssignValueInput: React.FC<Props> = ({
|
|
|
1408
1407
|
const extra = extraMetaTreeRef.current;
|
|
1409
1408
|
const extraTree = Array.isArray(extra) ? extra : [];
|
|
1410
1409
|
const mergedBase = mergeItemMetaTreeForAssignValue(base as MetaTreeNode[], extraTree as MetaTreeNode[]);
|
|
1410
|
+
const limitedBase = limitAssociationMetaTree(mergedBase, { maxAssociationDepth: maxAssociationFieldDepth });
|
|
1411
1411
|
return [
|
|
1412
1412
|
{
|
|
1413
1413
|
title: tExpr('Constant'),
|
|
@@ -1418,10 +1418,10 @@ export const FieldAssignValueInput: React.FC<Props> = ({
|
|
|
1418
1418
|
},
|
|
1419
1419
|
{ title: tExpr('Null'), name: 'null', type: 'object', paths: ['null'], render: NullComponent },
|
|
1420
1420
|
{ title: tExpr('RunJS'), name: 'runjs', type: 'object', paths: ['runjs'], render: RunJSComponent },
|
|
1421
|
-
...
|
|
1421
|
+
...limitedBase,
|
|
1422
1422
|
];
|
|
1423
1423
|
};
|
|
1424
|
-
}, [flowCtx, ConstantEditor, NullComponent, RunJSComponent]);
|
|
1424
|
+
}, [flowCtx, ConstantEditor, NullComponent, RunJSComponent, maxAssociationFieldDepth]);
|
|
1425
1425
|
|
|
1426
1426
|
const displayValue = React.useMemo(() => {
|
|
1427
1427
|
if (!useDateVariableConstant) {
|
|
@@ -108,6 +108,54 @@ describe('FieldAssignRulesEditor', () => {
|
|
|
108
108
|
};
|
|
109
109
|
};
|
|
110
110
|
|
|
111
|
+
const createAssociationFixtureWithoutIsAssociationField = () => {
|
|
112
|
+
const profileCollection = {
|
|
113
|
+
titleField: 'name',
|
|
114
|
+
filterTargetKey: 'id',
|
|
115
|
+
getField: (name: string) => {
|
|
116
|
+
if (name === 'name') {
|
|
117
|
+
return { name: 'name', title: 'Name', interface: 'input' };
|
|
118
|
+
}
|
|
119
|
+
if (name === 'nickname') {
|
|
120
|
+
return { name: 'nickname', title: 'Nickname', interface: 'input' };
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
},
|
|
124
|
+
getFields: () => [
|
|
125
|
+
{
|
|
126
|
+
name: 'name',
|
|
127
|
+
title: 'Name',
|
|
128
|
+
interface: 'input',
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: 'nickname',
|
|
132
|
+
title: 'Nickname',
|
|
133
|
+
interface: 'input',
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const profileField: any = {
|
|
139
|
+
name: 'profile',
|
|
140
|
+
title: 'Profile',
|
|
141
|
+
type: 'belongsTo',
|
|
142
|
+
interface: 'm2o',
|
|
143
|
+
target: 'profiles',
|
|
144
|
+
targetKey: 'id',
|
|
145
|
+
targetCollection: profileCollection,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const rootCollection = {
|
|
149
|
+
getField: (name: string) => (name === 'profile' ? profileField : null),
|
|
150
|
+
getFields: () => [profileField],
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
profileCollection,
|
|
155
|
+
rootCollection,
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
|
|
111
159
|
const openAdvancedPanel = async () => {
|
|
112
160
|
await userEvent.click(screen.getByRole('button', { name: /Advanced/i }));
|
|
113
161
|
await screen.findByText('Title field');
|
|
@@ -175,6 +223,68 @@ describe('FieldAssignRulesEditor', () => {
|
|
|
175
223
|
});
|
|
176
224
|
});
|
|
177
225
|
|
|
226
|
+
it('recognizes lightweight association fields without isAssociationField()', async () => {
|
|
227
|
+
const { rootCollection } = createAssociationFixtureWithoutIsAssociationField();
|
|
228
|
+
const value: FieldAssignRuleItem[] = [
|
|
229
|
+
{
|
|
230
|
+
key: 'rule-profile',
|
|
231
|
+
enable: true,
|
|
232
|
+
targetPath: 'profile',
|
|
233
|
+
mode: 'assign',
|
|
234
|
+
},
|
|
235
|
+
];
|
|
236
|
+
|
|
237
|
+
const { container } = render(
|
|
238
|
+
wrap(
|
|
239
|
+
<FieldAssignRulesEditor
|
|
240
|
+
t={t}
|
|
241
|
+
fieldOptions={[]}
|
|
242
|
+
rootCollection={rootCollection}
|
|
243
|
+
value={value}
|
|
244
|
+
showCondition={false}
|
|
245
|
+
isTitleFieldCandidate={() => true}
|
|
246
|
+
/>,
|
|
247
|
+
),
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
await waitFor(() => {
|
|
251
|
+
const input = container.querySelector('[data-testid="mock-value-input"]');
|
|
252
|
+
expect(input?.getAttribute('data-assoc-label')).toBe('name');
|
|
253
|
+
expect(input?.getAttribute('data-assoc-value')).toBe('id');
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('resolves nested labels and item meta tree through lightweight association fields', async () => {
|
|
258
|
+
const { rootCollection } = createAssociationFixtureWithoutIsAssociationField();
|
|
259
|
+
const value: FieldAssignRuleItem[] = [
|
|
260
|
+
{
|
|
261
|
+
key: 'rule-profile-nickname',
|
|
262
|
+
enable: true,
|
|
263
|
+
targetPath: 'profile.nickname',
|
|
264
|
+
mode: 'assign',
|
|
265
|
+
},
|
|
266
|
+
];
|
|
267
|
+
|
|
268
|
+
const { container } = render(
|
|
269
|
+
wrap(
|
|
270
|
+
<FieldAssignRulesEditor
|
|
271
|
+
t={t}
|
|
272
|
+
fieldOptions={[]}
|
|
273
|
+
rootCollection={rootCollection}
|
|
274
|
+
value={value}
|
|
275
|
+
showCondition={false}
|
|
276
|
+
isTitleFieldCandidate={() => true}
|
|
277
|
+
/>,
|
|
278
|
+
),
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
await waitFor(() => {
|
|
282
|
+
expect(container.textContent).toContain('Profile / Nickname');
|
|
283
|
+
const input = container.querySelector('[data-testid="mock-value-input"]');
|
|
284
|
+
expect(input?.getAttribute('data-extra')).toBe('yes');
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
178
288
|
it('saves selected title field into current assign rule without syncing', async () => {
|
|
179
289
|
const { rootCollection, value } = createAssociationFixture();
|
|
180
290
|
const onChange = vi.fn();
|
|
@@ -785,7 +895,8 @@ describe('FieldAssignRulesEditor', () => {
|
|
|
785
895
|
expect(merged.map((node) => node.name)).toEqual(['formValues', 'item', 'user']);
|
|
786
896
|
expect(merged.filter((node) => node.name === 'item')).toHaveLength(1);
|
|
787
897
|
|
|
788
|
-
const itemNode = merged.find((node) => node.name === 'item')
|
|
898
|
+
const itemNode = merged.find((node) => node.name === 'item');
|
|
899
|
+
expect(itemNode).toBeTruthy();
|
|
789
900
|
const parent = (itemNode.children as MetaTreeNode[]).find((node) => node.name === 'parentItem');
|
|
790
901
|
expect(parent).toBeTruthy();
|
|
791
902
|
|
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
|
|
10
10
|
import { describe, expect, it } from 'vitest';
|
|
11
11
|
import { buildCustomFieldTargetPath } from '../../internal/utils/modelUtils';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
buildFieldAssignCascaderOptionsFromCollection,
|
|
14
|
+
collectFieldAssignCascaderOptions,
|
|
15
|
+
} from '../fieldAssignOptions';
|
|
13
16
|
|
|
14
17
|
describe('fieldAssignOptions', () => {
|
|
15
18
|
it('marks subform node as non-leaf so Cascader can lazy-load target collection fields', () => {
|
|
@@ -94,4 +97,71 @@ describe('fieldAssignOptions', () => {
|
|
|
94
97
|
options.some((o: any) => o?.value === customTargetPath && o?.label === 'Custom age' && o?.isLeaf === true),
|
|
95
98
|
).toBe(true);
|
|
96
99
|
});
|
|
100
|
+
|
|
101
|
+
it('hides configured association fields deeper than relation / relation / field', () => {
|
|
102
|
+
const makeItem = (fieldPath: string, field: any, label: string) => ({
|
|
103
|
+
props: { label },
|
|
104
|
+
getStepParams: (flowKey: string, stepKey: string) => {
|
|
105
|
+
if (flowKey === 'fieldSettings' && stepKey === 'init') return { fieldPath };
|
|
106
|
+
return {};
|
|
107
|
+
},
|
|
108
|
+
subModels: {
|
|
109
|
+
field: {
|
|
110
|
+
context: { collectionField: field },
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const companyField = {
|
|
116
|
+
name: 'company',
|
|
117
|
+
title: 'Company',
|
|
118
|
+
interface: 'm2o',
|
|
119
|
+
target: 'companies',
|
|
120
|
+
isAssociationField: () => true,
|
|
121
|
+
targetCollection: { getFields: () => [] },
|
|
122
|
+
};
|
|
123
|
+
const nameField = {
|
|
124
|
+
name: 'name',
|
|
125
|
+
title: 'Name',
|
|
126
|
+
interface: 'input',
|
|
127
|
+
isAssociationField: () => false,
|
|
128
|
+
};
|
|
129
|
+
const formBlockModel = {
|
|
130
|
+
context: { collection: null },
|
|
131
|
+
subModels: {
|
|
132
|
+
grid: {
|
|
133
|
+
subModels: {
|
|
134
|
+
items: [
|
|
135
|
+
makeItem('users.department.name', nameField, 'Department name'),
|
|
136
|
+
makeItem('users.department.company', companyField, 'Company'),
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const options = collectFieldAssignCascaderOptions({ formBlockModel, t: (s) => s });
|
|
144
|
+
|
|
145
|
+
expect(options.map((item) => item.value)).toContain('name');
|
|
146
|
+
expect(options.map((item) => item.value)).not.toContain('company');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('hides lazy-loaded association selector options after two association levels', () => {
|
|
150
|
+
const collection = {
|
|
151
|
+
getFields: () => [
|
|
152
|
+
{ name: 'name', title: 'Name', interface: 'input' },
|
|
153
|
+
{
|
|
154
|
+
name: 'company',
|
|
155
|
+
title: 'Company',
|
|
156
|
+
interface: 'm2o',
|
|
157
|
+
target: 'companies',
|
|
158
|
+
targetCollection: { getFields: () => [] },
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const options = buildFieldAssignCascaderOptionsFromCollection(collection, (s) => s, { associationDepth: 2 });
|
|
164
|
+
|
|
165
|
+
expect(options.map((item) => item.value)).toEqual(['name']);
|
|
166
|
+
});
|
|
97
167
|
});
|
|
@@ -76,10 +76,15 @@ function getCollectionFromFormBlockModel(model: any): CollectionLike | null {
|
|
|
76
76
|
return collection && typeof collection === 'object' ? (collection as CollectionLike) : null;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
function
|
|
79
|
+
export function buildFieldAssignCascaderOptionsFromCollection(
|
|
80
80
|
collection: CollectionLike | null,
|
|
81
81
|
t: (key: string) => string,
|
|
82
|
+
options: {
|
|
83
|
+
associationDepth?: number;
|
|
84
|
+
maxAssociationFieldDepth?: number;
|
|
85
|
+
} = {},
|
|
82
86
|
): FieldAssignCascaderOption[] {
|
|
87
|
+
const { associationDepth = 0, maxAssociationFieldDepth = 2 } = options;
|
|
83
88
|
const fields = typeof collection?.getFields === 'function' ? collection.getFields() || [] : [];
|
|
84
89
|
const out: FieldAssignCascaderOption[] = [];
|
|
85
90
|
for (const rawField of fields) {
|
|
@@ -88,6 +93,7 @@ function buildRootOptionsFromCollection(
|
|
|
88
93
|
name?: unknown;
|
|
89
94
|
title?: unknown;
|
|
90
95
|
interface?: unknown;
|
|
96
|
+
target?: unknown;
|
|
91
97
|
isAssociationField?: () => boolean;
|
|
92
98
|
targetCollection?: any;
|
|
93
99
|
};
|
|
@@ -99,8 +105,11 @@ function buildRootOptionsFromCollection(
|
|
|
99
105
|
if (!name) continue;
|
|
100
106
|
const title = t(typeof f.title === 'string' ? f.title : name);
|
|
101
107
|
|
|
102
|
-
const isAssoc = !!f.isAssociationField?.();
|
|
108
|
+
const isAssoc = !!(f.isAssociationField?.() || f.target || f.targetCollection);
|
|
103
109
|
const hasTarget = !!f.targetCollection;
|
|
110
|
+
if (isAssoc && associationDepth >= maxAssociationFieldDepth) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
104
113
|
|
|
105
114
|
out.push({
|
|
106
115
|
label: title,
|
|
@@ -138,8 +147,9 @@ export function collectFieldAssignCascaderOptions(options: {
|
|
|
138
147
|
t: (key: string) => string;
|
|
139
148
|
/** 子表单模型递归深度(FormItemModel 层级);默认不限制(只受实际配置与循环引用约束) */
|
|
140
149
|
maxFormItemDepth?: number;
|
|
150
|
+
maxAssociationFieldDepth?: number;
|
|
141
151
|
}): FieldAssignCascaderOption[] {
|
|
142
|
-
const { formBlockModel, t, maxFormItemDepth = Number.POSITIVE_INFINITY } = options;
|
|
152
|
+
const { formBlockModel, t, maxFormItemDepth = Number.POSITIVE_INFINITY, maxAssociationFieldDepth = 2 } = options;
|
|
143
153
|
|
|
144
154
|
const rootItems = formBlockModel?.subModels?.grid?.subModels?.items || [];
|
|
145
155
|
const rootCollection = getCollectionFromFormBlockModel(formBlockModel);
|
|
@@ -167,9 +177,14 @@ export function collectFieldAssignCascaderOptions(options: {
|
|
|
167
177
|
|
|
168
178
|
const fieldModel = item?.subModels?.field;
|
|
169
179
|
const childItems = fieldModel?.subModels?.grid?.subModels?.items;
|
|
170
|
-
const cf: CollectionField | undefined =
|
|
171
|
-
|
|
180
|
+
const cf: (CollectionField & { target?: unknown; targetCollection?: unknown }) | undefined =
|
|
181
|
+
fieldModel?.context?.collectionField;
|
|
182
|
+
const isAssociation = !!(cf?.isAssociationField?.() || cf?.target || cf?.targetCollection);
|
|
172
183
|
const hasTargetCollection = !!cf?.targetCollection;
|
|
184
|
+
const associationDepth = isAssociation ? targetPath.split('.').filter(Boolean).length : 0;
|
|
185
|
+
if (isAssociation && associationDepth > maxAssociationFieldDepth) {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
173
188
|
|
|
174
189
|
// 1) 子表单/子表单列表:递归展开已配置字段(支持无限深度)。
|
|
175
190
|
//
|
|
@@ -207,6 +222,8 @@ export function collectFieldAssignCascaderOptions(options: {
|
|
|
207
222
|
};
|
|
208
223
|
|
|
209
224
|
const configuredOptions = walkItems(rootItems, 1);
|
|
210
|
-
const allFieldOptions =
|
|
225
|
+
const allFieldOptions = buildFieldAssignCascaderOptionsFromCollection(rootCollection, t, {
|
|
226
|
+
maxAssociationFieldDepth,
|
|
227
|
+
});
|
|
211
228
|
return mergeRootOptions(configuredOptions, allFieldOptions);
|
|
212
229
|
}
|
|
@@ -29,7 +29,8 @@ import {
|
|
|
29
29
|
UiSchemaEnumItem,
|
|
30
30
|
} from '../../internal/utils/enumOptionsUtils';
|
|
31
31
|
import { mergeItemMetaTreeForAssignValue } from '../FieldAssignValueInput';
|
|
32
|
-
import { resolveOperatorComponent } from '../../internal/utils/operatorSchemaHelper';
|
|
32
|
+
import { pickOperatorStyle as pickStyle, resolveOperatorComponent } from '../../internal/utils/operatorSchemaHelper';
|
|
33
|
+
import { limitAssociationMetaTree } from './metaTreeAssociationDepth';
|
|
33
34
|
|
|
34
35
|
const { DateFilterDynamicComponent: DateFilterDynamicComponentLazy } = lazy(
|
|
35
36
|
() => import('../../models/blocks/filter-form/fields/date-time/components/DateFilterDynamicComponent'),
|
|
@@ -49,6 +50,14 @@ export interface LinkageFilterItemProps {
|
|
|
49
50
|
model: FlowModel;
|
|
50
51
|
/** 向变量树额外注入节点(置于根部) */
|
|
51
52
|
extraMetaTree?: MetaTreeNode[];
|
|
53
|
+
maxAssociationFieldDepth?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function limitMetaTreeIfNeeded(nodes: MetaTreeNode[], maxAssociationFieldDepth?: number) {
|
|
57
|
+
if (typeof maxAssociationFieldDepth !== 'number') {
|
|
58
|
+
return nodes;
|
|
59
|
+
}
|
|
60
|
+
return limitAssociationMetaTree(nodes, { maxAssociationDepth: maxAssociationFieldDepth });
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
function createStaticInputRenderer(
|
|
@@ -221,7 +230,7 @@ export async function enhanceMetaTreeWithFilterableChildren(
|
|
|
221
230
|
* LinkageFilterItem:左/右均为可变量输入,适用于联动规则等“前端逻辑”场景
|
|
222
231
|
*/
|
|
223
232
|
export const LinkageFilterItem: React.FC<LinkageFilterItemProps> = observer((props) => {
|
|
224
|
-
const { value, model, extraMetaTree } = props;
|
|
233
|
+
const { value, model, extraMetaTree, maxAssociationFieldDepth } = props;
|
|
225
234
|
const ctx = useFlowViewContext();
|
|
226
235
|
const t = model.translate;
|
|
227
236
|
const { path: leftPath, operator: selectedOperator, value: rightOperandValue } = value || {};
|
|
@@ -345,7 +354,8 @@ export const LinkageFilterItem: React.FC<LinkageFilterItemProps> = observer((pro
|
|
|
345
354
|
return (inputProps: { value?: any; onChange?: (v: any) => void } & Record<string, any>) => {
|
|
346
355
|
const { value: inputValue, onChange, ...rest } = inputProps || {};
|
|
347
356
|
const nextProps = { ...componentProps };
|
|
348
|
-
|
|
357
|
+
const options = Array.isArray(nextProps.options) ? nextProps.options : undefined;
|
|
358
|
+
if ((!options || options.length === 0) && enumOptions.length > 0) {
|
|
349
359
|
nextProps.options = enumOptions;
|
|
350
360
|
}
|
|
351
361
|
const normalizedValue = Array.isArray(inputValue)
|
|
@@ -363,7 +373,7 @@ export const LinkageFilterItem: React.FC<LinkageFilterItemProps> = observer((pro
|
|
|
363
373
|
{...rest}
|
|
364
374
|
value={normalizedValue}
|
|
365
375
|
onChange={onChange}
|
|
366
|
-
style={{ width: 200, ...(nextProps
|
|
376
|
+
style={{ width: 200, ...pickStyle(nextProps.style), ...pickStyle(rest?.style) }}
|
|
367
377
|
/>
|
|
368
378
|
);
|
|
369
379
|
|
|
@@ -386,13 +396,16 @@ export const LinkageFilterItem: React.FC<LinkageFilterItemProps> = observer((pro
|
|
|
386
396
|
return async () => {
|
|
387
397
|
const baseMetaTree = (model?.context.getPropertyMetaTree() || ctx.getPropertyMetaTree()) as MetaTreeNode[];
|
|
388
398
|
const mergedMetaTree = mergeExtraMetaTreeWithBase(baseMetaTree, extraMetaTree);
|
|
389
|
-
return
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
399
|
+
return limitMetaTreeIfNeeded(
|
|
400
|
+
[
|
|
401
|
+
{ title: t('Constant'), name: 'constant', type: 'string', paths: ['constant'] },
|
|
402
|
+
{ title: t('Null'), name: 'null', type: 'object', paths: ['null'] },
|
|
403
|
+
...mergedMetaTree,
|
|
404
|
+
],
|
|
405
|
+
maxAssociationFieldDepth,
|
|
406
|
+
);
|
|
394
407
|
};
|
|
395
|
-
}, [ctx, model, t, extraMetaTree]);
|
|
408
|
+
}, [ctx, model, t, extraMetaTree, maxAssociationFieldDepth]);
|
|
396
409
|
|
|
397
410
|
// 左侧变量树:默认整棵 ctx(不追加 Constant/Null),确保可获取字段 interface
|
|
398
411
|
const leftMetaTreeGetter = useCallback(async () => {
|
|
@@ -403,8 +416,9 @@ export const LinkageFilterItem: React.FC<LinkageFilterItemProps> = observer((pro
|
|
|
403
416
|
getFlowFieldInterfaceOptions(name, model.context.dataSourceManager, model.context.app?.dataSourceManager) as
|
|
404
417
|
| FieldInterfaceDef
|
|
405
418
|
| undefined;
|
|
406
|
-
|
|
407
|
-
|
|
419
|
+
const enhanced = await enhanceMetaTreeWithFilterableChildren(merged, getFieldInterface);
|
|
420
|
+
return limitMetaTreeIfNeeded(enhanced, maxAssociationFieldDepth);
|
|
421
|
+
}, [ctx, model, extraMetaTree, maxAssociationFieldDepth]);
|
|
408
422
|
|
|
409
423
|
// 右侧 converters:常量/空值特殊处理;变量沿用默认(表达式)
|
|
410
424
|
const rightSideConverters = useMemo<Converters>(() => {
|
|
@@ -26,7 +26,8 @@ import {
|
|
|
26
26
|
import _ from 'lodash';
|
|
27
27
|
import { NumberPicker } from '@formily/antd-v5';
|
|
28
28
|
import { enumToOptions, normalizeSelectRenderValue, UiSchemaEnumItem } from '../../internal/utils/enumOptionsUtils';
|
|
29
|
-
import { resolveOperatorComponent } from '../../internal/utils/operatorSchemaHelper';
|
|
29
|
+
import { pickOperatorStyle as pickStyle, resolveOperatorComponent } from '../../internal/utils/operatorSchemaHelper';
|
|
30
|
+
import { limitAssociationMetaTree } from './metaTreeAssociationDepth';
|
|
30
31
|
|
|
31
32
|
const { DateFilterDynamicComponent: DateFilterDynamicComponentLazy } = lazy(
|
|
32
33
|
() => import('../../models/blocks/filter-form/fields/date-time/components/DateFilterDynamicComponent'),
|
|
@@ -85,6 +86,14 @@ export interface VariableFilterItemProps {
|
|
|
85
86
|
*/
|
|
86
87
|
rightMetaTree?: MetaTreeNode[] | (() => MetaTreeNode[] | Promise<MetaTreeNode[]>);
|
|
87
88
|
ignoreFieldNames?: string[];
|
|
89
|
+
maxAssociationFieldDepth?: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function limitMetaTreeIfNeeded(nodes: MetaTreeNode[], maxAssociationFieldDepth?: number) {
|
|
93
|
+
if (typeof maxAssociationFieldDepth !== 'number') {
|
|
94
|
+
return nodes;
|
|
95
|
+
}
|
|
96
|
+
return limitAssociationMetaTree(nodes, { maxAssociationDepth: maxAssociationFieldDepth });
|
|
88
97
|
}
|
|
89
98
|
|
|
90
99
|
function createStaticInputRenderer(
|
|
@@ -260,7 +269,7 @@ function normalizeRightValueInput(input: unknown) {
|
|
|
260
269
|
* 上下文筛选项组件
|
|
261
270
|
*/
|
|
262
271
|
export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
|
|
263
|
-
({ value, model, rightAsVariable, rightMetaTree, ignoreFieldNames }) => {
|
|
272
|
+
({ value, model, rightAsVariable, rightMetaTree, ignoreFieldNames, maxAssociationFieldDepth }) => {
|
|
264
273
|
// 使用 View 上下文,确保可访问 ctx.view 的异步子树
|
|
265
274
|
const ctx = useFlowViewContext();
|
|
266
275
|
const t = model.translate;
|
|
@@ -486,14 +495,15 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
|
|
|
486
495
|
if (resolved && supportKeyword) {
|
|
487
496
|
const { Comp, props: xProps } = resolved;
|
|
488
497
|
const nextProps = { ...xProps };
|
|
489
|
-
|
|
498
|
+
const options = Array.isArray(nextProps.options) ? nextProps.options : undefined;
|
|
499
|
+
if ((!options || options.length === 0) && enumOptions?.length) {
|
|
490
500
|
nextProps.options = enumOptions;
|
|
491
501
|
}
|
|
492
502
|
const style = {
|
|
493
503
|
flex: '1 1 40%',
|
|
494
504
|
minWidth: 160,
|
|
495
505
|
maxWidth: '100%',
|
|
496
|
-
...(nextProps
|
|
506
|
+
...pickStyle(nextProps.style),
|
|
497
507
|
};
|
|
498
508
|
const normalized =
|
|
499
509
|
Array.isArray(rightValue) && rightValue.every((v) => typeof v === 'string' || typeof v === 'number')
|
|
@@ -508,7 +518,7 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
|
|
|
508
518
|
<div style={style}>
|
|
509
519
|
<Comp
|
|
510
520
|
{...nextProps}
|
|
511
|
-
style={{ width: '100%', ...(nextProps
|
|
521
|
+
style={{ width: '100%', ...pickStyle(nextProps.style) }}
|
|
512
522
|
value={normalized}
|
|
513
523
|
onChange={(vals: any) => setRightValue(vals)}
|
|
514
524
|
/>
|
|
@@ -539,13 +549,22 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
|
|
|
539
549
|
const nodes: MetaTreeNode[] = Array.isArray(raw)
|
|
540
550
|
? (raw as MetaTreeNode[])
|
|
541
551
|
: await (raw as () => Promise<MetaTreeNode[]>)();
|
|
542
|
-
return
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
552
|
+
return limitMetaTreeIfNeeded(
|
|
553
|
+
[
|
|
554
|
+
{
|
|
555
|
+
title: t('Constant'),
|
|
556
|
+
name: 'constant',
|
|
557
|
+
type: 'string',
|
|
558
|
+
paths: ['constant'],
|
|
559
|
+
render: staticInputRenderer,
|
|
560
|
+
},
|
|
561
|
+
{ title: t('Null'), name: 'null', type: 'object', paths: ['null'], render: NullComponent },
|
|
562
|
+
...nodes,
|
|
563
|
+
],
|
|
564
|
+
maxAssociationFieldDepth,
|
|
565
|
+
);
|
|
547
566
|
};
|
|
548
|
-
}, [rightMetaTree, ctx, staticInputRenderer, NullComponent, t]);
|
|
567
|
+
}, [rightMetaTree, ctx, staticInputRenderer, NullComponent, t, maxAssociationFieldDepth]);
|
|
549
568
|
|
|
550
569
|
// 当启用右侧变量输入时,构造 VariableInput 的 converters:
|
|
551
570
|
// - 变量模式:返回 null 让 VariableInput 渲染 VariableTag
|
|
@@ -629,9 +648,9 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
|
|
|
629
648
|
for (const n of nodes) {
|
|
630
649
|
out.push(await enhanceNode(n));
|
|
631
650
|
}
|
|
632
|
-
return out;
|
|
651
|
+
return limitMetaTreeIfNeeded(out, maxAssociationFieldDepth);
|
|
633
652
|
};
|
|
634
|
-
}, [getFieldInterface, model]);
|
|
653
|
+
}, [getFieldInterface, model, maxAssociationFieldDepth]);
|
|
635
654
|
|
|
636
655
|
return (
|
|
637
656
|
<Space wrap style={{ width: '100%' }}>
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
|
|
12
|
+
import { fieldsToOptions } from '../fieldsToOptions';
|
|
13
|
+
|
|
14
|
+
describe('fieldsToOptions', () => {
|
|
15
|
+
it('skips nested fields when target collection is unavailable', () => {
|
|
16
|
+
const field = {
|
|
17
|
+
name: 'space',
|
|
18
|
+
type: 'belongsTo',
|
|
19
|
+
target: 'spaces',
|
|
20
|
+
title: 'Space',
|
|
21
|
+
interface: 'm2o',
|
|
22
|
+
filterable: true,
|
|
23
|
+
uiSchema: {},
|
|
24
|
+
getInterfaceOptions: () => ({
|
|
25
|
+
filterable: {
|
|
26
|
+
nested: true,
|
|
27
|
+
operators: [{ value: '$eq', label: 'is' }],
|
|
28
|
+
},
|
|
29
|
+
}),
|
|
30
|
+
getFields: () => {
|
|
31
|
+
throw new Error('Target collection spaces not found for field space');
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
expect(() => fieldsToOptions([field], 1, [], (key: string) => key)).not.toThrow();
|
|
36
|
+
expect(fieldsToOptions([field], 1, [], (key: string) => key)).toEqual([
|
|
37
|
+
{
|
|
38
|
+
name: 'space',
|
|
39
|
+
type: 'belongsTo',
|
|
40
|
+
target: 'spaces',
|
|
41
|
+
title: 'Space',
|
|
42
|
+
schema: {},
|
|
43
|
+
operators: [{ value: '$eq', label: 'is' }],
|
|
44
|
+
children: [],
|
|
45
|
+
},
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
});
|