@nocobase/flow-engine 2.1.0-beta.14 → 2.1.0-beta.16
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/components/FlowModelRenderer.js +10 -6
- package/lib/components/MobilePopup.js +6 -5
- package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +13 -5
- package/lib/components/subModel/AddSubModelButton.js +1 -1
- package/lib/components/subModel/utils.js +2 -2
- package/lib/flowEngine.d.ts +132 -1
- package/lib/flowEngine.js +360 -14
- package/lib/flowSettings.d.ts +14 -6
- package/lib/flowSettings.js +34 -6
- package/lib/lazy-helper.d.ts +14 -0
- package/lib/lazy-helper.js +71 -0
- package/lib/models/flowModel.d.ts +2 -1
- package/lib/models/flowModel.js +28 -9
- package/lib/types.d.ts +46 -0
- package/lib/utils/runjsTemplateCompat.js +1 -1
- package/package.json +4 -4
- package/src/__tests__/flow-engine.test.ts +166 -0
- package/src/__tests__/flowEngine.modelLoaders.test.ts +245 -0
- package/src/__tests__/flowSettings.test.ts +94 -15
- package/src/__tests__/renderHiddenInConfig.test.tsx +6 -6
- package/src/__tests__/viewScopedFlowEngine.test.ts +3 -3
- package/src/components/FlowModelRenderer.tsx +9 -5
- package/src/components/MobilePopup.tsx +4 -2
- package/src/components/__tests__/FlowModelRenderer.test.tsx +65 -2
- package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +3 -3
- package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +15 -4
- package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +67 -5
- package/src/components/subModel/AddSubModelButton.tsx +1 -1
- package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +93 -33
- package/src/components/subModel/utils.ts +1 -1
- package/src/flowEngine.ts +412 -10
- package/src/flowSettings.ts +40 -6
- package/src/lazy-helper.tsx +57 -0
- package/src/models/flowModel.tsx +31 -10
- package/src/types.ts +59 -0
- package/src/utils/runjsTemplateCompat.ts +1 -1
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
11
|
+
import React from 'react';
|
|
12
|
+
import { createForm } from '@formily/core';
|
|
13
|
+
import { createSchemaField, FormProvider } from '@formily/react';
|
|
14
|
+
import { render, screen } from '@testing-library/react';
|
|
11
15
|
import { FlowSettings } from '../flowSettings';
|
|
12
16
|
import { DefaultSettingsIcon } from '../components/settings/wrappers/contextual/DefaultSettingsIcon';
|
|
13
17
|
import { FlowModel } from '../models';
|
|
@@ -142,10 +146,10 @@ describe('FlowSettings', () => {
|
|
|
142
146
|
expect(settingsItem?.sort).toBe(0);
|
|
143
147
|
});
|
|
144
148
|
|
|
145
|
-
test('should set up observable properties', () => {
|
|
149
|
+
test('should set up observable properties', async () => {
|
|
146
150
|
// Test that enabled property is reactive
|
|
147
151
|
const initialEnabled = flowSettings.enabled;
|
|
148
|
-
flowSettings.enable();
|
|
152
|
+
await flowSettings.enable();
|
|
149
153
|
expect(flowSettings.enabled).not.toBe(initialEnabled);
|
|
150
154
|
expect(flowSettings.enabled).toBe(true);
|
|
151
155
|
});
|
|
@@ -186,6 +190,43 @@ describe('FlowSettings', () => {
|
|
|
186
190
|
flowSettings.registerComponents({});
|
|
187
191
|
expect(Object.keys(flowSettings.components)).toHaveLength(0);
|
|
188
192
|
});
|
|
193
|
+
|
|
194
|
+
test('should register component loaders and load component on render', async () => {
|
|
195
|
+
const loader = vi.fn(async () => ({
|
|
196
|
+
default: () => React.createElement('div', null, 'Lazy Flow Settings Component'),
|
|
197
|
+
}));
|
|
198
|
+
|
|
199
|
+
flowSettings.registerComponentLoaders({
|
|
200
|
+
DemoFlowSettingsLazyField: loader,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(loader).not.toHaveBeenCalled();
|
|
204
|
+
|
|
205
|
+
const SchemaField = createSchemaField();
|
|
206
|
+
const form = createForm();
|
|
207
|
+
|
|
208
|
+
render(
|
|
209
|
+
React.createElement(
|
|
210
|
+
FormProvider,
|
|
211
|
+
{ form },
|
|
212
|
+
React.createElement(SchemaField, {
|
|
213
|
+
schema: {
|
|
214
|
+
type: 'object',
|
|
215
|
+
properties: {
|
|
216
|
+
demo: {
|
|
217
|
+
type: 'void',
|
|
218
|
+
'x-component': 'DemoFlowSettingsLazyField',
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
components: flowSettings.components,
|
|
223
|
+
}),
|
|
224
|
+
),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
expect(await screen.findByText('Lazy Flow Settings Component')).toBeInTheDocument();
|
|
228
|
+
expect(loader).toHaveBeenCalledTimes(1);
|
|
229
|
+
});
|
|
189
230
|
});
|
|
190
231
|
|
|
191
232
|
describe('Scope Registration', () => {
|
|
@@ -228,30 +269,68 @@ describe('FlowSettings', () => {
|
|
|
228
269
|
});
|
|
229
270
|
|
|
230
271
|
describe('Enable/Disable Functionality', () => {
|
|
231
|
-
test('should enable flow settings', () => {
|
|
272
|
+
test('should enable flow settings', async () => {
|
|
232
273
|
expect(flowSettings.enabled).toBe(false);
|
|
233
274
|
|
|
234
|
-
flowSettings.enable();
|
|
275
|
+
await flowSettings.enable();
|
|
235
276
|
|
|
236
277
|
expect(flowSettings.enabled).toBe(true);
|
|
237
278
|
});
|
|
238
279
|
|
|
239
|
-
test('should
|
|
240
|
-
|
|
280
|
+
test('should preload model loaders before enabling flow settings', async () => {
|
|
281
|
+
const preloadSpy = vi.spyOn(engine, 'preloadModelLoaders').mockResolvedValue({
|
|
282
|
+
requested: [],
|
|
283
|
+
loaded: [],
|
|
284
|
+
failed: [],
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
await flowSettings.enable();
|
|
288
|
+
|
|
289
|
+
expect(preloadSpy).toHaveBeenCalledTimes(1);
|
|
241
290
|
expect(flowSettings.enabled).toBe(true);
|
|
291
|
+
});
|
|
242
292
|
|
|
243
|
-
|
|
293
|
+
test('should preload model loaders before force enabling flow settings', async () => {
|
|
294
|
+
const preloadSpy = vi.spyOn(engine, 'preloadModelLoaders').mockResolvedValue({
|
|
295
|
+
requested: [],
|
|
296
|
+
loaded: [],
|
|
297
|
+
failed: [],
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
await flowSettings.forceEnable();
|
|
301
|
+
|
|
302
|
+
expect(preloadSpy).toHaveBeenCalledTimes(1);
|
|
303
|
+
expect(flowSettings.enabled).toBe(true);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('should disable flow settings', async () => {
|
|
307
|
+
await flowSettings.enable();
|
|
308
|
+
expect(flowSettings.enabled).toBe(true);
|
|
309
|
+
|
|
310
|
+
await flowSettings.disable();
|
|
244
311
|
|
|
245
312
|
expect(flowSettings.enabled).toBe(false);
|
|
246
313
|
});
|
|
247
314
|
|
|
248
|
-
test('should handle multiple enable/disable calls', () => {
|
|
249
|
-
flowSettings.enable();
|
|
250
|
-
flowSettings.enable();
|
|
315
|
+
test('should handle multiple enable/disable calls', async () => {
|
|
316
|
+
await flowSettings.enable();
|
|
317
|
+
await flowSettings.enable();
|
|
251
318
|
expect(flowSettings.enabled).toBe(true);
|
|
252
319
|
|
|
253
|
-
flowSettings.disable();
|
|
254
|
-
flowSettings.disable();
|
|
320
|
+
await flowSettings.disable();
|
|
321
|
+
await flowSettings.disable();
|
|
322
|
+
expect(flowSettings.enabled).toBe(false);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('forceDisable should clear force-enabled state and disable flow settings', async () => {
|
|
326
|
+
await flowSettings.forceEnable();
|
|
327
|
+
expect(flowSettings.enabled).toBe(true);
|
|
328
|
+
|
|
329
|
+
await flowSettings.forceDisable();
|
|
330
|
+
|
|
331
|
+
expect(flowSettings.enabled).toBe(false);
|
|
332
|
+
|
|
333
|
+
await flowSettings.disable();
|
|
255
334
|
expect(flowSettings.enabled).toBe(false);
|
|
256
335
|
});
|
|
257
336
|
});
|
|
@@ -512,7 +591,7 @@ describe('FlowSettings', () => {
|
|
|
512
591
|
});
|
|
513
592
|
|
|
514
593
|
describe('Complex Integration Scenarios', () => {
|
|
515
|
-
test('should maintain state consistency during multiple operations', () => {
|
|
594
|
+
test('should maintain state consistency during multiple operations', async () => {
|
|
516
595
|
// Initialize with components and scopes
|
|
517
596
|
const TestComponent = () => 'TestComponent';
|
|
518
597
|
const testScope = () => 'testScope';
|
|
@@ -528,7 +607,7 @@ describe('FlowSettings', () => {
|
|
|
528
607
|
});
|
|
529
608
|
|
|
530
609
|
// Enable/disable
|
|
531
|
-
flowSettings.enable();
|
|
610
|
+
await flowSettings.enable();
|
|
532
611
|
expect(flowSettings.enabled).toBe(true);
|
|
533
612
|
|
|
534
613
|
// Verify all state is maintained
|
|
@@ -536,7 +615,7 @@ describe('FlowSettings', () => {
|
|
|
536
615
|
expect(flowSettings.scopes.testScope).toBe(testScope);
|
|
537
616
|
expect(flowSettings.getToolbarItems().find((item) => item.key === 'integration-test')).toBeDefined();
|
|
538
617
|
|
|
539
|
-
flowSettings.disable();
|
|
618
|
+
await flowSettings.disable();
|
|
540
619
|
expect(flowSettings.enabled).toBe(false);
|
|
541
620
|
|
|
542
621
|
// State should still be maintained after disable
|
|
@@ -14,7 +14,7 @@ import { FlowEngine } from '../flowEngine';
|
|
|
14
14
|
import { FlowModel, ModelRenderMode } from '../models/flowModel';
|
|
15
15
|
|
|
16
16
|
describe('FlowModel.renderHiddenInConfig', () => {
|
|
17
|
-
it('renders via renderHiddenInConfig when hidden and config enabled (React element mode, mounted)', () => {
|
|
17
|
+
it('renders via renderHiddenInConfig when hidden and config enabled (React element mode, mounted)', async () => {
|
|
18
18
|
class ElemModel extends FlowModel {
|
|
19
19
|
render() {
|
|
20
20
|
return <div data-testid="content">Content</div>;
|
|
@@ -28,14 +28,14 @@ describe('FlowModel.renderHiddenInConfig', () => {
|
|
|
28
28
|
const model = new ElemModel({ uid: 'elem-1', flowEngine: engine });
|
|
29
29
|
|
|
30
30
|
// runtime hidden => mounted result should be empty (no content/hidden)
|
|
31
|
-
engine.flowSettings.disable();
|
|
31
|
+
await engine.flowSettings.disable();
|
|
32
32
|
model.setHidden(true);
|
|
33
33
|
const { container, unmount, rerender } = render(model.render() as React.ReactElement);
|
|
34
34
|
expect(screen.queryByTestId('content')).toBeNull();
|
|
35
35
|
expect(screen.queryByTestId('hidden')).toBeNull();
|
|
36
36
|
|
|
37
37
|
// config enabled + hidden => should show renderHiddenInConfig result
|
|
38
|
-
engine.flowSettings.enable();
|
|
38
|
+
await engine.flowSettings.enable();
|
|
39
39
|
rerender(model.render() as React.ReactElement);
|
|
40
40
|
expect(screen.getByTestId('hidden').textContent).toBe('HiddenViaAPI');
|
|
41
41
|
|
|
@@ -46,7 +46,7 @@ describe('FlowModel.renderHiddenInConfig', () => {
|
|
|
46
46
|
unmount();
|
|
47
47
|
cleanup();
|
|
48
48
|
});
|
|
49
|
-
it('returns a render function when hidden and config enabled (RenderFunction mode)', () => {
|
|
49
|
+
it('returns a render function when hidden and config enabled (RenderFunction mode)', async () => {
|
|
50
50
|
class FuncModel extends FlowModel {
|
|
51
51
|
static override renderMode = ModelRenderMode.RenderFunction;
|
|
52
52
|
render() {
|
|
@@ -63,13 +63,13 @@ describe('FlowModel.renderHiddenInConfig', () => {
|
|
|
63
63
|
const model = engine.createModel({ use: 'FuncModel' }) as FuncModel;
|
|
64
64
|
|
|
65
65
|
// runtime hidden => null
|
|
66
|
-
engine.flowSettings.disable();
|
|
66
|
+
await engine.flowSettings.disable();
|
|
67
67
|
model.setHidden(true);
|
|
68
68
|
const runtimeHidden = model.render();
|
|
69
69
|
expect(runtimeHidden).toBeNull();
|
|
70
70
|
|
|
71
71
|
// config enabled + hidden => renderHiddenInConfig (function)
|
|
72
|
-
engine.flowSettings.enable();
|
|
72
|
+
await engine.flowSettings.enable();
|
|
73
73
|
const cfgHidden = model.render();
|
|
74
74
|
expect(typeof cfgHidden).toBe('function');
|
|
75
75
|
const cellNode = (cfgHidden as any)();
|
|
@@ -256,11 +256,11 @@ describe('ViewScopedFlowEngine', () => {
|
|
|
256
256
|
|
|
257
257
|
// Both children should return null from hydration because parent has flowSettingsEnabled
|
|
258
258
|
// This is the bug fix: previously only children with their own flowSettingsEnabled would return null
|
|
259
|
-
const result1 = (scoped as any).hydrateModelFromPreviousEngines({
|
|
259
|
+
const result1 = await (scoped as any).hydrateModelFromPreviousEngines({
|
|
260
260
|
parentId: 'parent-with-settings',
|
|
261
261
|
subKey: 'popup',
|
|
262
262
|
});
|
|
263
|
-
const result2 = (scoped as any).hydrateModelFromPreviousEngines({
|
|
263
|
+
const result2 = await (scoped as any).hydrateModelFromPreviousEngines({
|
|
264
264
|
parentId: 'parent-with-settings',
|
|
265
265
|
subKey: 'items',
|
|
266
266
|
});
|
|
@@ -298,7 +298,7 @@ describe('ViewScopedFlowEngine', () => {
|
|
|
298
298
|
const scoped = createViewScopedEngine(root);
|
|
299
299
|
|
|
300
300
|
// Call the private method hydrateModelFromPreviousEngines directly
|
|
301
|
-
const result = (scoped as any).hydrateModelFromPreviousEngines({
|
|
301
|
+
const result = await (scoped as any).hydrateModelFromPreviousEngines({
|
|
302
302
|
parentId: 'parent-normal',
|
|
303
303
|
subKey: 'content',
|
|
304
304
|
});
|
|
@@ -127,6 +127,7 @@ const FlowModelRendererWithAutoFlows: React.FC<{
|
|
|
127
127
|
settingsMenuLevel?: number;
|
|
128
128
|
extraToolbarItems?: ToolbarItemConfig[];
|
|
129
129
|
fallback?: React.ReactNode;
|
|
130
|
+
useCache?: boolean;
|
|
130
131
|
}> = observer(
|
|
131
132
|
({
|
|
132
133
|
model,
|
|
@@ -139,12 +140,12 @@ const FlowModelRendererWithAutoFlows: React.FC<{
|
|
|
139
140
|
settingsMenuLevel,
|
|
140
141
|
extraToolbarItems,
|
|
141
142
|
fallback,
|
|
143
|
+
useCache,
|
|
142
144
|
}) => {
|
|
143
145
|
// hidden 占位由模型自身处理;无需在此注入
|
|
144
|
-
|
|
145
146
|
const { loading: pending, error: autoFlowsError } = useApplyAutoFlows(model, inputArgs, {
|
|
146
147
|
throwOnError: false,
|
|
147
|
-
useCache
|
|
148
|
+
useCache,
|
|
148
149
|
});
|
|
149
150
|
// 将错误下沉到 model 实例上,供内容层读取(类型安全的 WeakMap 存储)
|
|
150
151
|
setAutoFlowError(model, autoFlowsError || null);
|
|
@@ -348,13 +349,15 @@ export const FlowModelRenderer: React.FC<FlowModelRendererProps> = observer(
|
|
|
348
349
|
extraToolbarItems,
|
|
349
350
|
useCache,
|
|
350
351
|
}) => {
|
|
352
|
+
const resolvedUseCache = typeof useCache === 'boolean' ? useCache : model?.context?.useCache;
|
|
353
|
+
|
|
351
354
|
useEffect(() => {
|
|
352
|
-
if (model?.context) {
|
|
355
|
+
if (model?.context && typeof resolvedUseCache !== 'undefined') {
|
|
353
356
|
model.context.defineProperty('useCache', {
|
|
354
|
-
value:
|
|
357
|
+
value: resolvedUseCache,
|
|
355
358
|
});
|
|
356
359
|
}
|
|
357
|
-
}, [model?.context,
|
|
360
|
+
}, [model?.context, resolvedUseCache]);
|
|
358
361
|
|
|
359
362
|
if (!model || typeof model.render !== 'function') {
|
|
360
363
|
// 可以选择渲染 null 或者一个错误/提示信息
|
|
@@ -375,6 +378,7 @@ export const FlowModelRenderer: React.FC<FlowModelRendererProps> = observer(
|
|
|
375
378
|
settingsMenuLevel={settingsMenuLevel}
|
|
376
379
|
extraToolbarItems={extraToolbarItems}
|
|
377
380
|
fallback={fallback}
|
|
381
|
+
useCache={resolvedUseCache}
|
|
378
382
|
/>
|
|
379
383
|
);
|
|
380
384
|
|
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { ConfigProvider } from 'antd';
|
|
11
|
-
import { Popup } from 'antd-mobile';
|
|
12
11
|
import React, { FC, ReactNode, useMemo } from 'react';
|
|
13
|
-
import { CloseOutline } from 'antd-mobile-icons';
|
|
14
12
|
import { useMobileActionDrawerStyle } from './MobilePopup.style';
|
|
15
13
|
import { useTranslation } from 'react-i18next';
|
|
14
|
+
import { lazy } from '../lazy-helper';
|
|
15
|
+
|
|
16
|
+
const { Popup } = lazy(() => import('antd-mobile'), 'Popup');
|
|
17
|
+
const { CloseOutline } = lazy(() => import('antd-mobile-icons'), 'CloseOutline');
|
|
16
18
|
|
|
17
19
|
interface MobilePopupProps {
|
|
18
20
|
title?: string;
|
|
@@ -38,9 +38,8 @@ describe('FlowModelRenderer', () => {
|
|
|
38
38
|
test('should pass useCache to useApplyAutoFlows and set it on context', async () => {
|
|
39
39
|
const { unmount } = renderWithProvider(<FlowModelRenderer model={model} useCache={true} />);
|
|
40
40
|
|
|
41
|
-
// Check if dispatchEvent was called with useCache: true
|
|
42
|
-
// useApplyAutoFlows calls dispatchEvent('beforeRender', inputArgs, { useCache })
|
|
43
41
|
await waitFor(() => {
|
|
42
|
+
expect(model.dispatchEvent).toHaveBeenCalledTimes(1);
|
|
44
43
|
expect(model.dispatchEvent).toHaveBeenCalledWith(
|
|
45
44
|
'beforeRender',
|
|
46
45
|
undefined,
|
|
@@ -58,6 +57,7 @@ describe('FlowModelRenderer', () => {
|
|
|
58
57
|
const { unmount } = renderWithProvider(<FlowModelRenderer model={model} useCache={false} />);
|
|
59
58
|
|
|
60
59
|
await waitFor(() => {
|
|
60
|
+
expect(model.dispatchEvent).toHaveBeenCalledTimes(1);
|
|
61
61
|
expect(model.dispatchEvent).toHaveBeenCalledWith(
|
|
62
62
|
'beforeRender',
|
|
63
63
|
undefined,
|
|
@@ -74,6 +74,7 @@ describe('FlowModelRenderer', () => {
|
|
|
74
74
|
const { unmount } = renderWithProvider(<FlowModelRenderer model={model} />);
|
|
75
75
|
|
|
76
76
|
await waitFor(() => {
|
|
77
|
+
expect(model.dispatchEvent).toHaveBeenCalledTimes(1);
|
|
77
78
|
expect(model.dispatchEvent).toHaveBeenCalledWith(
|
|
78
79
|
'beforeRender',
|
|
79
80
|
undefined,
|
|
@@ -86,4 +87,66 @@ describe('FlowModelRenderer', () => {
|
|
|
86
87
|
|
|
87
88
|
unmount();
|
|
88
89
|
});
|
|
90
|
+
|
|
91
|
+
test('should clear stale beforeRender state after unmount when reusing the same model', async () => {
|
|
92
|
+
const statefulEngine = new FlowEngine();
|
|
93
|
+
const onMountSpy = vi.fn();
|
|
94
|
+
const onUnmountSpy = vi.fn();
|
|
95
|
+
|
|
96
|
+
class StatefulModel extends FlowModel {
|
|
97
|
+
render(): any {
|
|
98
|
+
return <div>Stateful Content</div>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
protected onMount(): void {
|
|
102
|
+
onMountSpy();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
protected onUnmount(): void {
|
|
106
|
+
onUnmountSpy();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const statefulModel = new StatefulModel({
|
|
111
|
+
uid: 'stateful-model',
|
|
112
|
+
flowEngine: statefulEngine,
|
|
113
|
+
});
|
|
114
|
+
const executorSpy = vi.spyOn((statefulEngine as any).executor, 'dispatchEvent').mockResolvedValue([]);
|
|
115
|
+
|
|
116
|
+
const firstRender = renderWithProvider(<FlowModelRenderer model={statefulModel} />);
|
|
117
|
+
await waitFor(() => {
|
|
118
|
+
expect(executorSpy).toHaveBeenCalledTimes(1);
|
|
119
|
+
});
|
|
120
|
+
await waitFor(() => {
|
|
121
|
+
expect(onMountSpy).toHaveBeenCalledTimes(1);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
firstRender.unmount();
|
|
125
|
+
await waitFor(() => {
|
|
126
|
+
expect(onUnmountSpy).toHaveBeenCalledTimes(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
executorSpy.mockClear();
|
|
130
|
+
statefulModel.setStepParams('anyFlow', 'anyStep', { x: 1 });
|
|
131
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
132
|
+
expect(executorSpy.mock.calls.length).toBe(0);
|
|
133
|
+
|
|
134
|
+
const secondRender = renderWithProvider(<FlowModelRenderer model={statefulModel} />);
|
|
135
|
+
await waitFor(() => {
|
|
136
|
+
expect(executorSpy).toHaveBeenCalledTimes(1);
|
|
137
|
+
});
|
|
138
|
+
await waitFor(() => {
|
|
139
|
+
expect(onMountSpy).toHaveBeenCalledTimes(2);
|
|
140
|
+
});
|
|
141
|
+
const [target, eventName, inputArgs, options] = executorSpy.mock.calls[0];
|
|
142
|
+
expect(target).toBe(statefulModel);
|
|
143
|
+
expect(eventName).toBe('beforeRender');
|
|
144
|
+
expect(inputArgs).toBeUndefined();
|
|
145
|
+
expect(options).toMatchObject({ useCache: true });
|
|
146
|
+
|
|
147
|
+
secondRender.unmount();
|
|
148
|
+
await waitFor(() => {
|
|
149
|
+
expect(onUnmountSpy).toHaveBeenCalledTimes(2);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
89
152
|
});
|
|
@@ -124,7 +124,7 @@ describe('Delete problematic model via FlowSettings menu', () => {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
const engine = new FlowEngine();
|
|
127
|
-
engine.flowSettings.forceEnable();
|
|
127
|
+
await engine.flowSettings.forceEnable();
|
|
128
128
|
engine.registerModels({ BrokenModel });
|
|
129
129
|
const model = engine.createModel({ use: 'BrokenModel', uid: 'broken-top-2' }) as BrokenModel;
|
|
130
130
|
// satisfy FlowsFloatContextMenu styles
|
|
@@ -164,7 +164,7 @@ describe('Delete problematic model via FlowSettings menu', () => {
|
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
const engine = new FlowEngine();
|
|
167
|
-
engine.flowSettings.forceEnable();
|
|
167
|
+
await engine.flowSettings.forceEnable();
|
|
168
168
|
engine.registerModels({ ParentModel, BrokenChild });
|
|
169
169
|
const parent = engine.createModel({ use: 'ParentModel', uid: 'parent-3' }) as ParentModel;
|
|
170
170
|
const child = engine.createModel({ use: 'BrokenChild', uid: 'child-3' }) as BrokenChild;
|
|
@@ -210,7 +210,7 @@ describe('Delete problematic model via FlowSettings menu', () => {
|
|
|
210
210
|
}
|
|
211
211
|
|
|
212
212
|
const engine = new FlowEngine();
|
|
213
|
-
engine.flowSettings.forceEnable();
|
|
213
|
+
await engine.flowSettings.forceEnable();
|
|
214
214
|
engine.registerModels({ ParentModel, RenderFnChild });
|
|
215
215
|
const parent = engine.createModel({ use: 'ParentModel', uid: 'parent-4' }) as ParentModel;
|
|
216
216
|
const child = engine.createModel({ use: 'RenderFnChild', uid: 'cell-4' }) as RenderFnChild;
|
|
@@ -70,6 +70,15 @@ interface BaseFloatContextMenuProps {
|
|
|
70
70
|
toolbarPosition?: ToolbarPosition;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
const getFloatMenuInstanceId = (model?: FlowModel | null) => {
|
|
74
|
+
if (!model) {
|
|
75
|
+
return '';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const forkId = (model as any)?.isFork ? (model as any)?.forkId : undefined;
|
|
79
|
+
return forkId == null || forkId === '' ? String(model.uid || '') : `${String(model.uid || '')}::${String(forkId)}`;
|
|
80
|
+
};
|
|
81
|
+
|
|
73
82
|
const hostContainerStyles = css`
|
|
74
83
|
position: relative;
|
|
75
84
|
|
|
@@ -279,6 +288,7 @@ const detectButtonInDOM = (container: HTMLElement): boolean => {
|
|
|
279
288
|
// 渲染工具栏项目,并让设置菜单与工具栏共享同一个 popup 容器。
|
|
280
289
|
const renderToolbarItems = (
|
|
281
290
|
model: FlowModel,
|
|
291
|
+
modelInstanceId: string,
|
|
282
292
|
showDeleteButton: boolean,
|
|
283
293
|
showCopyUidButton: boolean,
|
|
284
294
|
flowEngine: FlowEngine,
|
|
@@ -304,7 +314,7 @@ const renderToolbarItems = (
|
|
|
304
314
|
<ItemComponent
|
|
305
315
|
key={itemConfig.key}
|
|
306
316
|
model={model}
|
|
307
|
-
id={
|
|
317
|
+
id={modelInstanceId}
|
|
308
318
|
showDeleteButton={showDeleteButton}
|
|
309
319
|
showCopyUidButton={showCopyUidButton}
|
|
310
320
|
menuLevels={settingsMenuLevel}
|
|
@@ -517,7 +527,7 @@ const FlowsFloatContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
|
|
|
517
527
|
updatePortalRect: () => {},
|
|
518
528
|
schedulePortalRectUpdate: () => {},
|
|
519
529
|
});
|
|
520
|
-
const modelUid = model
|
|
530
|
+
const modelUid = getFloatMenuInstanceId(model);
|
|
521
531
|
const flowEngine = useFlowEngine();
|
|
522
532
|
const updatePortalRectProxy = useCallback(() => {
|
|
523
533
|
portalActionsRef.current.updatePortalRect();
|
|
@@ -559,6 +569,7 @@ const FlowsFloatContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
|
|
|
559
569
|
model
|
|
560
570
|
? renderToolbarItems(
|
|
561
571
|
model,
|
|
572
|
+
modelUid,
|
|
562
573
|
showDeleteButton,
|
|
563
574
|
showCopyUidButton,
|
|
564
575
|
flowEngine,
|
|
@@ -629,7 +640,7 @@ const FlowsFloatContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
|
|
|
629
640
|
ref={toolbarContainerRef}
|
|
630
641
|
className={`nb-toolbar-container ${toolbarContainerClassName}`}
|
|
631
642
|
style={toolbarContainerStyle}
|
|
632
|
-
data-model-uid={
|
|
643
|
+
data-model-uid={modelUid}
|
|
633
644
|
>
|
|
634
645
|
{showTitle && (model.title || model.extraTitle) && (
|
|
635
646
|
<div className="nb-toolbar-container-title">
|
|
@@ -668,7 +679,7 @@ const FlowsFloatContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
|
|
|
668
679
|
className={`${hostContainerStyles} ${hasButton ? 'has-button-child' : ''} ${className || ''}`}
|
|
669
680
|
style={containerStyle}
|
|
670
681
|
data-has-float-menu="true"
|
|
671
|
-
data-float-menu-model-uid={
|
|
682
|
+
data-float-menu-model-uid={modelUid}
|
|
672
683
|
onMouseMove={handleChildHover}
|
|
673
684
|
onMouseEnter={handleHostMouseEnter}
|
|
674
685
|
onMouseLeave={handleHostMouseLeave}
|
package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx
CHANGED
|
@@ -192,7 +192,7 @@ describe('FlowsFloatContextMenu', () => {
|
|
|
192
192
|
|
|
193
193
|
it('defaults to portal into app container and keeps toolbar visible while moving from host to toolbar', async () => {
|
|
194
194
|
const engine = new FlowEngine();
|
|
195
|
-
engine.flowSettings.forceEnable();
|
|
195
|
+
await engine.flowSettings.forceEnable();
|
|
196
196
|
const model = createModel(engine, 'portal-model');
|
|
197
197
|
const appContainer = createAppContainer();
|
|
198
198
|
appContainer.scrollTop = 8;
|
|
@@ -254,7 +254,7 @@ describe('FlowsFloatContextMenu', () => {
|
|
|
254
254
|
|
|
255
255
|
it('renders through FlowModelRenderer with app-container portal and keeps toolbar pinned while dropdown is open', async () => {
|
|
256
256
|
const engine = new FlowEngine();
|
|
257
|
-
engine.flowSettings.forceEnable();
|
|
257
|
+
await engine.flowSettings.forceEnable();
|
|
258
258
|
const model = createModel(engine, 'renderer-model');
|
|
259
259
|
const appContainer = createAppContainer();
|
|
260
260
|
mockRect(appContainer, { top: 40, left: 60, width: 1200, height: 800 });
|
|
@@ -316,7 +316,7 @@ describe('FlowsFloatContextMenu', () => {
|
|
|
316
316
|
|
|
317
317
|
it('portals field toolbar to the nearest popup root and treats inset values as rect adjustments', async () => {
|
|
318
318
|
const engine = new FlowEngine();
|
|
319
|
-
engine.flowSettings.forceEnable();
|
|
319
|
+
await engine.flowSettings.forceEnable();
|
|
320
320
|
const model = createModel(engine, 'field-model');
|
|
321
321
|
model.render = vi.fn().mockReturnValue(<input data-testid="field-input" />);
|
|
322
322
|
const insetModel = createModel(engine, 'field-inset-model');
|
|
@@ -400,7 +400,7 @@ describe('FlowsFloatContextMenu', () => {
|
|
|
400
400
|
|
|
401
401
|
it('hides parent toolbar when hovering a nested child host', async () => {
|
|
402
402
|
const engine = new FlowEngine();
|
|
403
|
-
engine.flowSettings.forceEnable();
|
|
403
|
+
await engine.flowSettings.forceEnable();
|
|
404
404
|
const parentModel = createModel(engine, 'parent-model');
|
|
405
405
|
const childModel = createModel(engine, 'child-model');
|
|
406
406
|
const appContainer = createAppContainer();
|
|
@@ -470,7 +470,7 @@ describe('FlowsFloatContextMenu', () => {
|
|
|
470
470
|
|
|
471
471
|
it('restores parent toolbar after leaving a child toolbar back into the parent block', async () => {
|
|
472
472
|
const engine = new FlowEngine();
|
|
473
|
-
engine.flowSettings.forceEnable();
|
|
473
|
+
await engine.flowSettings.forceEnable();
|
|
474
474
|
const parentModel = createModel(engine, 'parent-restore-model');
|
|
475
475
|
const childModel = createModel(engine, 'child-restore-model');
|
|
476
476
|
const appContainer = createAppContainer();
|
|
@@ -544,4 +544,66 @@ describe('FlowsFloatContextMenu', () => {
|
|
|
544
544
|
expect(parentOverlayAfterRestore?.className).toContain('nb-toolbar-visible');
|
|
545
545
|
});
|
|
546
546
|
});
|
|
547
|
+
|
|
548
|
+
it('treats forked models as distinct float menu instances even when they share the same uid', async () => {
|
|
549
|
+
const engine = new FlowEngine();
|
|
550
|
+
await engine.flowSettings.forceEnable();
|
|
551
|
+
const masterModel = new FlowModel({ uid: 'forked-model', flowEngine: engine });
|
|
552
|
+
masterModel.context.defineProperty('themeToken', { value: { borderRadiusLG: 8 } });
|
|
553
|
+
masterModel.render = vi.fn(function (this: any) {
|
|
554
|
+
return <div data-testid={`content-${String(this.forkId || this.uid)}`}>{String(this.forkId || this.uid)}</div>;
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const firstFork = masterModel.createFork({}, 'card-1') as FlowModel & { forkId?: string };
|
|
558
|
+
const secondFork = masterModel.createFork({}, 'card-2') as FlowModel & { forkId?: string };
|
|
559
|
+
const firstInstanceId = `forked-model::${String((firstFork as any).forkId)}`;
|
|
560
|
+
const secondInstanceId = `forked-model::${String((secondFork as any).forkId)}`;
|
|
561
|
+
const appContainer = createAppContainer();
|
|
562
|
+
mockRect(appContainer, { top: 0, left: 0, width: 1280, height: 900 });
|
|
563
|
+
|
|
564
|
+
const { getByTestId } = renderWithProviders(
|
|
565
|
+
engine,
|
|
566
|
+
<>
|
|
567
|
+
<FlowsFloatContextMenu model={firstFork}>
|
|
568
|
+
<div data-testid="fork-host-1">first</div>
|
|
569
|
+
</FlowsFloatContextMenu>
|
|
570
|
+
<FlowsFloatContextMenu model={secondFork}>
|
|
571
|
+
<div data-testid="fork-host-2">second</div>
|
|
572
|
+
</FlowsFloatContextMenu>
|
|
573
|
+
</>,
|
|
574
|
+
{ container: appContainer },
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
const firstHost = getHost(getByTestId('fork-host-1'));
|
|
578
|
+
const secondHost = getHost(getByTestId('fork-host-2'));
|
|
579
|
+
mockRect(firstHost, { top: 20, left: 20, width: 180, height: 72 });
|
|
580
|
+
mockRect(secondHost, { top: 120, left: 20, width: 180, height: 72 });
|
|
581
|
+
|
|
582
|
+
fireEvent.mouseEnter(firstHost);
|
|
583
|
+
|
|
584
|
+
const firstOverlay = await waitFor(() => {
|
|
585
|
+
const nextOverlay = queryOverlay(appContainer, firstInstanceId);
|
|
586
|
+
expect(nextOverlay).toBeTruthy();
|
|
587
|
+
return nextOverlay as HTMLDivElement;
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
await waitFor(() => {
|
|
591
|
+
expect(within(firstOverlay).getByLabelText('flows-settings')).toBeTruthy();
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
fireEvent.mouseEnter(secondHost);
|
|
595
|
+
|
|
596
|
+
const secondOverlay = await waitFor(() => {
|
|
597
|
+
const nextOverlay = queryOverlay(appContainer, secondInstanceId);
|
|
598
|
+
expect(nextOverlay).toBeTruthy();
|
|
599
|
+
return nextOverlay as HTMLDivElement;
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
await waitFor(() => {
|
|
603
|
+
expect(within(secondOverlay).getByLabelText('flows-settings')).toBeTruthy();
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
expect(firstOverlay.getAttribute('data-model-uid')).toBe(firstInstanceId);
|
|
607
|
+
expect(secondOverlay.getAttribute('data-model-uid')).toBe(secondInstanceId);
|
|
608
|
+
});
|
|
547
609
|
});
|
|
@@ -610,7 +610,7 @@ const AddSubModelButtonCore = function AddSubModelButton({
|
|
|
610
610
|
let addedModel: FlowModel | undefined;
|
|
611
611
|
|
|
612
612
|
try {
|
|
613
|
-
addedModel = model.flowEngine.
|
|
613
|
+
addedModel = await model.flowEngine.createModelAsync({
|
|
614
614
|
..._.cloneDeep(createOpts),
|
|
615
615
|
parentId: model.uid,
|
|
616
616
|
subKey: subModelKey,
|