@nocobase/flow-engine 2.0.0-beta.2 → 2.0.0-beta.20
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/lib/BlockScopedFlowEngine.js +0 -1
- package/lib/JSRunner.d.ts +6 -0
- package/lib/JSRunner.js +2 -1
- package/lib/ViewScopedFlowEngine.js +3 -0
- package/lib/acl/Acl.js +13 -3
- package/lib/components/dnd/gridDragPlanner.d.ts +1 -0
- package/lib/components/dnd/gridDragPlanner.js +53 -1
- package/lib/components/settings/wrappers/component/SwitchWithTitle.js +2 -1
- package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +11 -3
- package/lib/components/variables/VariableInput.js +8 -2
- package/lib/data-source/index.js +6 -0
- package/lib/executor/FlowExecutor.d.ts +2 -1
- package/lib/executor/FlowExecutor.js +156 -22
- package/lib/flowContext.d.ts +4 -1
- package/lib/flowContext.js +176 -107
- package/lib/flowEngine.d.ts +21 -0
- package/lib/flowEngine.js +38 -0
- package/lib/flowSettings.js +12 -10
- package/lib/index.d.ts +3 -0
- package/lib/index.js +16 -0
- package/lib/models/CollectionFieldModel.d.ts +1 -0
- package/lib/models/CollectionFieldModel.js +3 -2
- package/lib/models/flowModel.d.ts +7 -0
- package/lib/models/flowModel.js +66 -1
- package/lib/provider.js +7 -6
- package/lib/resources/baseRecordResource.d.ts +5 -0
- package/lib/resources/baseRecordResource.js +24 -0
- package/lib/resources/multiRecordResource.d.ts +1 -0
- package/lib/resources/multiRecordResource.js +11 -4
- package/lib/resources/singleRecordResource.js +2 -0
- package/lib/resources/sqlResource.d.ts +1 -0
- package/lib/resources/sqlResource.js +8 -3
- package/lib/runjs-context/contexts/base.js +10 -4
- package/lib/runjsLibs.d.ts +28 -0
- package/lib/runjsLibs.js +532 -0
- package/lib/scheduler/ModelOperationScheduler.d.ts +2 -0
- package/lib/scheduler/ModelOperationScheduler.js +21 -21
- package/lib/types.d.ts +15 -0
- package/lib/utils/createCollectionContextMeta.js +1 -0
- package/lib/utils/index.d.ts +2 -0
- package/lib/utils/index.js +10 -0
- package/lib/utils/params-resolvers.js +16 -9
- package/lib/utils/resolveModuleUrl.d.ts +58 -0
- package/lib/utils/resolveModuleUrl.js +65 -0
- package/lib/utils/runjsModuleLoader.d.ts +58 -0
- package/lib/utils/runjsModuleLoader.js +422 -0
- package/lib/utils/runjsTemplateCompat.d.ts +35 -0
- package/lib/utils/runjsTemplateCompat.js +743 -0
- package/lib/utils/safeGlobals.d.ts +5 -9
- package/lib/utils/safeGlobals.js +129 -17
- package/lib/views/createViewMeta.d.ts +0 -7
- package/lib/views/createViewMeta.js +19 -70
- package/lib/views/index.d.ts +1 -2
- package/lib/views/index.js +4 -3
- package/lib/views/useDialog.js +8 -3
- package/lib/views/useDrawer.js +7 -2
- package/lib/views/usePage.d.ts +4 -0
- package/lib/views/usePage.js +43 -6
- package/lib/views/usePopover.js +4 -1
- package/lib/views/viewEvents.d.ts +17 -0
- package/lib/views/viewEvents.js +90 -0
- package/package.json +4 -4
- package/src/BlockScopedFlowEngine.ts +2 -5
- package/src/JSRunner.ts +8 -1
- package/src/ViewScopedFlowEngine.ts +4 -0
- package/src/__tests__/createViewMeta.popup.test.ts +62 -1
- package/src/__tests__/flowEngine.dataSourceDirty.test.ts +63 -0
- package/src/__tests__/flowSettings.open.test.tsx +69 -15
- package/src/__tests__/provider.test.tsx +0 -5
- package/src/__tests__/runjsExternalLibs.test.ts +242 -0
- package/src/__tests__/runjsLibsLazyLoading.test.ts +44 -0
- package/src/__tests__/runjsPreprocessDefault.test.ts +49 -0
- package/src/acl/Acl.tsx +3 -3
- package/src/components/__tests__/gridDragPlanner.test.ts +141 -1
- package/src/components/dnd/gridDragPlanner.ts +60 -0
- package/src/components/settings/wrappers/component/SwitchWithTitle.tsx +2 -1
- package/src/components/settings/wrappers/component/__tests__/InlineControls.test.tsx +74 -0
- package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +11 -3
- package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +63 -4
- package/src/components/variables/VariableInput.tsx +8 -2
- package/src/data-source/index.ts +6 -0
- package/src/executor/FlowExecutor.ts +193 -23
- package/src/executor/__tests__/flowExecutor.test.ts +66 -0
- package/src/flowContext.ts +234 -118
- package/src/flowEngine.ts +41 -0
- package/src/flowSettings.ts +12 -11
- package/src/index.ts +10 -0
- package/src/models/CollectionFieldModel.tsx +3 -1
- package/src/models/__tests__/dispatchEvent.when.test.ts +356 -0
- package/src/models/__tests__/flowModel.clone.test.ts +416 -0
- package/src/models/__tests__/flowModel.test.ts +16 -0
- package/src/models/flowModel.tsx +94 -1
- package/src/provider.tsx +9 -7
- package/src/resources/__tests__/multiRecordResource.test.ts +44 -0
- package/src/resources/__tests__/sqlResource.test.ts +60 -0
- package/src/resources/baseRecordResource.ts +31 -0
- package/src/resources/multiRecordResource.ts +11 -4
- package/src/resources/singleRecordResource.ts +3 -0
- package/src/resources/sqlResource.ts +8 -3
- package/src/runjs-context/contexts/base.ts +9 -2
- package/src/runjsLibs.ts +622 -0
- package/src/scheduler/ModelOperationScheduler.ts +23 -21
- package/src/types.ts +26 -1
- package/src/utils/__tests__/params-resolvers.test.ts +40 -0
- package/src/utils/__tests__/runjsRequireAsyncAutoWhitelist.test.ts +38 -0
- package/src/utils/__tests__/runjsTemplateCompat.test.ts +159 -0
- package/src/utils/__tests__/safeGlobals.test.ts +49 -2
- package/src/utils/createCollectionContextMeta.ts +1 -0
- package/src/utils/index.ts +6 -0
- package/src/utils/params-resolvers.ts +23 -9
- package/src/utils/resolveModuleUrl.ts +91 -0
- package/src/utils/runjsModuleLoader.ts +553 -0
- package/src/utils/runjsTemplateCompat.ts +828 -0
- package/src/utils/safeGlobals.ts +133 -16
- package/src/views/__tests__/FlowView.usePage.test.tsx +54 -1
- package/src/views/__tests__/useDialog.closeDestroy.test.tsx +35 -8
- package/src/views/__tests__/viewEvents.resolveOpenerEngine.test.ts +28 -0
- package/src/views/createViewMeta.ts +22 -75
- package/src/views/index.tsx +1 -2
- package/src/views/useDialog.tsx +9 -2
- package/src/views/useDrawer.tsx +8 -1
- package/src/views/usePage.tsx +51 -5
- package/src/views/usePopover.tsx +4 -1
- package/src/views/viewEvents.ts +55 -0
|
@@ -0,0 +1,416 @@
|
|
|
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, it, expect, beforeEach, vi } from 'vitest';
|
|
11
|
+
import { FlowEngine } from '../../flowEngine';
|
|
12
|
+
import { FlowModel } from '../flowModel';
|
|
13
|
+
|
|
14
|
+
describe('FlowModel.clone', () => {
|
|
15
|
+
let flowEngine: FlowEngine;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
flowEngine = new FlowEngine();
|
|
19
|
+
// Mock api for FlowEngineContext
|
|
20
|
+
(flowEngine.context as any).api = {
|
|
21
|
+
auth: {
|
|
22
|
+
role: 'admin',
|
|
23
|
+
locale: 'en-US',
|
|
24
|
+
token: 'mock-token',
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
vi.clearAllMocks();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should clone a simple model with a new uid', () => {
|
|
31
|
+
const original = flowEngine.createModel({
|
|
32
|
+
uid: 'original-uid',
|
|
33
|
+
use: 'FlowModel',
|
|
34
|
+
props: { title: 'Original Title' },
|
|
35
|
+
stepParams: { testFlow: { step1: { value: 'test' } } },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const cloned = original.clone();
|
|
39
|
+
|
|
40
|
+
// uid should be different
|
|
41
|
+
expect(cloned.uid).not.toBe(original.uid);
|
|
42
|
+
|
|
43
|
+
// props should be the same
|
|
44
|
+
expect(cloned.props).toEqual(original.props);
|
|
45
|
+
|
|
46
|
+
// stepParams should be the same
|
|
47
|
+
expect(cloned.stepParams).toEqual(original.stepParams);
|
|
48
|
+
|
|
49
|
+
// should be registered in flowEngine
|
|
50
|
+
expect(flowEngine.getModel(cloned.uid)).toBe(cloned);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should clone model without parent relationship', () => {
|
|
54
|
+
const parent = flowEngine.createModel({
|
|
55
|
+
uid: 'parent-uid',
|
|
56
|
+
use: 'FlowModel',
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const child = parent.addSubModel('children', {
|
|
60
|
+
use: 'FlowModel',
|
|
61
|
+
props: { name: 'child' },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const cloned = child.clone();
|
|
65
|
+
|
|
66
|
+
// cloned model should not have parent
|
|
67
|
+
expect(cloned.parent).toBeUndefined();
|
|
68
|
+
expect(cloned['_options'].parentId).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should clone model with subModels recursively', () => {
|
|
72
|
+
const parent = flowEngine.createModel({
|
|
73
|
+
uid: 'parent-uid',
|
|
74
|
+
use: 'FlowModel',
|
|
75
|
+
props: { name: 'parent' },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const child1 = parent.addSubModel('items', {
|
|
79
|
+
use: 'FlowModel',
|
|
80
|
+
props: { name: 'child1' },
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const child2 = parent.addSubModel('items', {
|
|
84
|
+
use: 'FlowModel',
|
|
85
|
+
props: { name: 'child2' },
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const cloned = parent.clone();
|
|
89
|
+
|
|
90
|
+
// parent uid should be different
|
|
91
|
+
expect(cloned.uid).not.toBe(parent.uid);
|
|
92
|
+
|
|
93
|
+
// subModels should exist
|
|
94
|
+
expect(cloned.subModels['items']).toBeDefined();
|
|
95
|
+
expect(cloned.subModels['items']).toHaveLength(2);
|
|
96
|
+
|
|
97
|
+
// subModels uids should be different
|
|
98
|
+
const clonedChildren = cloned.subModels['items'] as FlowModel[];
|
|
99
|
+
expect(clonedChildren[0].uid).not.toBe(child1.uid);
|
|
100
|
+
expect(clonedChildren[1].uid).not.toBe(child2.uid);
|
|
101
|
+
|
|
102
|
+
// subModels props should be the same
|
|
103
|
+
expect(clonedChildren[0].props.name).toBe('child1');
|
|
104
|
+
expect(clonedChildren[1].props.name).toBe('child2');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should clone model with nested subModels', () => {
|
|
108
|
+
const root = flowEngine.createModel({
|
|
109
|
+
uid: 'root-uid',
|
|
110
|
+
use: 'FlowModel',
|
|
111
|
+
props: { level: 'root' },
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const level1 = root.addSubModel('children', {
|
|
115
|
+
use: 'FlowModel',
|
|
116
|
+
props: { level: 'level1' },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const level2 = level1.addSubModel('children', {
|
|
120
|
+
use: 'FlowModel',
|
|
121
|
+
props: { level: 'level2' },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const cloned = root.clone();
|
|
125
|
+
|
|
126
|
+
// All uids should be different
|
|
127
|
+
expect(cloned.uid).not.toBe(root.uid);
|
|
128
|
+
|
|
129
|
+
const clonedLevel1 = (cloned.subModels['children'] as FlowModel[])[0];
|
|
130
|
+
expect(clonedLevel1.uid).not.toBe(level1.uid);
|
|
131
|
+
expect(clonedLevel1.props.level).toBe('level1');
|
|
132
|
+
|
|
133
|
+
const clonedLevel2 = (clonedLevel1.subModels['children'] as FlowModel[])[0];
|
|
134
|
+
expect(clonedLevel2.uid).not.toBe(level2.uid);
|
|
135
|
+
expect(clonedLevel2.props.level).toBe('level2');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should clone model with object-type subModel', () => {
|
|
139
|
+
const parent = flowEngine.createModel({
|
|
140
|
+
uid: 'parent-uid',
|
|
141
|
+
use: 'FlowModel',
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const header = parent.setSubModel('header', {
|
|
145
|
+
use: 'FlowModel',
|
|
146
|
+
props: { title: 'Header' },
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const cloned = parent.clone();
|
|
150
|
+
|
|
151
|
+
// parent uid should be different
|
|
152
|
+
expect(cloned.uid).not.toBe(parent.uid);
|
|
153
|
+
|
|
154
|
+
// object-type subModel should exist with different uid
|
|
155
|
+
const clonedHeader = cloned.subModels['header'] as FlowModel;
|
|
156
|
+
expect(clonedHeader).toBeDefined();
|
|
157
|
+
expect(clonedHeader.uid).not.toBe(header.uid);
|
|
158
|
+
expect(clonedHeader.props.title).toBe('Header');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should preserve sortIndex when cloning', () => {
|
|
162
|
+
const model = flowEngine.createModel({
|
|
163
|
+
uid: 'test-uid',
|
|
164
|
+
use: 'FlowModel',
|
|
165
|
+
sortIndex: 5,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const cloned = model.clone();
|
|
169
|
+
|
|
170
|
+
expect(cloned.sortIndex).toBe(5);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should preserve stepParams when cloning', () => {
|
|
174
|
+
const model = flowEngine.createModel({
|
|
175
|
+
uid: 'test-uid',
|
|
176
|
+
use: 'FlowModel',
|
|
177
|
+
stepParams: {
|
|
178
|
+
flow1: { step1: { param1: 'value1' } },
|
|
179
|
+
flow2: { step2: { param2: 'value2' } },
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const cloned = model.clone();
|
|
184
|
+
|
|
185
|
+
expect(cloned.stepParams).toEqual({
|
|
186
|
+
flow1: { step1: { param1: 'value1' } },
|
|
187
|
+
flow2: { step2: { param2: 'value2' } },
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should throw error if flowEngine is not set', () => {
|
|
192
|
+
const model = flowEngine.createModel({
|
|
193
|
+
uid: 'test-uid',
|
|
194
|
+
use: 'FlowModel',
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Manually remove flowEngine to simulate edge case
|
|
198
|
+
(model as any).flowEngine = null;
|
|
199
|
+
|
|
200
|
+
expect(() => model.clone()).toThrow('FlowEngine is not set on this model. Please set flowEngine before cloning.');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('should clone model with correct type', () => {
|
|
204
|
+
class CustomModel extends FlowModel {
|
|
205
|
+
customMethod() {
|
|
206
|
+
return 'custom';
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
flowEngine.registerModels({ CustomModel });
|
|
211
|
+
|
|
212
|
+
const original = flowEngine.createModel<CustomModel>({
|
|
213
|
+
uid: 'custom-uid',
|
|
214
|
+
use: 'CustomModel',
|
|
215
|
+
props: { custom: true },
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const cloned = original.clone<CustomModel>();
|
|
219
|
+
|
|
220
|
+
expect(cloned).toBeInstanceOf(CustomModel);
|
|
221
|
+
expect(cloned.customMethod()).toBe('custom');
|
|
222
|
+
expect(cloned.uid).not.toBe(original.uid);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should clone model and all cloned models should be independent', () => {
|
|
226
|
+
const original = flowEngine.createModel({
|
|
227
|
+
uid: 'original-uid',
|
|
228
|
+
use: 'FlowModel',
|
|
229
|
+
props: { value: 1 },
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const cloned = original.clone();
|
|
233
|
+
|
|
234
|
+
// Modify original
|
|
235
|
+
original.setProps({ value: 2 });
|
|
236
|
+
|
|
237
|
+
// Cloned should not be affected
|
|
238
|
+
expect(cloned.props.value).toBe(1);
|
|
239
|
+
expect(original.props.value).toBe(2);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('should clone model with mixed subModels (array and object)', () => {
|
|
243
|
+
const parent = flowEngine.createModel({
|
|
244
|
+
uid: 'parent-uid',
|
|
245
|
+
use: 'FlowModel',
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Add array-type subModels
|
|
249
|
+
parent.addSubModel('items', { use: 'FlowModel', props: { type: 'item1' } });
|
|
250
|
+
parent.addSubModel('items', { use: 'FlowModel', props: { type: 'item2' } });
|
|
251
|
+
|
|
252
|
+
// Add object-type subModel
|
|
253
|
+
parent.setSubModel('header', { use: 'FlowModel', props: { type: 'header' } });
|
|
254
|
+
parent.setSubModel('footer', { use: 'FlowModel', props: { type: 'footer' } });
|
|
255
|
+
|
|
256
|
+
const cloned = parent.clone();
|
|
257
|
+
|
|
258
|
+
// Check array-type subModels
|
|
259
|
+
const clonedItems = cloned.subModels['items'] as FlowModel[];
|
|
260
|
+
expect(clonedItems).toHaveLength(2);
|
|
261
|
+
expect(clonedItems[0].props.type).toBe('item1');
|
|
262
|
+
expect(clonedItems[1].props.type).toBe('item2');
|
|
263
|
+
|
|
264
|
+
// Check object-type subModels
|
|
265
|
+
const clonedHeader = cloned.subModels['header'] as FlowModel;
|
|
266
|
+
const clonedFooter = cloned.subModels['footer'] as FlowModel;
|
|
267
|
+
expect(clonedHeader.props.type).toBe('header');
|
|
268
|
+
expect(clonedFooter.props.type).toBe('footer');
|
|
269
|
+
|
|
270
|
+
// All uids should be unique
|
|
271
|
+
const originalItems = parent.subModels['items'] as FlowModel[];
|
|
272
|
+
expect(clonedItems[0].uid).not.toBe(originalItems[0].uid);
|
|
273
|
+
expect(clonedItems[1].uid).not.toBe(originalItems[1].uid);
|
|
274
|
+
expect(clonedHeader.uid).not.toBe((parent.subModels['header'] as FlowModel).uid);
|
|
275
|
+
expect(clonedFooter.uid).not.toBe((parent.subModels['footer'] as FlowModel).uid);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('should correctly remap parentId for subModels to new parent uid', () => {
|
|
279
|
+
const parent = flowEngine.createModel({
|
|
280
|
+
uid: 'parent-uid',
|
|
281
|
+
use: 'FlowModel',
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const child = parent.addSubModel('children', {
|
|
285
|
+
use: 'FlowModel',
|
|
286
|
+
props: { name: 'child' },
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Verify original relationship
|
|
290
|
+
expect(child.parentId).toBe(parent.uid);
|
|
291
|
+
|
|
292
|
+
const cloned = parent.clone();
|
|
293
|
+
const clonedChild = (cloned.subModels['children'] as FlowModel[])[0];
|
|
294
|
+
|
|
295
|
+
// Root model should not have parentId
|
|
296
|
+
expect(cloned.parentId).toBeUndefined();
|
|
297
|
+
|
|
298
|
+
// Child's parentId should be remapped to cloned parent's uid
|
|
299
|
+
expect(clonedChild.parentId).toBe(cloned.uid);
|
|
300
|
+
expect(clonedChild.parentId).not.toBe(parent.uid);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('should correctly remap parentId in deeply nested subModels', () => {
|
|
304
|
+
const root = flowEngine.createModel({
|
|
305
|
+
uid: 'root-uid',
|
|
306
|
+
use: 'FlowModel',
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const level1 = root.addSubModel('children', {
|
|
310
|
+
use: 'FlowModel',
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const level2 = level1.addSubModel('children', {
|
|
314
|
+
use: 'FlowModel',
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const cloned = root.clone();
|
|
318
|
+
const clonedLevel1 = (cloned.subModels['children'] as FlowModel[])[0];
|
|
319
|
+
const clonedLevel2 = (clonedLevel1.subModels['children'] as FlowModel[])[0];
|
|
320
|
+
|
|
321
|
+
// Root should not have parentId
|
|
322
|
+
expect(cloned.parentId).toBeUndefined();
|
|
323
|
+
|
|
324
|
+
// Level1's parentId should point to cloned root
|
|
325
|
+
expect(clonedLevel1.parentId).toBe(cloned.uid);
|
|
326
|
+
|
|
327
|
+
// Level2's parentId should point to cloned level1
|
|
328
|
+
expect(clonedLevel2.parentId).toBe(clonedLevel1.uid);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('should replace uid references in props and other fields', () => {
|
|
332
|
+
const parent = flowEngine.createModel({
|
|
333
|
+
uid: 'parent-uid',
|
|
334
|
+
use: 'FlowModel',
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const child = parent.addSubModel('children', {
|
|
338
|
+
use: 'FlowModel',
|
|
339
|
+
props: {
|
|
340
|
+
// Store a reference to parent uid in props
|
|
341
|
+
targetUid: 'parent-uid',
|
|
342
|
+
relatedIds: ['parent-uid'],
|
|
343
|
+
nested: { refId: 'parent-uid' },
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const cloned = parent.clone();
|
|
348
|
+
const clonedChild = (cloned.subModels['children'] as FlowModel[])[0];
|
|
349
|
+
|
|
350
|
+
// Props containing old uid references should be updated to new uids
|
|
351
|
+
expect(clonedChild.props.targetUid).toBe(cloned.uid);
|
|
352
|
+
expect(clonedChild.props.relatedIds[0]).toBe(cloned.uid);
|
|
353
|
+
expect(clonedChild.props.nested.refId).toBe(cloned.uid);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('should replace uid references in stepParams', () => {
|
|
357
|
+
const parent = flowEngine.createModel({
|
|
358
|
+
uid: 'parent-uid',
|
|
359
|
+
use: 'FlowModel',
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const child = parent.addSubModel('children', {
|
|
363
|
+
use: 'FlowModel',
|
|
364
|
+
stepParams: {
|
|
365
|
+
someFlow: {
|
|
366
|
+
someStep: {
|
|
367
|
+
targetModelUid: 'parent-uid',
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
const cloned = parent.clone();
|
|
374
|
+
const clonedChild = (cloned.subModels['children'] as FlowModel[])[0];
|
|
375
|
+
|
|
376
|
+
// stepParams containing old uid references should be updated
|
|
377
|
+
expect(clonedChild.stepParams.someFlow.someStep.targetModelUid).toBe(cloned.uid);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('should handle self-referencing uid in props', () => {
|
|
381
|
+
const model = flowEngine.createModel({
|
|
382
|
+
uid: 'self-uid',
|
|
383
|
+
use: 'FlowModel',
|
|
384
|
+
props: {
|
|
385
|
+
selfRef: 'self-uid',
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const cloned = model.clone();
|
|
390
|
+
|
|
391
|
+
// Self-reference should be updated to new uid
|
|
392
|
+
expect(cloned.props.selfRef).toBe(cloned.uid);
|
|
393
|
+
expect(cloned.props.selfRef).not.toBe('self-uid');
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('should not replace strings that are not uids', () => {
|
|
397
|
+
const model = flowEngine.createModel({
|
|
398
|
+
uid: 'model-uid',
|
|
399
|
+
use: 'FlowModel',
|
|
400
|
+
props: {
|
|
401
|
+
title: 'Some Title',
|
|
402
|
+
description: 'This is a description',
|
|
403
|
+
count: 42,
|
|
404
|
+
enabled: true,
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
const cloned = model.clone();
|
|
409
|
+
|
|
410
|
+
// Non-uid strings should remain unchanged
|
|
411
|
+
expect(cloned.props.title).toBe('Some Title');
|
|
412
|
+
expect(cloned.props.description).toBe('This is a description');
|
|
413
|
+
expect(cloned.props.count).toBe(42);
|
|
414
|
+
expect(cloned.props.enabled).toBe(true);
|
|
415
|
+
});
|
|
416
|
+
});
|
|
@@ -1558,6 +1558,22 @@ describe('FlowModel', () => {
|
|
|
1558
1558
|
expect(model.forks.size).toBe(1);
|
|
1559
1559
|
});
|
|
1560
1560
|
|
|
1561
|
+
test('should recreate cached fork after dispose to avoid state leakage', () => {
|
|
1562
|
+
const fork1 = model.createFork({ foo: 'bar' }, 'cacheKey');
|
|
1563
|
+
fork1.hidden = true;
|
|
1564
|
+
fork1.setProps({ disabled: true });
|
|
1565
|
+
|
|
1566
|
+
fork1.dispose();
|
|
1567
|
+
|
|
1568
|
+
expect(model.getFork('cacheKey')).toBeUndefined();
|
|
1569
|
+
|
|
1570
|
+
const fork2 = model.createFork({}, 'cacheKey');
|
|
1571
|
+
|
|
1572
|
+
expect(fork2).not.toBe(fork1);
|
|
1573
|
+
expect(fork2.hidden).toBe(false);
|
|
1574
|
+
expect(fork2.localProps).toEqual({});
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1561
1577
|
test('should create different instances for different keys', () => {
|
|
1562
1578
|
const fork1 = model.createFork({}, 'key1');
|
|
1563
1579
|
const fork2 = model.createFork({}, 'key2');
|
package/src/models/flowModel.tsx
CHANGED
|
@@ -424,7 +424,19 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
|
|
|
424
424
|
const meta = Cls.meta as any;
|
|
425
425
|
const metaCreate = meta?.createModelOptions;
|
|
426
426
|
if (metaCreate && typeof metaCreate === 'object' && metaCreate.subModels) {
|
|
427
|
-
|
|
427
|
+
const replaceArrays = (objValue: unknown, srcValue: unknown) => {
|
|
428
|
+
if (Array.isArray(objValue) && Array.isArray(srcValue)) {
|
|
429
|
+
// Arrays should be replaced, not merged by index.
|
|
430
|
+
return srcValue;
|
|
431
|
+
}
|
|
432
|
+
return undefined;
|
|
433
|
+
};
|
|
434
|
+
mergedSubModels = _.mergeWith(
|
|
435
|
+
{},
|
|
436
|
+
_.cloneDeep(metaCreate.subModels || {}),
|
|
437
|
+
_.cloneDeep(subModels || {}),
|
|
438
|
+
replaceArrays,
|
|
439
|
+
);
|
|
428
440
|
}
|
|
429
441
|
} catch (e) {
|
|
430
442
|
// Fallback silently if meta defaults resolution fails
|
|
@@ -1444,6 +1456,87 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
|
|
|
1444
1456
|
return data;
|
|
1445
1457
|
}
|
|
1446
1458
|
|
|
1459
|
+
/**
|
|
1460
|
+
* 复制当前模型实例为一个新的实例。
|
|
1461
|
+
* 新实例及其所有子模型都会有新的 uid,且不保留 root model 的 parent 关系。
|
|
1462
|
+
* 内部所有引用旧 uid 的地方(如 parentId, parentUid 等)都会被替换为对应的新 uid。
|
|
1463
|
+
* @returns {FlowModel} 复制后的新模型实例
|
|
1464
|
+
*/
|
|
1465
|
+
clone<T extends FlowModel = this>(): T {
|
|
1466
|
+
if (!this.flowEngine) {
|
|
1467
|
+
throw new Error('FlowEngine is not set on this model. Please set flowEngine before cloning.');
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// 序列化当前实例
|
|
1471
|
+
const serialized = this.serialize();
|
|
1472
|
+
|
|
1473
|
+
// 第一步:收集所有 uid 并建立 oldUid -> newUid 的映射
|
|
1474
|
+
const uidMap = new Map<string, string>();
|
|
1475
|
+
|
|
1476
|
+
const collectUids = (data: Record<string, any>): void => {
|
|
1477
|
+
if (data.uid && typeof data.uid === 'string') {
|
|
1478
|
+
uidMap.set(data.uid, uid());
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// 递归处理 subModels
|
|
1482
|
+
if (data.subModels) {
|
|
1483
|
+
for (const key in data.subModels) {
|
|
1484
|
+
const subModel = data.subModels[key];
|
|
1485
|
+
if (Array.isArray(subModel)) {
|
|
1486
|
+
subModel.forEach((item) => collectUids(item));
|
|
1487
|
+
} else if (subModel && typeof subModel === 'object') {
|
|
1488
|
+
collectUids(subModel);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
};
|
|
1493
|
+
|
|
1494
|
+
collectUids(serialized);
|
|
1495
|
+
|
|
1496
|
+
// 第二步:深度遍历并替换所有 uid 引用
|
|
1497
|
+
const replaceUidReferences = (data: any, isRoot = false): any => {
|
|
1498
|
+
if (data === null || data === undefined) {
|
|
1499
|
+
return data;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// 如果是字符串,检查是否是需要替换的 uid
|
|
1503
|
+
if (typeof data === 'string') {
|
|
1504
|
+
return uidMap.get(data) ?? data;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
// 如果是数组,递归处理每个元素
|
|
1508
|
+
if (Array.isArray(data)) {
|
|
1509
|
+
return data.map((item) => replaceUidReferences(item, false));
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// 如果是对象,递归处理每个属性
|
|
1513
|
+
if (typeof data === 'object') {
|
|
1514
|
+
const result: Record<string, any> = {};
|
|
1515
|
+
|
|
1516
|
+
for (const key in data) {
|
|
1517
|
+
if (!Object.prototype.hasOwnProperty.call(data, key)) continue;
|
|
1518
|
+
|
|
1519
|
+
// 只删除 root model 的 parentId
|
|
1520
|
+
if (isRoot && key === 'parentId') {
|
|
1521
|
+
continue;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
result[key] = replaceUidReferences(data[key], false);
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
return result;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
// 其他类型(number, boolean 等)直接返回
|
|
1531
|
+
return data;
|
|
1532
|
+
};
|
|
1533
|
+
|
|
1534
|
+
const clonedData = replaceUidReferences(serialized, true);
|
|
1535
|
+
|
|
1536
|
+
// 使用 flowEngine 创建新实例
|
|
1537
|
+
return this.flowEngine.createModel<T>(clonedData as any);
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1447
1540
|
/**
|
|
1448
1541
|
* Opens the flow settings dialog for this flow model.
|
|
1449
1542
|
* @param options - Configuration options for opening flow settings, excluding the model property
|
package/src/provider.tsx
CHANGED
|
@@ -58,18 +58,19 @@ export const FlowEngineGlobalsContextProvider: React.FC<{ children: React.ReactN
|
|
|
58
58
|
cache: false,
|
|
59
59
|
get: (ctx) => new FlowViewer(ctx, { drawer, embed, popover, dialog }),
|
|
60
60
|
});
|
|
61
|
-
// 将 themeToken 定义为 observable, 使组件能够响应主题的变更
|
|
62
|
-
engine.context.defineProperty('themeToken', {
|
|
63
|
-
get: () => token,
|
|
64
|
-
observable: true,
|
|
65
|
-
cache: true,
|
|
66
|
-
});
|
|
67
61
|
for (const item of Object.entries(context)) {
|
|
68
62
|
const [key, value] = item;
|
|
69
63
|
if (value) {
|
|
70
64
|
engine.context.defineProperty(key, { value });
|
|
71
65
|
}
|
|
72
66
|
}
|
|
67
|
+
// 将 themeToken 定义为 observable, 使组件能够响应主题的变更
|
|
68
|
+
// NOTE: 必须在 antdConfig 写入后再更新 themeToken;否则会读取到旧 antdConfig 的值。
|
|
69
|
+
engine.context.defineProperty('themeToken', {
|
|
70
|
+
get: () => token,
|
|
71
|
+
observable: true,
|
|
72
|
+
cache: true,
|
|
73
|
+
});
|
|
73
74
|
engine.reactView.refresh();
|
|
74
75
|
}, [engine, drawer, modal, message, notification, config, popover, token, dialog, embed]);
|
|
75
76
|
|
|
@@ -90,9 +91,10 @@ export const useFlowEngine = ({ throwError = true } = {}): FlowEngine => {
|
|
|
90
91
|
if (!context && throwError) {
|
|
91
92
|
// This error should ideally not be hit if FlowEngineProvider is used correctly at the root
|
|
92
93
|
// and always supplied with an engine.
|
|
93
|
-
|
|
94
|
+
console.warn(
|
|
94
95
|
'useFlowEngine must be used within a FlowEngineProvider, and FlowEngineProvider must be supplied with an engine.',
|
|
95
96
|
);
|
|
97
|
+
return;
|
|
96
98
|
}
|
|
97
99
|
return context;
|
|
98
100
|
};
|
|
@@ -0,0 +1,44 @@
|
|
|
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, vi } from 'vitest';
|
|
11
|
+
import { FlowEngine } from '../../flowEngine';
|
|
12
|
+
import { MultiRecordResource } from '../multiRecordResource';
|
|
13
|
+
|
|
14
|
+
function createMultiRecordResource() {
|
|
15
|
+
const engine = new FlowEngine();
|
|
16
|
+
return engine.createResource(MultiRecordResource);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('MultiRecordResource - refresh', () => {
|
|
20
|
+
it('should coalesce multiple refresh calls and settle all awaiters', async () => {
|
|
21
|
+
vi.useFakeTimers();
|
|
22
|
+
try {
|
|
23
|
+
const r = createMultiRecordResource();
|
|
24
|
+
const api = {
|
|
25
|
+
request: vi.fn().mockResolvedValue({
|
|
26
|
+
data: { data: [], meta: { count: 0, page: 1, pageSize: 20 } },
|
|
27
|
+
}),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
r.setAPIClient(api as any);
|
|
31
|
+
r.setResourceName('posts');
|
|
32
|
+
|
|
33
|
+
const p1 = r.refresh();
|
|
34
|
+
const p2 = r.refresh();
|
|
35
|
+
|
|
36
|
+
await vi.runAllTimersAsync();
|
|
37
|
+
await expect(p1).resolves.toBeUndefined();
|
|
38
|
+
await expect(p2).resolves.toBeUndefined();
|
|
39
|
+
expect(api.request).toHaveBeenCalledTimes(1);
|
|
40
|
+
} finally {
|
|
41
|
+
vi.useRealTimers();
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { FlowEngine } from '../../flowEngine';
|
|
12
|
+
import { SQLResource } from '../sqlResource';
|
|
13
|
+
|
|
14
|
+
function createSQLResource() {
|
|
15
|
+
const engine = new FlowEngine();
|
|
16
|
+
return engine.createResource(SQLResource);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('SQLResource - refresh', () => {
|
|
20
|
+
it('should coalesce multiple refresh calls and settle all awaiters', async () => {
|
|
21
|
+
vi.useFakeTimers();
|
|
22
|
+
try {
|
|
23
|
+
const r = createSQLResource();
|
|
24
|
+
const run = vi.fn().mockResolvedValue({ data: { ok: 1 }, meta: { page: 1 } });
|
|
25
|
+
(r as any).run = run;
|
|
26
|
+
|
|
27
|
+
const p1 = r.refresh();
|
|
28
|
+
const p2 = r.refresh();
|
|
29
|
+
|
|
30
|
+
await vi.runAllTimersAsync();
|
|
31
|
+
await expect(p1).resolves.toBeUndefined();
|
|
32
|
+
await expect(p2).resolves.toBeUndefined();
|
|
33
|
+
expect(run).toHaveBeenCalledTimes(1);
|
|
34
|
+
} finally {
|
|
35
|
+
vi.useRealTimers();
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should reject all awaiters on failure', async () => {
|
|
40
|
+
vi.useFakeTimers();
|
|
41
|
+
try {
|
|
42
|
+
const r = createSQLResource();
|
|
43
|
+
const run = vi.fn().mockRejectedValue(new Error('boom'));
|
|
44
|
+
(r as any).run = run;
|
|
45
|
+
|
|
46
|
+
const p1 = r.refresh();
|
|
47
|
+
const p2 = r.refresh();
|
|
48
|
+
|
|
49
|
+
// Attach handlers before timers run to avoid unhandled rejection warnings.
|
|
50
|
+
const e1 = expect(p1).rejects.toThrow('boom');
|
|
51
|
+
const e2 = expect(p2).rejects.toThrow('boom');
|
|
52
|
+
await vi.runAllTimersAsync();
|
|
53
|
+
await e1;
|
|
54
|
+
await e2;
|
|
55
|
+
expect(run).toHaveBeenCalledTimes(1);
|
|
56
|
+
} finally {
|
|
57
|
+
vi.useRealTimers();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|