@nocobase/client-v2 2.1.0-beta.29 → 2.1.0-beta.32
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/actions/index.d.ts +1 -1
- package/es/flow/actions/linkageRules.d.ts +2 -0
- package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +4 -0
- package/es/flow/admin-shell/admin-layout/AdminLayoutModel.d.ts +7 -0
- package/es/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.d.ts +5 -0
- package/es/flow/components/FieldAssignRulesEditor.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/table/TableSelectModel.d.ts +8 -0
- package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +3 -0
- package/es/flow/models/fields/DisplayTitleFieldModel.d.ts +1 -1
- package/es/flow/models/utils/displayValueUtils.d.ts +12 -0
- package/es/flow-compat/passwordUtils.d.ts +1 -1
- package/es/index.mjs +122 -106
- package/es/utils/remotePlugins.d.ts +0 -4
- package/lib/index.js +121 -105
- package/package.json +6 -5
- package/src/BaseApplication.tsx +14 -8
- package/src/PluginManager.ts +1 -0
- package/src/__tests__/app.test.tsx +28 -1
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +67 -46
- package/src/__tests__/remotePlugins.test.ts +29 -18
- package/src/__tests__/settings-center.test.tsx +30 -0
- 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/__tests__/FlowRoute.test.tsx +4 -5
- package/src/flow/actions/__tests__/actionLinkageRules.race.repro.test.ts +199 -0
- package/src/flow/actions/__tests__/dataScopeFilter.test.ts +92 -13
- package/src/flow/actions/__tests__/linkageRules.formValueDrivenRefresh.test.ts +6 -1
- package/src/flow/actions/__tests__/linkageRules.menu.test.ts +90 -0
- package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +476 -1
- package/src/flow/actions/index.ts +2 -0
- package/src/flow/actions/linkageRules.tsx +316 -280
- package/src/flow/actions/linkageRulesFormValueRefresh.ts +2 -8
- package/src/flow/actions/setTargetDataScope.tsx +32 -3
- package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +8 -1
- package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +70 -12
- package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +26 -87
- package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +11 -0
- package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +5 -1
- package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +292 -31
- package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +50 -12
- package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +77 -56
- package/src/flow/components/AdminLayout.tsx +2 -2
- package/src/flow/components/FieldAssignRulesEditor.tsx +2 -0
- package/src/flow/components/FlowRoute.tsx +17 -4
- package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +81 -4
- 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/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-manager/FilterManager.ts +66 -2
- package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +270 -0
- package/src/flow/models/blocks/form/FormBlockModel.tsx +8 -5
- package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +30 -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/table/TableBlockModel.tsx +11 -6
- package/src/flow/models/blocks/table/TableColumnModel.tsx +3 -0
- 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 +96 -1
- package/src/flow/models/blocks/table/__tests__/TableSelectModel.test.ts +41 -0
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +4 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +7 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +4 -1
- package/src/flow/models/fields/ClickableFieldModel.tsx +9 -4
- package/src/flow/models/fields/DisplayTitleFieldModel.tsx +12 -4
- package/src/flow/models/fields/SelectFieldModel.tsx +31 -1
- package/src/flow/models/fields/__tests__/ClickableFieldModel.test.ts +23 -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/flow/system-settings/useSystemSettings.tsx +36 -1
- package/src/utils/globalDeps.ts +2 -0
- package/src/utils/remotePlugins.ts +7 -27
|
@@ -46,6 +46,35 @@ export function enumToOptions(uiEnum: UiSchemaEnumItem[] | undefined, t: (s: str
|
|
|
46
46
|
return translateOptions(normalized, t);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
export function normalizeSelectRenderValue(value: unknown, props?: Record<string, any>) {
|
|
50
|
+
const mode = props?.mode;
|
|
51
|
+
const isMultiple = mode === 'multiple' || mode === 'tags' || props?.multiple === true;
|
|
52
|
+
|
|
53
|
+
if (Array.isArray(value)) {
|
|
54
|
+
return value.filter((item) => item !== '' && item !== null && typeof item !== 'undefined');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (value === '' || value === null || typeof value === 'undefined') {
|
|
58
|
+
return isMultiple ? [] : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getSelectedEnumLabels(value: any, fallbackOptions: Option[] = []): Array<{ value: any; label: any }> {
|
|
65
|
+
const values = Array.isArray(value) ? value : value == null ? [] : [value];
|
|
66
|
+
return values.map((item) => {
|
|
67
|
+
const fallback = fallbackOptions.find((option) => option?.value === item);
|
|
68
|
+
if (fallback) {
|
|
69
|
+
return { value: item, label: fallback.label };
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
value: item,
|
|
73
|
+
label: item?.toString?.() ?? item,
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
49
78
|
// Populate model.props.options from uiSchema.enum if missing; returns whether options were injected
|
|
50
79
|
export function ensureOptionsFromUiSchemaEnumIfAbsent(model: FlowModel, collectionField: CollectionField): boolean {
|
|
51
80
|
const iface = collectionField?.interface;
|
|
@@ -16,6 +16,7 @@ import { SkeletonFallback } from '../../components/SkeletonFallback';
|
|
|
16
16
|
import { ActionModel, ActionSceneEnum } from '../base';
|
|
17
17
|
import {
|
|
18
18
|
applyAssociateAction,
|
|
19
|
+
getAssociationSelectorContextInputArgs,
|
|
19
20
|
getAssociationTargetResourceSettings,
|
|
20
21
|
isAssociationBlockContext,
|
|
21
22
|
} from './AssociationActionUtils';
|
|
@@ -139,7 +140,6 @@ AssociateActionModel.registerFlow({
|
|
|
139
140
|
steps: {
|
|
140
141
|
openSelector: {
|
|
141
142
|
async handler(ctx, params) {
|
|
142
|
-
const blockModel = ctx.blockModel;
|
|
143
143
|
const targetResourceSettings = getAssociationTargetResourceSettings(ctx);
|
|
144
144
|
const openMode = ctx.inputArgs?.isMobileLayout ? 'embed' : ctx.inputArgs?.mode || params?.mode || 'drawer';
|
|
145
145
|
const size = ctx.inputArgs?.size || params?.size || 'medium';
|
|
@@ -168,9 +168,9 @@ AssociateActionModel.registerFlow({
|
|
|
168
168
|
scene: 'select',
|
|
169
169
|
dataSourceKey: targetResourceSettings.dataSourceKey,
|
|
170
170
|
collectionName: targetResourceSettings.collectionName,
|
|
171
|
+
...getAssociationSelectorContextInputArgs(ctx),
|
|
171
172
|
rowSelectionProps: {
|
|
172
173
|
type: 'checkbox',
|
|
173
|
-
defaultSelectedRows: () => blockModel?.resource?.getData?.() || [],
|
|
174
174
|
renderCell: undefined,
|
|
175
175
|
selectedRowKeys: undefined,
|
|
176
176
|
onChange: (_, selectedRows) => {
|
|
@@ -33,6 +33,20 @@ export const getAssociationTargetResourceSettings = (ctx: FlowModelContext | any
|
|
|
33
33
|
};
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
+
export const getAssociationSelectorContextInputArgs = (ctx: FlowModelContext | any) => {
|
|
37
|
+
const blockModel = ctx?.blockModel || ctx?.model?.context?.blockModel;
|
|
38
|
+
const association = blockModel?.association;
|
|
39
|
+
const resourceSettings = getAssociationBlockResourceSettings(ctx);
|
|
40
|
+
const sourceId = blockModel?.resource?.getSourceId?.() ?? resourceSettings?.sourceId;
|
|
41
|
+
const associatedRecords = blockModel?.resource?.getData?.() || [];
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
collectionField: association,
|
|
45
|
+
sourceId,
|
|
46
|
+
associatedRecords,
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
36
50
|
const callAssociationResourceAction = async (resource: any, action: 'add' | 'remove', values: any[]) => {
|
|
37
51
|
if (typeof resource?.[action] === 'function') {
|
|
38
52
|
return await resource[action]({ values });
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
AssociateActionModel,
|
|
17
17
|
CollectionActionGroupModel,
|
|
18
18
|
DisassociateActionModel,
|
|
19
|
+
getAssociationSelectorContextInputArgs,
|
|
19
20
|
getAssociationTargetResourceSettings,
|
|
20
21
|
PopupActionModel,
|
|
21
22
|
RecordActionGroupModel,
|
|
@@ -87,6 +88,68 @@ describe('association action models', () => {
|
|
|
87
88
|
});
|
|
88
89
|
});
|
|
89
90
|
|
|
91
|
+
it('passes one-to-many association context to selector table blocks', () => {
|
|
92
|
+
const association = {
|
|
93
|
+
name: 'orders',
|
|
94
|
+
interface: 'o2m',
|
|
95
|
+
target: 'orders',
|
|
96
|
+
sourceKey: 'id',
|
|
97
|
+
foreignKey: 'userId',
|
|
98
|
+
};
|
|
99
|
+
const ctx: any = {
|
|
100
|
+
blockModel: {
|
|
101
|
+
association,
|
|
102
|
+
resource: {
|
|
103
|
+
getSourceId: () => 362872646860800,
|
|
104
|
+
},
|
|
105
|
+
getResourceSettingsInitParams: () => ({
|
|
106
|
+
dataSourceKey: 'main',
|
|
107
|
+
collectionName: 'orders',
|
|
108
|
+
associationName: 'users.orders',
|
|
109
|
+
sourceId: '{{ctx.popup.record.id}}',
|
|
110
|
+
}),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
expect(getAssociationSelectorContextInputArgs(ctx)).toEqual({
|
|
115
|
+
collectionField: association,
|
|
116
|
+
sourceId: 362872646860800,
|
|
117
|
+
associatedRecords: [],
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('passes current associated records to selector table blocks', () => {
|
|
122
|
+
const associatedRecords = [{ id: 11 }, { id: 12 }];
|
|
123
|
+
const association = {
|
|
124
|
+
name: 'tags',
|
|
125
|
+
interface: 'm2m',
|
|
126
|
+
target: 'tags',
|
|
127
|
+
sourceKey: 'id',
|
|
128
|
+
targetKey: 'id',
|
|
129
|
+
};
|
|
130
|
+
const ctx: any = {
|
|
131
|
+
blockModel: {
|
|
132
|
+
association,
|
|
133
|
+
resource: {
|
|
134
|
+
getSourceId: () => 1,
|
|
135
|
+
getData: () => associatedRecords,
|
|
136
|
+
},
|
|
137
|
+
getResourceSettingsInitParams: () => ({
|
|
138
|
+
dataSourceKey: 'main',
|
|
139
|
+
collectionName: 'tags',
|
|
140
|
+
associationName: 'posts.tags',
|
|
141
|
+
sourceId: '{{ctx.popup.record.id}}',
|
|
142
|
+
}),
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
expect(getAssociationSelectorContextInputArgs(ctx)).toEqual({
|
|
147
|
+
collectionField: association,
|
|
148
|
+
sourceId: 1,
|
|
149
|
+
associatedRecords,
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
90
153
|
it('shows Associate only in collection actions of association blocks', async () => {
|
|
91
154
|
const engine = createEngine();
|
|
92
155
|
|
|
@@ -662,6 +662,9 @@ CollectionBlockModel.registerFlow({
|
|
|
662
662
|
async handler(ctx) {
|
|
663
663
|
const blockModel = ctx.model as CollectionBlockModel;
|
|
664
664
|
const filterManager: FilterManager = ctx.model.context.filterManager;
|
|
665
|
+
const preparedFilterBlocks = filterManager?.prepareFiltersForTarget
|
|
666
|
+
? await filterManager.prepareFiltersForTarget(ctx.model.uid)
|
|
667
|
+
: undefined;
|
|
665
668
|
if (filterManager) {
|
|
666
669
|
filterManager.bindToTarget(ctx.model.uid);
|
|
667
670
|
}
|
|
@@ -679,6 +682,10 @@ CollectionBlockModel.registerFlow({
|
|
|
679
682
|
return;
|
|
680
683
|
}
|
|
681
684
|
|
|
685
|
+
preparedFilterBlocks?.forEach((filterBlockModel: any) => {
|
|
686
|
+
filterBlockModel?.markInitialTargetRefreshHandled?.(ctx.model.uid);
|
|
687
|
+
});
|
|
688
|
+
|
|
682
689
|
if (ctx.model.isManualRefresh) {
|
|
683
690
|
ctx.model.resource.loading = false;
|
|
684
691
|
} else {
|
|
@@ -32,7 +32,7 @@ import { BlockSceneEnum } from '../../base/BlockModel';
|
|
|
32
32
|
import { FilterBlockModel } from '../../base/FilterBlockModel';
|
|
33
33
|
import { FormComponent } from '../form/FormBlockModel';
|
|
34
34
|
import { isEmptyValue } from '../form/value-runtime/utils';
|
|
35
|
-
import { FilterManager } from '../filter-manager/FilterManager';
|
|
35
|
+
import { FilterManager, type RefreshTargetsByFilterOptions } from '../filter-manager/FilterManager';
|
|
36
36
|
import { FilterFormItemModel } from './FilterFormItemModel';
|
|
37
37
|
import { clearLegacyDefaultValuesFromFilterFormModel } from './legacyDefaultValueMigration';
|
|
38
38
|
import { findFormItemModelByFieldPath } from '../../../internal/utils/modelUtils';
|
|
@@ -54,6 +54,8 @@ export class FilterFormBlockModel extends FilterBlockModel<{
|
|
|
54
54
|
autoTriggerFilter = true;
|
|
55
55
|
|
|
56
56
|
private removeTargetBlockListener?: () => void;
|
|
57
|
+
private initialDefaultsPromise?: Promise<void>;
|
|
58
|
+
private initialRefreshHandledTargetIds = new Set<string>();
|
|
57
59
|
|
|
58
60
|
get form() {
|
|
59
61
|
return this.context.form;
|
|
@@ -82,13 +84,13 @@ export class FilterFormBlockModel extends FilterBlockModel<{
|
|
|
82
84
|
this.context.defineProperty('blockModel', {
|
|
83
85
|
value: this,
|
|
84
86
|
});
|
|
85
|
-
this.context.defineMethod('refreshTargets', async () => {
|
|
87
|
+
this.context.defineMethod('refreshTargets', async (options?: RefreshTargetsByFilterOptions) => {
|
|
86
88
|
const gridModel = this.subModels.grid;
|
|
87
89
|
const fieldModels: FilterFormItemModel[] = gridModel.subModels.items;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
const filterIds = fieldModels?.map((fieldModel) => fieldModel?.uid).filter(Boolean);
|
|
91
|
+
if (filterIds?.length) {
|
|
92
|
+
const filterManager: FilterManager = this.context.filterManager;
|
|
93
|
+
await filterManager.refreshTargetsByFilter(filterIds, options);
|
|
92
94
|
}
|
|
93
95
|
});
|
|
94
96
|
}
|
|
@@ -124,9 +126,31 @@ export class FilterFormBlockModel extends FilterBlockModel<{
|
|
|
124
126
|
}
|
|
125
127
|
|
|
126
128
|
private async applyDefaultsAndInitialFilter() {
|
|
127
|
-
await this.
|
|
128
|
-
|
|
129
|
-
|
|
129
|
+
const prepared = await this.prepareInitialFilterValues();
|
|
130
|
+
if (prepared) {
|
|
131
|
+
await this.context.refreshTargets?.({ excludeTargetIds: this.initialRefreshHandledTargetIds });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async prepareInitialFilterValues() {
|
|
136
|
+
if (!this.form) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!this.initialDefaultsPromise) {
|
|
141
|
+
this.initialDefaultsPromise = (async () => {
|
|
142
|
+
await this.ensureFilterItemsBeforeRender();
|
|
143
|
+
await this.applyFormDefaultValues();
|
|
144
|
+
})();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
await this.initialDefaultsPromise;
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
markInitialTargetRefreshHandled(targetId: string) {
|
|
152
|
+
if (!targetId) return;
|
|
153
|
+
this.initialRefreshHandledTargetIds.add(targetId);
|
|
130
154
|
}
|
|
131
155
|
|
|
132
156
|
private async ensureFilterItemsBeforeRender() {
|
|
@@ -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
|
});
|
|
@@ -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
|
}
|