@nocobase/client-v2 2.1.0-alpha.34 → 2.1.0-alpha.36
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 +1 -0
- package/es/PluginManager.d.ts +1 -0
- package/es/components/form/DrawerFormLayout.d.ts +49 -0
- package/es/components/form/EnvVariableInput.d.ts +42 -0
- package/es/components/form/FileSizeInput.d.ts +27 -0
- package/es/components/form/createFormRegistry.d.ts +33 -0
- package/es/components/form/index.d.ts +13 -0
- package/es/components/index.d.ts +1 -1
- package/es/flow/components/FieldAssignRulesEditor.d.ts +1 -0
- package/es/flow/components/code-editor/index.d.ts +1 -0
- package/es/flow/internal/utils/enumOptionsUtils.d.ts +5 -0
- package/es/flow/models/actions/AssociationActionUtils.d.ts +5 -0
- package/es/flow/models/blocks/filter-form/FilterFormBlockModel.d.ts +4 -0
- package/es/flow/models/blocks/filter-manager/FilterManager.d.ts +5 -1
- package/es/flow/models/blocks/shared/legacyDefaultValueMigrationBase.d.ts +1 -0
- package/es/flow/models/blocks/table/TableSelectModel.d.ts +8 -0
- package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +4 -0
- package/es/flow/models/fields/ClickableFieldModel.d.ts +3 -0
- package/es/flow/models/fields/DisplayEnumFieldModel.d.ts +6 -0
- package/es/flow/models/fields/DisplayTitleFieldModel.d.ts +2 -2
- package/es/flow/models/utils/displayValueUtils.d.ts +12 -0
- package/es/flow-compat/passwordUtils.d.ts +1 -1
- package/es/index.mjs +119 -108
- package/es/utils/remotePlugins.d.ts +0 -4
- package/lib/index.js +122 -111
- package/package.json +9 -5
- package/src/BaseApplication.tsx +14 -8
- package/src/PluginManager.ts +1 -0
- package/src/__tests__/app.test.tsx +28 -1
- package/src/__tests__/globalDeps.test.ts +1 -0
- package/src/__tests__/remotePlugins.test.ts +29 -18
- package/src/components/form/DrawerFormLayout.tsx +103 -0
- package/src/components/form/EnvVariableInput.tsx +126 -0
- package/src/components/form/FileSizeInput.tsx +105 -0
- package/src/components/form/createFormRegistry.ts +60 -0
- package/src/components/form/index.tsx +14 -0
- package/src/components/index.ts +1 -1
- package/src/flow/actions/__tests__/dataScopeFilter.test.ts +92 -13
- package/src/flow/actions/__tests__/formAssignRules.legacyMigration.test.tsx +173 -0
- package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +476 -1
- package/src/flow/actions/__tests__/pattern.test.ts +134 -0
- package/src/flow/actions/__tests__/titleField.test.ts +45 -0
- package/src/flow/actions/filterFormDefaultValues.tsx +30 -9
- package/src/flow/actions/formAssignRules.tsx +24 -9
- package/src/flow/actions/linkageRules.tsx +240 -258
- package/src/flow/actions/pattern.tsx +41 -6
- package/src/flow/actions/setTargetDataScope.tsx +32 -3
- package/src/flow/actions/titleField.tsx +4 -2
- package/src/flow/actions/validation.tsx +1 -1
- package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +0 -4
- package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +13 -5
- package/src/flow/components/DynamicFlowsIcon.tsx +87 -13
- package/src/flow/components/FieldAssignRulesEditor.tsx +2 -0
- package/src/flow/components/__tests__/DynamicFlowsIcon.test.tsx +195 -8
- package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +81 -4
- package/src/flow/components/code-editor/index.tsx +12 -8
- package/src/flow/components/filter/LinkageFilterItem.tsx +9 -2
- package/src/flow/components/filter/VariableFilterItem.tsx +2 -6
- package/src/flow/components/filter/__tests__/LinkageFilterItem.test.tsx +71 -0
- package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +48 -0
- package/src/flow/internal/utils/__tests__/enumOptionsUtils.test.ts +10 -1
- package/src/flow/internal/utils/enumOptionsUtils.ts +29 -0
- package/src/flow/models/actions/AssociateActionModel.tsx +2 -2
- package/src/flow/models/actions/AssociationActionUtils.ts +14 -0
- package/src/flow/models/actions/__tests__/AssociationActionModel.test.ts +63 -0
- package/src/flow/models/base/CollectionBlockModel.tsx +7 -0
- package/src/flow/models/base/PageModel/RootPageModel.tsx +1 -1
- package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +33 -9
- package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +53 -13
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +63 -3
- package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +33 -1
- package/src/flow/models/blocks/filter-form/__tests__/legacyDefaultValueMigration.test.ts +3 -0
- package/src/flow/models/blocks/filter-manager/FilterManager.ts +66 -2
- package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +270 -0
- package/src/flow/models/blocks/form/FormActionModel.tsx +2 -8
- package/src/flow/models/blocks/form/FormBlockModel.tsx +8 -5
- package/src/flow/models/blocks/form/FormItemModel.tsx +1 -1
- package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +30 -0
- package/src/flow/models/blocks/form/__tests__/legacyDefaultValueMigration.test.ts +3 -0
- package/src/flow/models/blocks/form/value-runtime/rules.ts +6 -1
- package/src/flow/models/blocks/form/value-runtime/runtime.ts +6 -1
- package/src/flow/models/blocks/shared/legacyDefaultValueMigrationBase.ts +21 -5
- package/src/flow/models/blocks/table/TableBlockModel.tsx +11 -6
- package/src/flow/models/blocks/table/TableColumnModel.tsx +8 -2
- package/src/flow/models/blocks/table/TableSelectModel.tsx +36 -26
- package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowClick.test.ts +69 -0
- package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +132 -1
- package/src/flow/models/blocks/table/__tests__/TableSelectModel.test.ts +41 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +144 -3
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.rowRecord.test.ts +170 -1
- package/src/flow/models/fields/ClickableFieldModel.tsx +55 -6
- package/src/flow/models/fields/DisplayEnumFieldModel.tsx +44 -0
- package/src/flow/models/fields/DisplayTitleFieldModel.tsx +40 -7
- package/src/flow/models/fields/SelectFieldModel.tsx +31 -1
- package/src/flow/models/fields/__tests__/ClickableFieldModel.test.ts +202 -1
- package/src/flow/models/fields/__tests__/DisplayEnumFieldModel.test.tsx +39 -0
- package/src/flow/models/fields/mobile-components/MobileSelect.tsx +2 -1
- package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +7 -0
- package/src/flow/models/utils/displayValueUtils.ts +57 -0
- package/src/utils/globalDeps.ts +11 -0
- package/src/utils/remotePlugins.ts +7 -27
|
@@ -20,8 +20,9 @@ import _, { cloneDeep, debounce, isEqual } from 'lodash';
|
|
|
20
20
|
import React from 'react';
|
|
21
21
|
import { CollectionBlockModel, FieldModel } from '../../base';
|
|
22
22
|
import { RecordSelectFieldModel } from '../../fields/AssociationFieldModel/RecordSelectFieldModel';
|
|
23
|
+
import { normalizeAssociationFieldNames } from '../../fields/AssociationFieldModel/recordSelectShared';
|
|
23
24
|
import { FilterManager } from '../filter-manager';
|
|
24
|
-
import { getAllDataModels, getDefaultOperator } from '../filter-manager/utils';
|
|
25
|
+
import { getAllDataModels, getDefaultOperator, isFilterValueEmpty } from '../filter-manager/utils';
|
|
25
26
|
import { FilterFormFieldModel } from './fields';
|
|
26
27
|
import { normalizeFilterValueByOperator } from './valueNormalization';
|
|
27
28
|
|
|
@@ -58,13 +59,11 @@ const normalizeAssociationDefaultFilterValue = (value: any, fieldModel: any) =>
|
|
|
58
59
|
return value;
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
collectionField?.collection?.filterTargetKey ||
|
|
67
|
-
'id';
|
|
62
|
+
const normalizedFieldNames = normalizeAssociationFieldNames(
|
|
63
|
+
fieldModel?.props?.fieldNames || collectionField?.fieldNames,
|
|
64
|
+
collectionField?.targetCollection,
|
|
65
|
+
);
|
|
66
|
+
const valueKey = normalizedFieldNames.value;
|
|
68
67
|
const pickValue = (item: any) => {
|
|
69
68
|
const target = item && typeof item === 'object' && typeof item?.data !== 'undefined' ? item.data : item;
|
|
70
69
|
if (!target || typeof target !== 'object') {
|
|
@@ -89,6 +88,30 @@ const normalizeAssociationDefaultFilterValue = (value: any, fieldModel: any) =>
|
|
|
89
88
|
return pickValue(value);
|
|
90
89
|
};
|
|
91
90
|
|
|
91
|
+
const hasAssociationRecordValue = (value: any) => {
|
|
92
|
+
const hasRecord = (item: any) => {
|
|
93
|
+
const target = item && typeof item === 'object' && typeof item?.data !== 'undefined' ? item.data : item;
|
|
94
|
+
return !!target && typeof target === 'object';
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return Array.isArray(value) ? value.some(hasRecord) : hasRecord(value);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const isToOneAssociationField = (collectionField: any) => {
|
|
101
|
+
return (
|
|
102
|
+
['belongsTo', 'hasOne'].includes(collectionField?.type) ||
|
|
103
|
+
['m2o', 'o2o', 'oho', 'obo', 'updatedBy', 'createdBy'].includes(collectionField?.interface)
|
|
104
|
+
);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const isMultipleAssociationField = (fieldModel: any, collectionField: any) => {
|
|
108
|
+
if (fieldModel?.props?.allowMultiple === true || fieldModel?.props?.multiple === true) {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return !isToOneAssociationField(collectionField);
|
|
113
|
+
};
|
|
114
|
+
|
|
92
115
|
const buildVirtualFilterCollectionField = (ctx: FlowModelContext, filterField: any) => {
|
|
93
116
|
if (!filterField) {
|
|
94
117
|
return;
|
|
@@ -480,9 +503,21 @@ export class FilterFormItemModel extends FilterableItemModel<{
|
|
|
480
503
|
*/
|
|
481
504
|
getFilterValue() {
|
|
482
505
|
const fieldModel = this.subModels.field as FieldModel & { getFilterValue?: () => any };
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
506
|
+
const formValue = this.context.form?.getFieldValue(this.props.name);
|
|
507
|
+
const modelValue = fieldModel.getFilterValue ? fieldModel.getFilterValue() : formValue;
|
|
508
|
+
const collectionField = (fieldModel as any)?.context?.collectionField;
|
|
509
|
+
const isAssociationField =
|
|
510
|
+
typeof collectionField?.isAssociationField === 'function'
|
|
511
|
+
? collectionField.isAssociationField()
|
|
512
|
+
: !!collectionField?.target;
|
|
513
|
+
const shouldUseFormValue =
|
|
514
|
+
isAssociationField &&
|
|
515
|
+
!this.mounted &&
|
|
516
|
+
!isFilterValueEmpty(formValue) &&
|
|
517
|
+
hasAssociationRecordValue(formValue) &&
|
|
518
|
+
!hasAssociationRecordValue(modelValue);
|
|
519
|
+
const shouldFallbackToFormValue = isFilterValueEmpty(modelValue) && !isFilterValueEmpty(formValue);
|
|
520
|
+
const fieldValue = shouldUseFormValue || shouldFallbackToFormValue ? formValue : modelValue;
|
|
486
521
|
|
|
487
522
|
let rawValue = fieldValue;
|
|
488
523
|
|
|
@@ -517,10 +552,15 @@ export class FilterFormItemModel extends FilterableItemModel<{
|
|
|
517
552
|
if (!isAssociation) {
|
|
518
553
|
return value;
|
|
519
554
|
}
|
|
520
|
-
const
|
|
555
|
+
const normalizedFieldNames = normalizeAssociationFieldNames(
|
|
556
|
+
(fieldModel as any)?.props?.fieldNames || collectionField?.fieldNames,
|
|
557
|
+
collectionField?.targetCollection,
|
|
558
|
+
);
|
|
559
|
+
const valueKey = normalizedFieldNames.value;
|
|
521
560
|
if (Array.isArray(value)) {
|
|
522
561
|
if (value.length === 0) return value;
|
|
523
|
-
|
|
562
|
+
const normalizedValue = value.map((item) => (item && typeof item === 'object' ? item[valueKey] : item));
|
|
563
|
+
return isMultipleAssociationField(fieldModel, collectionField) ? normalizedValue : normalizedValue[0];
|
|
524
564
|
}
|
|
525
565
|
if (typeof value === 'object') {
|
|
526
566
|
return (value as any)?.[valueKey];
|
package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts
CHANGED
|
@@ -18,19 +18,21 @@ describe('FilterFormItemModel getFilterValue', () => {
|
|
|
18
18
|
association = true,
|
|
19
19
|
fieldNames = { label: 'nickname', value: 'id' },
|
|
20
20
|
collectionField = {},
|
|
21
|
+
fieldProps = {},
|
|
21
22
|
}: {
|
|
22
23
|
defaultValue: any;
|
|
23
24
|
fieldValue?: any;
|
|
24
25
|
association?: boolean;
|
|
25
26
|
fieldNames?: { label: string; value: string };
|
|
26
27
|
collectionField?: Record<string, any>;
|
|
28
|
+
fieldProps?: Record<string, any>;
|
|
27
29
|
}) => {
|
|
28
30
|
return {
|
|
29
31
|
mounted: false,
|
|
30
32
|
props: { name: 'ownerId' },
|
|
31
33
|
subModels: {
|
|
32
34
|
field: {
|
|
33
|
-
props: { fieldNames },
|
|
35
|
+
props: { fieldNames, ...fieldProps },
|
|
34
36
|
context: {
|
|
35
37
|
collectionField: {
|
|
36
38
|
isAssociationField: () => association,
|
|
@@ -99,13 +101,71 @@ describe('FilterFormItemModel getFilterValue', () => {
|
|
|
99
101
|
expect(value).toBe(23);
|
|
100
102
|
});
|
|
101
103
|
|
|
104
|
+
it('uses current form value when association field props are not synchronized yet', () => {
|
|
105
|
+
const model = createModelMock({
|
|
106
|
+
defaultValue: undefined,
|
|
107
|
+
fieldValue: [3],
|
|
108
|
+
fieldNames: { label: 'nickname', value: 'uuid' },
|
|
109
|
+
collectionField: { type: 'belongsTo', interface: 'm2o' },
|
|
110
|
+
});
|
|
111
|
+
model.context.form.getFieldValue.mockReturnValue([{ id: 3, uuid: 'org-3', nickname: 'Org 3' }]);
|
|
112
|
+
|
|
113
|
+
const value = FilterFormItemModel.prototype.getFilterValue.call(model as any);
|
|
114
|
+
expect(value).toBe('org-3');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('keeps to-one association arrays when allowMultiple is enabled', () => {
|
|
118
|
+
const model = createModelMock({
|
|
119
|
+
defaultValue: undefined,
|
|
120
|
+
fieldValue: [
|
|
121
|
+
{ id: 5, nickname: 'Org 5' },
|
|
122
|
+
{ id: 6, nickname: 'Org 6' },
|
|
123
|
+
],
|
|
124
|
+
collectionField: { type: 'belongsTo', interface: 'm2o' },
|
|
125
|
+
fieldProps: { allowMultiple: true, multiple: true },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const value = FilterFormItemModel.prototype.getFilterValue.call(model as any);
|
|
129
|
+
expect(value).toEqual([5, 6]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('keeps association arrays for to-many fields', () => {
|
|
133
|
+
const model = createModelMock({
|
|
134
|
+
defaultValue: [
|
|
135
|
+
{ id: 3, uuid: 'org-3', nickname: 'Org 3' },
|
|
136
|
+
{ id: 4, uuid: 'org-4', nickname: 'Org 4' },
|
|
137
|
+
],
|
|
138
|
+
fieldValue: undefined,
|
|
139
|
+
fieldNames: { label: 'nickname', value: 'uuid' },
|
|
140
|
+
collectionField: { type: 'belongsToMany', interface: 'm2m' },
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const value = FilterFormItemModel.prototype.getFilterValue.call(model as any);
|
|
144
|
+
expect(value).toEqual(['org-3', 'org-4']);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('uses target collection filter target key for association default records', () => {
|
|
148
|
+
const model = createModelMock({
|
|
149
|
+
defaultValue: { id: 3, uuid: 'org-3', nickname: 'Org 3' },
|
|
150
|
+
fieldValue: undefined,
|
|
151
|
+
fieldNames: null as any,
|
|
152
|
+
collectionField: {
|
|
153
|
+
targetKey: 'id',
|
|
154
|
+
targetCollection: { filterTargetKey: 'uuid' },
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const value = FilterFormItemModel.prototype.getFilterValue.call(model as any);
|
|
159
|
+
expect(value).toBe('org-3');
|
|
160
|
+
});
|
|
161
|
+
|
|
102
162
|
it('falls back to collection target key when fieldNames value is missing', () => {
|
|
103
163
|
const model = createModelMock({
|
|
104
164
|
defaultValue: { id: 25, nickname: 'User 25' },
|
|
105
165
|
fieldValue: undefined,
|
|
106
|
-
fieldNames:
|
|
166
|
+
fieldNames: null as any,
|
|
107
167
|
collectionField: {
|
|
108
|
-
fieldNames:
|
|
168
|
+
fieldNames: null,
|
|
109
169
|
targetKey: 'id',
|
|
110
170
|
targetCollection: { filterTargetKey: 'id' },
|
|
111
171
|
},
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { describe, expect, it } from 'vitest';
|
|
10
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
11
11
|
import { filterFormDefaultValues } from '../../../../actions/filterFormDefaultValues';
|
|
12
12
|
import { FilterFormBlockModel } from '../FilterFormBlockModel';
|
|
13
13
|
|
|
@@ -16,4 +16,36 @@ describe('filter-form defaultValues wiring', () => {
|
|
|
16
16
|
expect(filterFormDefaultValues).toBeTruthy();
|
|
17
17
|
expect(FilterFormBlockModel).toBeTruthy();
|
|
18
18
|
});
|
|
19
|
+
|
|
20
|
+
it('only excludes targets already handled by their own initial refresh', async () => {
|
|
21
|
+
const refreshTargets = vi.fn().mockResolvedValue(undefined);
|
|
22
|
+
const model = {
|
|
23
|
+
initialRefreshHandledTargetIds: new Set<string>(),
|
|
24
|
+
form: {},
|
|
25
|
+
context: {
|
|
26
|
+
refreshTargets,
|
|
27
|
+
},
|
|
28
|
+
prepareInitialFilterValues: vi.fn().mockResolvedValue(true),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
FilterFormBlockModel.prototype.markInitialTargetRefreshHandled.call(model as any, 'target-1');
|
|
32
|
+
await (FilterFormBlockModel.prototype as any).applyDefaultsAndInitialFilter.call(model);
|
|
33
|
+
|
|
34
|
+
expect(refreshTargets).toHaveBeenCalledWith({ excludeTargetIds: new Set(['target-1']) });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('does not cache initial defaults when form is not ready', async () => {
|
|
38
|
+
const model = {
|
|
39
|
+
form: undefined,
|
|
40
|
+
initialDefaultsPromise: undefined,
|
|
41
|
+
ensureFilterItemsBeforeRender: vi.fn().mockResolvedValue(undefined),
|
|
42
|
+
applyFormDefaultValues: vi.fn().mockResolvedValue(undefined),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const prepared = await FilterFormBlockModel.prototype.prepareInitialFilterValues.call(model as any);
|
|
46
|
+
|
|
47
|
+
expect(prepared).toBe(false);
|
|
48
|
+
expect(model.initialDefaultsPromise).toBeUndefined();
|
|
49
|
+
expect(model.applyFormDefaultValues).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
19
51
|
});
|
|
@@ -18,6 +18,7 @@ function createMockFieldModel(options: { uid: string; props?: Record<string, any
|
|
|
18
18
|
const model: any = {
|
|
19
19
|
uid: options.uid,
|
|
20
20
|
props: { ...(options.props || {}) },
|
|
21
|
+
_options: { props: { ...(options.props || {}) } },
|
|
21
22
|
stepParams: { ...(options.stepParams || {}) },
|
|
22
23
|
emitter: { emit: vi.fn() },
|
|
23
24
|
setProps(patch: any) {
|
|
@@ -97,7 +98,9 @@ describe('filter-form legacyDefaultValueMigration', () => {
|
|
|
97
98
|
clearLegacyDefaultValuesFromFilterFormModel(filterFormModel);
|
|
98
99
|
|
|
99
100
|
expect(field1.props.initialValue).toBeUndefined();
|
|
101
|
+
expect(field1._options.props.initialValue).toBeUndefined();
|
|
100
102
|
expect(field1.props.keep).toBe(true);
|
|
103
|
+
expect(field1._options.props.keep).toBe(true);
|
|
101
104
|
expect(field1.stepParams.filterFormItemSettings?.initialValue).toBeUndefined();
|
|
102
105
|
expect(field1.stepParams.otherFlow?.s?.x).toBe(1);
|
|
103
106
|
|
|
@@ -24,6 +24,10 @@ type FilterConfig = {
|
|
|
24
24
|
operator?: string;
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
+
export type RefreshTargetsByFilterOptions = {
|
|
28
|
+
excludeTargetIds?: Set<string> | string[];
|
|
29
|
+
};
|
|
30
|
+
|
|
27
31
|
const ARRAY_FIELD_OPERATORS_TO_SCALAR_OPERATORS: Record<string, string> = {
|
|
28
32
|
$match: '$in',
|
|
29
33
|
$anyOf: '$in',
|
|
@@ -78,6 +82,36 @@ function normalizeOperatorForTargetField(operator: string, targetModel: any, fie
|
|
|
78
82
|
return scalarOperator;
|
|
79
83
|
}
|
|
80
84
|
|
|
85
|
+
async function waitForResourceIdle(resource: any) {
|
|
86
|
+
if (!resource?.loading) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
await new Promise<void>((resolve) => {
|
|
91
|
+
const check = () => {
|
|
92
|
+
if (!resource.loading) {
|
|
93
|
+
resolve();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
setTimeout(check, 16);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
check();
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function getErrorMessage(error: unknown) {
|
|
104
|
+
if (error instanceof Error) {
|
|
105
|
+
return error.message;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
return String(error);
|
|
110
|
+
} catch {
|
|
111
|
+
return 'Unknown error';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
81
115
|
export class FilterManager {
|
|
82
116
|
private filterConfigs: FilterConfig[];
|
|
83
117
|
private readonly gridModel: FlowModel;
|
|
@@ -111,6 +145,32 @@ export class FilterManager {
|
|
|
111
145
|
};
|
|
112
146
|
}
|
|
113
147
|
|
|
148
|
+
async prepareFiltersForTarget(targetId: string) {
|
|
149
|
+
const relatedConfigs = this.filterConfigs.filter((config) => config.targetId === targetId);
|
|
150
|
+
const preparedBlockModels = new Set<any>();
|
|
151
|
+
|
|
152
|
+
await Promise.all(
|
|
153
|
+
relatedConfigs.map(async (config) => {
|
|
154
|
+
try {
|
|
155
|
+
const filterModel: any = this.gridModel.flowEngine.getModel(config.filterId);
|
|
156
|
+
const blockModel = filterModel?.context?.blockModel;
|
|
157
|
+
if (!blockModel || typeof blockModel.prepareInitialFilterValues !== 'function') {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const prepared = await blockModel.prepareInitialFilterValues();
|
|
162
|
+
if (prepared !== false) {
|
|
163
|
+
preparedBlockModels.add(blockModel);
|
|
164
|
+
}
|
|
165
|
+
} catch (error) {
|
|
166
|
+
console.error(`Failed to prepare filter defaults for target "${targetId}": ${getErrorMessage(error)}`);
|
|
167
|
+
}
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
return preparedBlockModels;
|
|
172
|
+
}
|
|
173
|
+
|
|
114
174
|
async saveConnectFieldsConfig(filterModelUid: string, config: ConnectFieldsConfig) {
|
|
115
175
|
// 1. 把参数 FilterModelUid 和 config 转换成一个 filterConfigs
|
|
116
176
|
const newFilterConfigs: FilterConfig[] = config.targets.map((target) => ({
|
|
@@ -459,7 +519,7 @@ export class FilterManager {
|
|
|
459
519
|
* await filterManager.refreshTargetsByFilter(['filter-1', 'filter-2']);
|
|
460
520
|
* ```
|
|
461
521
|
*/
|
|
462
|
-
async refreshTargetsByFilter(filterId: string | string[]): Promise<void> {
|
|
522
|
+
async refreshTargetsByFilter(filterId: string | string[], options?: RefreshTargetsByFilterOptions): Promise<void> {
|
|
463
523
|
// 1. 参数验证和标准化
|
|
464
524
|
if (!filterId) {
|
|
465
525
|
console.error('filterId must be provided');
|
|
@@ -482,7 +542,10 @@ export class FilterManager {
|
|
|
482
542
|
}
|
|
483
543
|
|
|
484
544
|
// 3. 提取所有相关的 targetId 并去重
|
|
485
|
-
const
|
|
545
|
+
const excludeTargetIds = options?.excludeTargetIds ? new Set(options.excludeTargetIds) : undefined;
|
|
546
|
+
const targetIds = [...new Set(relatedConfigs.map((config) => config.targetId))].filter(
|
|
547
|
+
(targetId) => !excludeTargetIds?.has(targetId),
|
|
548
|
+
);
|
|
486
549
|
|
|
487
550
|
// 4. 并行处理所有目标模型
|
|
488
551
|
const refreshPromises = targetIds.map(async (targetId) => {
|
|
@@ -522,6 +585,7 @@ export class FilterManager {
|
|
|
522
585
|
}
|
|
523
586
|
|
|
524
587
|
// 4.5 调用 refresh 方法
|
|
588
|
+
await waitForResourceIdle(resource);
|
|
525
589
|
if (typeof resource.setPage === 'function') {
|
|
526
590
|
resource.setPage(1); // 重置到第一页
|
|
527
591
|
}
|
|
@@ -243,6 +243,125 @@ describe('FilterManager', () => {
|
|
|
243
243
|
});
|
|
244
244
|
});
|
|
245
245
|
|
|
246
|
+
describe('prepareFiltersForTarget', () => {
|
|
247
|
+
it('should prepare filter form initial values before target refresh', async () => {
|
|
248
|
+
(filterManager as any).filterConfigs = [
|
|
249
|
+
{
|
|
250
|
+
filterId: 'filter1',
|
|
251
|
+
targetId: 'target1',
|
|
252
|
+
filterPaths: ['name'],
|
|
253
|
+
},
|
|
254
|
+
];
|
|
255
|
+
const prepareInitialFilterValues = vi.fn().mockResolvedValue(undefined);
|
|
256
|
+
const filterBlockModel = { prepareInitialFilterValues };
|
|
257
|
+
const filterModel = {
|
|
258
|
+
context: {
|
|
259
|
+
blockModel: filterBlockModel,
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
(mockFlowModel.flowEngine.getModel as any).mockImplementation((uid: string) => {
|
|
264
|
+
if (uid === 'filter1') return filterModel;
|
|
265
|
+
return null;
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const prepared = await filterManager.prepareFiltersForTarget('target1');
|
|
269
|
+
|
|
270
|
+
expect(prepareInitialFilterValues).toHaveBeenCalledTimes(1);
|
|
271
|
+
expect(prepared.has(filterBlockModel)).toBe(true);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('should skip marking a filter block when initial values are not ready', async () => {
|
|
275
|
+
(filterManager as any).filterConfigs = [
|
|
276
|
+
{
|
|
277
|
+
filterId: 'filter1',
|
|
278
|
+
targetId: 'target1',
|
|
279
|
+
filterPaths: ['name'],
|
|
280
|
+
},
|
|
281
|
+
];
|
|
282
|
+
const filterBlockModel = { prepareInitialFilterValues: vi.fn().mockResolvedValue(false) };
|
|
283
|
+
const filterModel = {
|
|
284
|
+
context: {
|
|
285
|
+
blockModel: filterBlockModel,
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
(mockFlowModel.flowEngine.getModel as any).mockImplementation((uid: string) => {
|
|
290
|
+
if (uid === 'filter1') return filterModel;
|
|
291
|
+
return null;
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const prepared = await filterManager.prepareFiltersForTarget('target1');
|
|
295
|
+
|
|
296
|
+
expect(prepared.has(filterBlockModel)).toBe(false);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('should continue preparing other filter blocks when one fails', async () => {
|
|
300
|
+
(filterManager as any).filterConfigs = [
|
|
301
|
+
{
|
|
302
|
+
filterId: 'filter1',
|
|
303
|
+
targetId: 'target1',
|
|
304
|
+
filterPaths: ['name'],
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
filterId: 'filter2',
|
|
308
|
+
targetId: 'target1',
|
|
309
|
+
filterPaths: ['title'],
|
|
310
|
+
},
|
|
311
|
+
];
|
|
312
|
+
const failingBlockModel = { prepareInitialFilterValues: vi.fn().mockRejectedValue(new Error('bad default')) };
|
|
313
|
+
const preparedBlockModel = { prepareInitialFilterValues: vi.fn().mockResolvedValue(true) };
|
|
314
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
315
|
+
|
|
316
|
+
(mockFlowModel.flowEngine.getModel as any).mockImplementation((uid: string) => {
|
|
317
|
+
if (uid === 'filter1') return { context: { blockModel: failingBlockModel } };
|
|
318
|
+
if (uid === 'filter2') return { context: { blockModel: preparedBlockModel } };
|
|
319
|
+
return null;
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const prepared = await filterManager.prepareFiltersForTarget('target1');
|
|
323
|
+
|
|
324
|
+
expect(prepared.has(failingBlockModel)).toBe(false);
|
|
325
|
+
expect(prepared.has(preparedBlockModel)).toBe(true);
|
|
326
|
+
expect(errorSpy).toHaveBeenCalled();
|
|
327
|
+
errorSpy.mockRestore();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('should keep error isolation when a filter block rejects a non-error value', async () => {
|
|
331
|
+
(filterManager as any).filterConfigs = [
|
|
332
|
+
{
|
|
333
|
+
filterId: 'filter1',
|
|
334
|
+
targetId: 'target1',
|
|
335
|
+
filterPaths: ['name'],
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
filterId: 'filter2',
|
|
339
|
+
targetId: 'target1',
|
|
340
|
+
filterPaths: ['title'],
|
|
341
|
+
},
|
|
342
|
+
];
|
|
343
|
+
const failingBlockModel = { prepareInitialFilterValues: vi.fn().mockRejectedValue('bad default') };
|
|
344
|
+
const preparedBlockModel = { prepareInitialFilterValues: vi.fn().mockResolvedValue(true) };
|
|
345
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
346
|
+
|
|
347
|
+
(mockFlowModel.flowEngine.getModel as any).mockImplementation((uid: string) => {
|
|
348
|
+
if (uid === 'filter1') return { context: { blockModel: failingBlockModel } };
|
|
349
|
+
if (uid === 'filter2') return { context: { blockModel: preparedBlockModel } };
|
|
350
|
+
return null;
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
try {
|
|
354
|
+
const prepared = await filterManager.prepareFiltersForTarget('target1');
|
|
355
|
+
|
|
356
|
+
expect(prepared.has(failingBlockModel)).toBe(false);
|
|
357
|
+
expect(prepared.has(preparedBlockModel)).toBe(true);
|
|
358
|
+
expect(errorSpy).toHaveBeenCalledWith('Failed to prepare filter defaults for target "target1": bad default');
|
|
359
|
+
} finally {
|
|
360
|
+
errorSpy.mockRestore();
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
246
365
|
describe('addFilterConfig', () => {
|
|
247
366
|
it('should add a new filter config successfully', async () => {
|
|
248
367
|
const filterConfig = {
|
|
@@ -496,6 +615,157 @@ describe('FilterManager', () => {
|
|
|
496
615
|
expect(mockTargetModel2.resource.refresh).toHaveBeenCalledTimes(1);
|
|
497
616
|
});
|
|
498
617
|
|
|
618
|
+
it('should wait for target resource idle before refreshing', async () => {
|
|
619
|
+
vi.useFakeTimers();
|
|
620
|
+
(filterManager as any).filterConfigs = [
|
|
621
|
+
{
|
|
622
|
+
filterId: 'filter-1',
|
|
623
|
+
targetId: 'target-1',
|
|
624
|
+
filterPaths: ['name'],
|
|
625
|
+
operator: '$eq',
|
|
626
|
+
},
|
|
627
|
+
];
|
|
628
|
+
|
|
629
|
+
const resource = {
|
|
630
|
+
loading: true,
|
|
631
|
+
addFilterGroup: vi.fn(),
|
|
632
|
+
removeFilterGroup: vi.fn(),
|
|
633
|
+
refresh: vi.fn().mockImplementation(() => {
|
|
634
|
+
expect(resource.loading).toBe(false);
|
|
635
|
+
return Promise.resolve();
|
|
636
|
+
}),
|
|
637
|
+
};
|
|
638
|
+
const mockTargetModel = {
|
|
639
|
+
resource,
|
|
640
|
+
setFilterActive: vi.fn(),
|
|
641
|
+
getDataLoadingMode: vi.fn().mockReturnValue('auto'),
|
|
642
|
+
};
|
|
643
|
+
const mockFilterModel = {
|
|
644
|
+
getFilterValue: vi.fn().mockReturnValue('test-value'),
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
(mockFlowModel.flowEngine.getModel as any).mockImplementation((uid: string) => {
|
|
648
|
+
if (uid === 'target-1') return mockTargetModel;
|
|
649
|
+
if (uid === 'filter-1') return mockFilterModel;
|
|
650
|
+
return null;
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
const refreshPromise = filterManager.refreshTargetsByFilter('filter-1');
|
|
654
|
+
|
|
655
|
+
try {
|
|
656
|
+
await vi.advanceTimersByTimeAsync(16);
|
|
657
|
+
expect(resource.refresh).not.toHaveBeenCalled();
|
|
658
|
+
|
|
659
|
+
resource.loading = false;
|
|
660
|
+
await vi.advanceTimersByTimeAsync(16);
|
|
661
|
+
await refreshPromise;
|
|
662
|
+
|
|
663
|
+
expect(resource.refresh).toHaveBeenCalledTimes(1);
|
|
664
|
+
} finally {
|
|
665
|
+
vi.useRealTimers();
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it('should not refresh before a slow target resource becomes idle', async () => {
|
|
670
|
+
vi.useFakeTimers();
|
|
671
|
+
(filterManager as any).filterConfigs = [
|
|
672
|
+
{
|
|
673
|
+
filterId: 'filter-1',
|
|
674
|
+
targetId: 'target-1',
|
|
675
|
+
filterPaths: ['name'],
|
|
676
|
+
operator: '$eq',
|
|
677
|
+
},
|
|
678
|
+
];
|
|
679
|
+
|
|
680
|
+
const resource = {
|
|
681
|
+
loading: true,
|
|
682
|
+
addFilterGroup: vi.fn(),
|
|
683
|
+
removeFilterGroup: vi.fn(),
|
|
684
|
+
refresh: vi.fn().mockResolvedValue(undefined),
|
|
685
|
+
};
|
|
686
|
+
const mockTargetModel = {
|
|
687
|
+
resource,
|
|
688
|
+
setFilterActive: vi.fn(),
|
|
689
|
+
getDataLoadingMode: vi.fn().mockReturnValue('auto'),
|
|
690
|
+
};
|
|
691
|
+
const mockFilterModel = {
|
|
692
|
+
getFilterValue: vi.fn().mockReturnValue('test-value'),
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
(mockFlowModel.flowEngine.getModel as any).mockImplementation((uid: string) => {
|
|
696
|
+
if (uid === 'target-1') return mockTargetModel;
|
|
697
|
+
if (uid === 'filter-1') return mockFilterModel;
|
|
698
|
+
return null;
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
const refreshPromise = filterManager.refreshTargetsByFilter('filter-1');
|
|
702
|
+
|
|
703
|
+
try {
|
|
704
|
+
await vi.advanceTimersByTimeAsync(2100);
|
|
705
|
+
|
|
706
|
+
expect(resource.refresh).not.toHaveBeenCalled();
|
|
707
|
+
|
|
708
|
+
resource.loading = false;
|
|
709
|
+
await vi.advanceTimersByTimeAsync(16);
|
|
710
|
+
await refreshPromise;
|
|
711
|
+
|
|
712
|
+
expect(resource.refresh).toHaveBeenCalledTimes(1);
|
|
713
|
+
} finally {
|
|
714
|
+
vi.useRealTimers();
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it('should skip excluded target ids when refreshing', async () => {
|
|
719
|
+
(filterManager as any).filterConfigs = [
|
|
720
|
+
{
|
|
721
|
+
filterId: 'filter-1',
|
|
722
|
+
targetId: 'target-1',
|
|
723
|
+
filterPaths: ['name'],
|
|
724
|
+
operator: '$eq',
|
|
725
|
+
},
|
|
726
|
+
{
|
|
727
|
+
filterId: 'filter-1',
|
|
728
|
+
targetId: 'target-2',
|
|
729
|
+
filterPaths: ['name'],
|
|
730
|
+
operator: '$eq',
|
|
731
|
+
},
|
|
732
|
+
];
|
|
733
|
+
|
|
734
|
+
const mockTargetModel1 = {
|
|
735
|
+
resource: {
|
|
736
|
+
addFilterGroup: vi.fn(),
|
|
737
|
+
removeFilterGroup: vi.fn(),
|
|
738
|
+
refresh: vi.fn().mockResolvedValue(undefined),
|
|
739
|
+
},
|
|
740
|
+
setFilterActive: vi.fn(),
|
|
741
|
+
getDataLoadingMode: vi.fn().mockReturnValue('auto'),
|
|
742
|
+
};
|
|
743
|
+
const mockTargetModel2 = {
|
|
744
|
+
resource: {
|
|
745
|
+
addFilterGroup: vi.fn(),
|
|
746
|
+
removeFilterGroup: vi.fn(),
|
|
747
|
+
refresh: vi.fn().mockResolvedValue(undefined),
|
|
748
|
+
},
|
|
749
|
+
setFilterActive: vi.fn(),
|
|
750
|
+
getDataLoadingMode: vi.fn().mockReturnValue('auto'),
|
|
751
|
+
};
|
|
752
|
+
const mockFilterModel = {
|
|
753
|
+
getFilterValue: vi.fn().mockReturnValue('test-value'),
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
(mockFlowModel.flowEngine.getModel as any).mockImplementation((uid: string) => {
|
|
757
|
+
if (uid === 'target-1') return mockTargetModel1;
|
|
758
|
+
if (uid === 'target-2') return mockTargetModel2;
|
|
759
|
+
if (uid === 'filter-1') return mockFilterModel;
|
|
760
|
+
return null;
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
await filterManager.refreshTargetsByFilter('filter-1', { excludeTargetIds: new Set(['target-1']) });
|
|
764
|
+
|
|
765
|
+
expect(mockTargetModel1.resource.refresh).not.toHaveBeenCalled();
|
|
766
|
+
expect(mockTargetModel2.resource.refresh).toHaveBeenCalledTimes(1);
|
|
767
|
+
});
|
|
768
|
+
|
|
499
769
|
it('should normalize array operators when the target field is scalar', async () => {
|
|
500
770
|
const filterConfigs = [
|
|
501
771
|
{
|
|
@@ -94,8 +94,6 @@ FormSubmitActionModel.registerFlow({
|
|
|
94
94
|
ctx.model.setProps('loading', true);
|
|
95
95
|
const { submitHandler } = await import('./submitHandler');
|
|
96
96
|
await submitHandler(ctx, params);
|
|
97
|
-
ctx.message.success(ctx.t('Saved successfully'));
|
|
98
|
-
ctx.model.setProps('loading', false);
|
|
99
97
|
} catch (error) {
|
|
100
98
|
ctx.model.setProps('loading', false);
|
|
101
99
|
// 显示保存失败提示
|
|
@@ -107,12 +105,8 @@ FormSubmitActionModel.registerFlow({
|
|
|
107
105
|
}
|
|
108
106
|
},
|
|
109
107
|
},
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (ctx.view) {
|
|
113
|
-
ctx.view.close();
|
|
114
|
-
}
|
|
115
|
-
},
|
|
108
|
+
afterSuccess: {
|
|
109
|
+
use: 'afterSuccess',
|
|
116
110
|
},
|
|
117
111
|
},
|
|
118
112
|
});
|