@nocobase/flow-engine 2.1.0-beta.10 → 2.1.0-beta.12
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.d.ts +1 -1
- package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.d.ts +3 -0
- package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +48 -9
- package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.d.ts +19 -43
- package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +332 -296
- package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.d.ts +36 -0
- package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.js +272 -0
- package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.d.ts +30 -0
- package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +247 -0
- package/lib/components/subModel/AddSubModelButton.js +11 -0
- package/lib/flowContext.js +27 -0
- package/lib/runjs-context/setup.js +1 -0
- package/lib/runjs-context/snippets/index.js +13 -2
- package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.d.ts +11 -0
- package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.js +50 -0
- package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.d.ts +11 -0
- package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.js +54 -0
- package/package.json +5 -4
- package/src/__tests__/flowContext.test.ts +65 -1
- package/src/__tests__/runjsContext.test.ts +3 -0
- package/src/__tests__/runjsContextRuntime.test.ts +2 -0
- package/src/__tests__/runjsSnippets.test.ts +21 -0
- package/src/components/FlowModelRenderer.tsx +3 -1
- package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +17 -7
- package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +63 -9
- package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +457 -440
- package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +95 -0
- package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +547 -0
- package/src/components/settings/wrappers/contextual/useFloatToolbarPortal.ts +358 -0
- package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +281 -0
- package/src/components/subModel/AddSubModelButton.tsx +15 -1
- package/src/flowContext.ts +30 -0
- package/src/runjs-context/setup.ts +1 -0
- package/src/runjs-context/snippets/index.ts +12 -1
- package/src/runjs-context/snippets/scene/detail/set-field-style.snippet.ts +30 -0
- package/src/runjs-context/snippets/scene/table/set-cell-style.snippet.ts +34 -0
|
@@ -37,6 +37,7 @@ vi.mock('antd', async (importOriginal) => {
|
|
|
37
37
|
(globalThis as any).__lastDropdownMenu = props.menu;
|
|
38
38
|
(globalThis as any).__lastDropdownOnOpenChange = props.onOpenChange;
|
|
39
39
|
(globalThis as any).__lastDropdownOpen = props.open;
|
|
40
|
+
(globalThis as any).__lastDropdownGetPopupContainer = props.getPopupContainer;
|
|
40
41
|
dropdownMenus.push(props.menu);
|
|
41
42
|
return React.createElement('span', { 'data-testid': 'dropdown' }, props.children);
|
|
42
43
|
};
|
|
@@ -132,6 +133,7 @@ describe('DefaultSettingsIcon - only static flows are shown', () => {
|
|
|
132
133
|
(globalThis as any).__lastDropdownMenu = undefined;
|
|
133
134
|
(globalThis as any).__lastDropdownOnOpenChange = undefined;
|
|
134
135
|
(globalThis as any).__lastDropdownOpen = undefined;
|
|
136
|
+
(globalThis as any).__lastDropdownGetPopupContainer = undefined;
|
|
135
137
|
});
|
|
136
138
|
|
|
137
139
|
afterEach(() => {
|
|
@@ -414,6 +416,99 @@ describe('DefaultSettingsIcon - only static flows are shown', () => {
|
|
|
414
416
|
});
|
|
415
417
|
});
|
|
416
418
|
|
|
419
|
+
it('prefers the local toolbar container as popup host inside contextual toolbars', async () => {
|
|
420
|
+
class TestFlowModel extends FlowModel {}
|
|
421
|
+
const engine = new FlowEngine();
|
|
422
|
+
const model = new TestFlowModel({ uid: 'm-toolbar-popup-host', flowEngine: engine });
|
|
423
|
+
const externalPopupRoot = document.createElement('div');
|
|
424
|
+
externalPopupRoot.id = 'external-popup-root';
|
|
425
|
+
document.body.appendChild(externalPopupRoot);
|
|
426
|
+
|
|
427
|
+
TestFlowModel.registerFlow({
|
|
428
|
+
key: 'flowPopupHost',
|
|
429
|
+
title: 'Flow Popup Host',
|
|
430
|
+
steps: {
|
|
431
|
+
general: { title: 'General', uiSchema: { f: { type: 'string', 'x-component': 'Input' } } },
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
const { getByTestId, unmount } = render(
|
|
436
|
+
React.createElement(
|
|
437
|
+
ConfigProvider as any,
|
|
438
|
+
null,
|
|
439
|
+
React.createElement(
|
|
440
|
+
App as any,
|
|
441
|
+
null,
|
|
442
|
+
React.createElement(
|
|
443
|
+
'div',
|
|
444
|
+
{ className: 'nb-toolbar-container' },
|
|
445
|
+
React.createElement(
|
|
446
|
+
'div',
|
|
447
|
+
{ className: 'nb-toolbar-container-icons' },
|
|
448
|
+
React.createElement(DefaultSettingsIcon as any, {
|
|
449
|
+
model,
|
|
450
|
+
getPopupContainer: () => externalPopupRoot,
|
|
451
|
+
}),
|
|
452
|
+
),
|
|
453
|
+
),
|
|
454
|
+
),
|
|
455
|
+
),
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
await waitFor(() => {
|
|
459
|
+
expect((globalThis as any).__lastDropdownGetPopupContainer).toBeTruthy();
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const popupContainer = (globalThis as any).__lastDropdownGetPopupContainer?.(getByTestId('dropdown'));
|
|
463
|
+
expect(popupContainer).toBeTruthy();
|
|
464
|
+
expect(popupContainer?.className).toContain('nb-toolbar-container-icons');
|
|
465
|
+
|
|
466
|
+
unmount();
|
|
467
|
+
externalPopupRoot.remove();
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('falls back to the provided popup host outside contextual toolbars', async () => {
|
|
471
|
+
class TestFlowModel extends FlowModel {}
|
|
472
|
+
const engine = new FlowEngine();
|
|
473
|
+
const model = new TestFlowModel({ uid: 'm-external-popup-host', flowEngine: engine });
|
|
474
|
+
const externalPopupRoot = document.createElement('div');
|
|
475
|
+
externalPopupRoot.id = 'external-popup-root';
|
|
476
|
+
document.body.appendChild(externalPopupRoot);
|
|
477
|
+
|
|
478
|
+
TestFlowModel.registerFlow({
|
|
479
|
+
key: 'flowExternalPopupHost',
|
|
480
|
+
title: 'Flow External Popup Host',
|
|
481
|
+
steps: {
|
|
482
|
+
general: { title: 'General', uiSchema: { f: { type: 'string', 'x-component': 'Input' } } },
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
const { getByTestId, unmount } = render(
|
|
487
|
+
React.createElement(
|
|
488
|
+
ConfigProvider as any,
|
|
489
|
+
null,
|
|
490
|
+
React.createElement(
|
|
491
|
+
App as any,
|
|
492
|
+
null,
|
|
493
|
+
React.createElement(DefaultSettingsIcon as any, {
|
|
494
|
+
model,
|
|
495
|
+
getPopupContainer: () => externalPopupRoot,
|
|
496
|
+
}),
|
|
497
|
+
),
|
|
498
|
+
),
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
await waitFor(() => {
|
|
502
|
+
expect((globalThis as any).__lastDropdownGetPopupContainer).toBeTruthy();
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
const popupContainer = (globalThis as any).__lastDropdownGetPopupContainer?.(getByTestId('dropdown'));
|
|
506
|
+
expect(popupContainer).toBe(externalPopupRoot);
|
|
507
|
+
|
|
508
|
+
unmount();
|
|
509
|
+
externalPopupRoot.remove();
|
|
510
|
+
});
|
|
511
|
+
|
|
417
512
|
it('copy UID action writes model uid to clipboard', async () => {
|
|
418
513
|
class TestFlowModel extends FlowModel {}
|
|
419
514
|
const engine = new FlowEngine();
|
|
@@ -0,0 +1,547 @@
|
|
|
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 React from 'react';
|
|
11
|
+
import { cleanup, fireEvent, render, waitFor, within } from '@testing-library/react';
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
13
|
+
import { App, ConfigProvider } from 'antd';
|
|
14
|
+
import { FlowEngine } from '../../../../../flowEngine';
|
|
15
|
+
import { FlowModel } from '../../../../../models/flowModel';
|
|
16
|
+
import { FlowEngineProvider } from '../../../../../provider';
|
|
17
|
+
import { FieldModelRenderer } from '../../../../FieldModelRenderer';
|
|
18
|
+
import { FlowModelRenderer } from '../../../../FlowModelRenderer';
|
|
19
|
+
import { FlowsFloatContextMenu } from '../FlowsFloatContextMenu';
|
|
20
|
+
|
|
21
|
+
const mockColorTextTertiary = '#8c8c8c';
|
|
22
|
+
|
|
23
|
+
vi.mock('antd', async (importOriginal) => {
|
|
24
|
+
const actual = await importOriginal<any>();
|
|
25
|
+
const Dropdown = (props: any) => {
|
|
26
|
+
const { children, getPopupContainer, onOpenChange, open } = props;
|
|
27
|
+
const ref = React.useRef<HTMLSpanElement>(null);
|
|
28
|
+
const [popupContainer, setPopupContainer] = React.useState('');
|
|
29
|
+
|
|
30
|
+
React.useEffect(() => {
|
|
31
|
+
if (!ref.current || typeof getPopupContainer !== 'function') {
|
|
32
|
+
setPopupContainer('');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const container = getPopupContainer(ref.current);
|
|
37
|
+
setPopupContainer(container?.id || container?.className || container?.tagName?.toLowerCase() || '');
|
|
38
|
+
}, [getPopupContainer, open]);
|
|
39
|
+
|
|
40
|
+
return React.createElement(
|
|
41
|
+
'span',
|
|
42
|
+
{
|
|
43
|
+
ref,
|
|
44
|
+
'data-testid': 'dropdown',
|
|
45
|
+
'data-open': open ? 'true' : 'false',
|
|
46
|
+
'data-popup-container': popupContainer,
|
|
47
|
+
'data-has-popup-container': typeof getPopupContainer === 'function' ? 'true' : 'false',
|
|
48
|
+
onMouseEnter: () => onOpenChange?.(true, { source: 'trigger' }),
|
|
49
|
+
onMouseLeave: () => onOpenChange?.(false, { source: 'trigger' }),
|
|
50
|
+
},
|
|
51
|
+
children,
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
const App = Object.assign(({ children }: any) => React.createElement(React.Fragment, null, children), {
|
|
55
|
+
useApp: () => ({ message: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }),
|
|
56
|
+
});
|
|
57
|
+
const ConfigProvider = ({ children }: any) => React.createElement(React.Fragment, null, children);
|
|
58
|
+
const Modal = {
|
|
59
|
+
confirm: vi.fn(),
|
|
60
|
+
error: vi.fn(),
|
|
61
|
+
};
|
|
62
|
+
const Tooltip = ({ children }: any) => React.createElement('span', null, children);
|
|
63
|
+
const Space = ({ children }: any) => React.createElement('div', null, children);
|
|
64
|
+
const Alert = (props: any) => React.createElement('div', { role: 'alert' }, props.message ?? 'Alert');
|
|
65
|
+
const Select = (props: any) => React.createElement('select', props);
|
|
66
|
+
const Switch = (props: any) => React.createElement('input', { ...props, type: 'checkbox' });
|
|
67
|
+
const Typography = {
|
|
68
|
+
Paragraph: ({ children }: any) => React.createElement('p', null, children),
|
|
69
|
+
Text: ({ children }: any) => React.createElement('span', null, children),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
...actual,
|
|
74
|
+
Dropdown,
|
|
75
|
+
App,
|
|
76
|
+
ConfigProvider,
|
|
77
|
+
Modal,
|
|
78
|
+
Tooltip,
|
|
79
|
+
Space,
|
|
80
|
+
Alert,
|
|
81
|
+
Select,
|
|
82
|
+
Switch,
|
|
83
|
+
Typography,
|
|
84
|
+
theme: { ...actual.theme, useToken: () => ({ token: { colorTextTertiary: mockColorTextTertiary } }) },
|
|
85
|
+
} as any;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const createRect = ({ top, left, width, height }: { top: number; left: number; width: number; height: number }) => ({
|
|
89
|
+
x: left,
|
|
90
|
+
y: top,
|
|
91
|
+
top,
|
|
92
|
+
left,
|
|
93
|
+
width,
|
|
94
|
+
height,
|
|
95
|
+
right: left + width,
|
|
96
|
+
bottom: top + height,
|
|
97
|
+
toJSON: () => '',
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const mockRect = (element: HTMLElement, rect: { top: number; left: number; width: number; height: number }) => {
|
|
101
|
+
Object.defineProperty(element, 'getBoundingClientRect', {
|
|
102
|
+
configurable: true,
|
|
103
|
+
value: () => createRect(rect),
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const renderWithProviders = (engine: FlowEngine, ui: React.ReactNode, renderOptions?: Parameters<typeof render>[1]) => {
|
|
108
|
+
return render(
|
|
109
|
+
<ConfigProvider>
|
|
110
|
+
<App>
|
|
111
|
+
<FlowEngineProvider engine={engine}>{ui}</FlowEngineProvider>
|
|
112
|
+
</App>
|
|
113
|
+
</ConfigProvider>,
|
|
114
|
+
renderOptions,
|
|
115
|
+
);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const createAppContainer = () => {
|
|
119
|
+
const container = document.createElement('div');
|
|
120
|
+
container.id = 'nocobase-app-container';
|
|
121
|
+
document.body.appendChild(container);
|
|
122
|
+
return container;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const createPopupRoot = (
|
|
126
|
+
className:
|
|
127
|
+
| 'ant-drawer-content-wrapper'
|
|
128
|
+
| 'ant-drawer-content'
|
|
129
|
+
| 'ant-modal-wrap'
|
|
130
|
+
| 'ant-modal-content'
|
|
131
|
+
| 'ant-drawer-root'
|
|
132
|
+
| 'ant-modal-root',
|
|
133
|
+
) => {
|
|
134
|
+
const popupRoot = document.createElement('div');
|
|
135
|
+
popupRoot.className = className;
|
|
136
|
+
return popupRoot;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const setupDrawerPopup = () => {
|
|
140
|
+
const appContainer = createAppContainer();
|
|
141
|
+
const drawerWrapper = createPopupRoot('ant-drawer-content-wrapper');
|
|
142
|
+
const drawerContent = createPopupRoot('ant-drawer-content');
|
|
143
|
+
drawerContent.setAttribute('role', 'dialog');
|
|
144
|
+
drawerWrapper.scrollTop = 14;
|
|
145
|
+
drawerWrapper.scrollLeft = 9;
|
|
146
|
+
drawerWrapper.appendChild(drawerContent);
|
|
147
|
+
appContainer.appendChild(drawerWrapper);
|
|
148
|
+
mockRect(appContainer, { top: 0, left: 0, width: 1280, height: 900 });
|
|
149
|
+
mockRect(drawerWrapper, { top: 80, left: 200, width: 640, height: 520 });
|
|
150
|
+
mockRect(drawerContent, { top: 96, left: 216, width: 624, height: 504 });
|
|
151
|
+
return { drawerWrapper, drawerContent };
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const getHost = (element: HTMLElement) => element.closest('[data-has-float-menu="true"]') as HTMLDivElement;
|
|
155
|
+
const queryOverlay = (container: HTMLElement, uid: string) =>
|
|
156
|
+
container.querySelector(`[data-model-uid="${uid}"]`) as HTMLDivElement | null;
|
|
157
|
+
|
|
158
|
+
const createModel = (engine: FlowEngine, uid: string) => {
|
|
159
|
+
const model = new FlowModel({ uid, flowEngine: engine });
|
|
160
|
+
model.context.defineProperty('themeToken', { value: { borderRadiusLG: 8 } });
|
|
161
|
+
model.render = vi.fn().mockReturnValue(<div data-testid={`${uid}-content`}>{uid}</div>);
|
|
162
|
+
return model;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
describe('FlowsFloatContextMenu', () => {
|
|
166
|
+
const originalResizeObserver = globalThis.ResizeObserver;
|
|
167
|
+
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
|
168
|
+
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
|
|
169
|
+
|
|
170
|
+
beforeEach(() => {
|
|
171
|
+
globalThis.ResizeObserver = class {
|
|
172
|
+
observe = vi.fn();
|
|
173
|
+
disconnect = vi.fn();
|
|
174
|
+
unobserve = vi.fn();
|
|
175
|
+
} as any;
|
|
176
|
+
|
|
177
|
+
globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
|
|
178
|
+
cb(0);
|
|
179
|
+
return 1;
|
|
180
|
+
}) as typeof requestAnimationFrame;
|
|
181
|
+
globalThis.cancelAnimationFrame = vi.fn() as any;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
afterEach(() => {
|
|
185
|
+
cleanup();
|
|
186
|
+
vi.clearAllMocks();
|
|
187
|
+
globalThis.ResizeObserver = originalResizeObserver;
|
|
188
|
+
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
|
189
|
+
globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
|
|
190
|
+
document.body.innerHTML = '';
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('defaults to portal into app container and keeps toolbar visible while moving from host to toolbar', async () => {
|
|
194
|
+
const engine = new FlowEngine();
|
|
195
|
+
engine.flowSettings.forceEnable();
|
|
196
|
+
const model = createModel(engine, 'portal-model');
|
|
197
|
+
const appContainer = createAppContainer();
|
|
198
|
+
appContainer.scrollTop = 8;
|
|
199
|
+
appContainer.scrollLeft = 6;
|
|
200
|
+
mockRect(appContainer, { top: 100, left: 120, width: 960, height: 720 });
|
|
201
|
+
|
|
202
|
+
const { getByTestId } = renderWithProviders(
|
|
203
|
+
engine,
|
|
204
|
+
<FlowsFloatContextMenu model={model}>
|
|
205
|
+
<div data-testid="content">content</div>
|
|
206
|
+
</FlowsFloatContextMenu>,
|
|
207
|
+
{ container: appContainer },
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const host = getHost(getByTestId('content'));
|
|
211
|
+
mockRect(host, { top: 112, left: 124, width: 160, height: 60 });
|
|
212
|
+
|
|
213
|
+
expect(appContainer.querySelector('[data-model-uid="portal-model"]')).toBeNull();
|
|
214
|
+
expect(host.querySelector('.nb-toolbar-container')).toBeNull();
|
|
215
|
+
|
|
216
|
+
fireEvent.mouseEnter(host);
|
|
217
|
+
|
|
218
|
+
const overlay = await waitFor(() => {
|
|
219
|
+
const nextOverlay = appContainer.querySelector('[data-model-uid="portal-model"]') as HTMLDivElement | null;
|
|
220
|
+
expect(nextOverlay).toBeTruthy();
|
|
221
|
+
return nextOverlay as HTMLDivElement;
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
await waitFor(() => {
|
|
225
|
+
expect(within(overlay).getByLabelText('flows-settings')).toBeTruthy();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await waitFor(() => {
|
|
229
|
+
expect(overlay.className).toContain('nb-toolbar-visible');
|
|
230
|
+
expect(overlay.className).toContain('nb-toolbar-portal-absolute');
|
|
231
|
+
expect(overlay.style.top).toBe('20px');
|
|
232
|
+
expect(overlay.style.left).toBe('10px');
|
|
233
|
+
expect(overlay.style.width).toBe('160px');
|
|
234
|
+
expect(overlay.style.height).toBe('60px');
|
|
235
|
+
expect(overlay.parentElement).toBe(appContainer);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const icons = overlay.querySelector('.nb-toolbar-container-icons') as HTMLDivElement;
|
|
239
|
+
const dropdown = within(overlay).getByTestId('dropdown');
|
|
240
|
+
expect(dropdown.getAttribute('data-popup-container')).toContain('nb-toolbar-container-icons');
|
|
241
|
+
fireEvent.mouseLeave(host, { relatedTarget: icons });
|
|
242
|
+
fireEvent.mouseEnter(icons, { relatedTarget: host });
|
|
243
|
+
|
|
244
|
+
await waitFor(() => {
|
|
245
|
+
expect(overlay.className).toContain('nb-toolbar-visible');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
fireEvent.mouseLeave(icons);
|
|
249
|
+
|
|
250
|
+
await waitFor(() => {
|
|
251
|
+
expect(queryOverlay(appContainer, 'portal-model')).toBeNull();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('renders through FlowModelRenderer with app-container portal and keeps toolbar pinned while dropdown is open', async () => {
|
|
256
|
+
const engine = new FlowEngine();
|
|
257
|
+
engine.flowSettings.forceEnable();
|
|
258
|
+
const model = createModel(engine, 'renderer-model');
|
|
259
|
+
const appContainer = createAppContainer();
|
|
260
|
+
mockRect(appContainer, { top: 40, left: 60, width: 1200, height: 800 });
|
|
261
|
+
|
|
262
|
+
const { findByTestId } = renderWithProviders(
|
|
263
|
+
engine,
|
|
264
|
+
<FlowModelRenderer model={model} showFlowSettings={{ toolbarPosition: 'above' }} />,
|
|
265
|
+
{ container: appContainer },
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const content = await findByTestId('renderer-model-content');
|
|
269
|
+
const host = getHost(content);
|
|
270
|
+
mockRect(host, { top: 100, left: 150, width: 180, height: 72 });
|
|
271
|
+
|
|
272
|
+
expect(appContainer.querySelector('[data-model-uid="renderer-model"]')).toBeNull();
|
|
273
|
+
|
|
274
|
+
fireEvent.mouseEnter(host);
|
|
275
|
+
|
|
276
|
+
const overlay = await waitFor(() => {
|
|
277
|
+
const nextOverlay = appContainer.querySelector('[data-model-uid="renderer-model"]') as HTMLDivElement | null;
|
|
278
|
+
expect(nextOverlay).toBeTruthy();
|
|
279
|
+
return nextOverlay as HTMLDivElement;
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
await waitFor(() => {
|
|
283
|
+
expect(within(overlay).getByLabelText('flows-settings')).toBeTruthy();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
await waitFor(() => {
|
|
287
|
+
expect(overlay.className).toContain('nb-toolbar-visible');
|
|
288
|
+
expect(overlay.className).toContain('nb-toolbar-portal-absolute');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const icons = overlay.querySelector('.nb-toolbar-container-icons') as HTMLDivElement;
|
|
292
|
+
const dropdown = within(overlay).getByTestId('dropdown');
|
|
293
|
+
expect(icons.className).toContain('nb-toolbar-position-above');
|
|
294
|
+
expect(dropdown.getAttribute('data-popup-container')).toContain('nb-toolbar-container-icons');
|
|
295
|
+
|
|
296
|
+
fireEvent.mouseEnter(icons);
|
|
297
|
+
fireEvent.mouseEnter(dropdown);
|
|
298
|
+
|
|
299
|
+
await waitFor(() => {
|
|
300
|
+
expect(dropdown.getAttribute('data-open')).toBe('true');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
fireEvent.mouseLeave(icons);
|
|
304
|
+
|
|
305
|
+
await waitFor(() => {
|
|
306
|
+
expect(overlay.className).toContain('nb-toolbar-visible');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
fireEvent.mouseLeave(dropdown);
|
|
310
|
+
fireEvent.mouseLeave(icons);
|
|
311
|
+
|
|
312
|
+
await waitFor(() => {
|
|
313
|
+
expect(queryOverlay(appContainer, 'renderer-model')).toBeNull();
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('portals field toolbar to the nearest popup root and treats inset values as rect adjustments', async () => {
|
|
318
|
+
const engine = new FlowEngine();
|
|
319
|
+
engine.flowSettings.forceEnable();
|
|
320
|
+
const model = createModel(engine, 'field-model');
|
|
321
|
+
model.render = vi.fn().mockReturnValue(<input data-testid="field-input" />);
|
|
322
|
+
const insetModel = createModel(engine, 'field-inset-model');
|
|
323
|
+
insetModel.render = vi.fn().mockReturnValue(<input data-testid="field-inset-input" />);
|
|
324
|
+
const { drawerWrapper, drawerContent } = setupDrawerPopup();
|
|
325
|
+
|
|
326
|
+
const { findByTestId } = renderWithProviders(
|
|
327
|
+
engine,
|
|
328
|
+
<>
|
|
329
|
+
<FieldModelRenderer model={model} showFlowSettings={{ toolbarPosition: 'inside' }} />
|
|
330
|
+
<FieldModelRenderer
|
|
331
|
+
model={insetModel}
|
|
332
|
+
showFlowSettings={{
|
|
333
|
+
toolbarPosition: 'inside',
|
|
334
|
+
style: {
|
|
335
|
+
top: -6,
|
|
336
|
+
left: -6,
|
|
337
|
+
right: -6,
|
|
338
|
+
bottom: -6,
|
|
339
|
+
},
|
|
340
|
+
}}
|
|
341
|
+
/>
|
|
342
|
+
</>,
|
|
343
|
+
{ container: drawerContent },
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
const input = await findByTestId('field-input');
|
|
347
|
+
const insetInput = await findByTestId('field-inset-input');
|
|
348
|
+
const host = getHost(input);
|
|
349
|
+
const insetHost = getHost(insetInput);
|
|
350
|
+
mockRect(host, { top: 140, left: 280, width: 220, height: 48 });
|
|
351
|
+
mockRect(insetHost, { top: 220, left: 320, width: 220, height: 48 });
|
|
352
|
+
|
|
353
|
+
expect(drawerWrapper.querySelector('[data-model-uid="field-model"]')).toBeNull();
|
|
354
|
+
expect(drawerWrapper.querySelector('[data-model-uid="field-inset-model"]')).toBeNull();
|
|
355
|
+
|
|
356
|
+
fireEvent.mouseEnter(host);
|
|
357
|
+
|
|
358
|
+
const overlay = await waitFor(() => {
|
|
359
|
+
const nextOverlay = drawerWrapper.querySelector('[data-model-uid="field-model"]') as HTMLDivElement | null;
|
|
360
|
+
expect(nextOverlay).toBeTruthy();
|
|
361
|
+
return nextOverlay as HTMLDivElement;
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
await waitFor(() => {
|
|
365
|
+
expect(within(overlay).getByLabelText('flows-settings')).toBeTruthy();
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
await waitFor(() => {
|
|
369
|
+
expect(overlay.className).toContain('nb-toolbar-portal-absolute');
|
|
370
|
+
expect(overlay.style.top).toBe('74px');
|
|
371
|
+
expect(overlay.style.left).toBe('89px');
|
|
372
|
+
expect(overlay.style.width).toBe('220px');
|
|
373
|
+
expect(overlay.style.height).toBe('48px');
|
|
374
|
+
expect(overlay.parentElement).toBe(drawerWrapper);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
fireEvent.mouseEnter(insetHost);
|
|
378
|
+
|
|
379
|
+
const insetOverlay = await waitFor(() => {
|
|
380
|
+
const nextOverlay = drawerWrapper.querySelector('[data-model-uid="field-inset-model"]') as HTMLDivElement | null;
|
|
381
|
+
expect(nextOverlay).toBeTruthy();
|
|
382
|
+
return nextOverlay as HTMLDivElement;
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
await waitFor(() => {
|
|
386
|
+
expect(within(insetOverlay).getByLabelText('flows-settings')).toBeTruthy();
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
await waitFor(() => {
|
|
390
|
+
expect(insetOverlay.className).toContain('nb-toolbar-portal-absolute');
|
|
391
|
+
expect(insetOverlay.style.top).toBe('148px');
|
|
392
|
+
expect(insetOverlay.style.left).toBe('123px');
|
|
393
|
+
expect(insetOverlay.style.width).toBe('232px');
|
|
394
|
+
expect(insetOverlay.style.height).toBe('60px');
|
|
395
|
+
expect(insetOverlay.parentElement).toBe(drawerWrapper);
|
|
396
|
+
expect(insetOverlay.style.right).toBe('');
|
|
397
|
+
expect(insetOverlay.style.bottom).toBe('');
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('hides parent toolbar when hovering a nested child host', async () => {
|
|
402
|
+
const engine = new FlowEngine();
|
|
403
|
+
engine.flowSettings.forceEnable();
|
|
404
|
+
const parentModel = createModel(engine, 'parent-model');
|
|
405
|
+
const childModel = createModel(engine, 'child-model');
|
|
406
|
+
const appContainer = createAppContainer();
|
|
407
|
+
mockRect(appContainer, { top: 0, left: 0, width: 1280, height: 900 });
|
|
408
|
+
|
|
409
|
+
const { getByTestId } = renderWithProviders(
|
|
410
|
+
engine,
|
|
411
|
+
<FlowsFloatContextMenu model={parentModel}>
|
|
412
|
+
<div data-testid="parent-content">
|
|
413
|
+
<FlowsFloatContextMenu model={childModel}>
|
|
414
|
+
<div data-testid="child-content">child</div>
|
|
415
|
+
</FlowsFloatContextMenu>
|
|
416
|
+
</div>
|
|
417
|
+
</FlowsFloatContextMenu>,
|
|
418
|
+
{ container: appContainer },
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
const parentHost = getHost(getByTestId('parent-content'));
|
|
422
|
+
const childHost = getHost(getByTestId('child-content'));
|
|
423
|
+
mockRect(parentHost, { top: 10, left: 10, width: 240, height: 120 });
|
|
424
|
+
mockRect(childHost, { top: 28, left: 36, width: 120, height: 48 });
|
|
425
|
+
|
|
426
|
+
expect(appContainer.querySelector('[data-model-uid="parent-model"]')).toBeNull();
|
|
427
|
+
expect(appContainer.querySelector('[data-model-uid="child-model"]')).toBeNull();
|
|
428
|
+
|
|
429
|
+
fireEvent.mouseEnter(parentHost);
|
|
430
|
+
|
|
431
|
+
const parentOverlay = await waitFor(() => {
|
|
432
|
+
const nextOverlay = appContainer.querySelector('[data-model-uid="parent-model"]') as HTMLDivElement | null;
|
|
433
|
+
expect(nextOverlay).toBeTruthy();
|
|
434
|
+
return nextOverlay as HTMLDivElement;
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
await waitFor(() => {
|
|
438
|
+
expect(within(parentOverlay).getByLabelText('flows-settings')).toBeTruthy();
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
await waitFor(() => {
|
|
442
|
+
expect(parentOverlay.className).toContain('nb-toolbar-visible');
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
fireEvent.mouseEnter(childHost);
|
|
446
|
+
fireEvent.mouseMove(childHost);
|
|
447
|
+
|
|
448
|
+
const childOverlay = await waitFor(() => {
|
|
449
|
+
const nextOverlay = appContainer.querySelector('[data-model-uid="child-model"]') as HTMLDivElement | null;
|
|
450
|
+
expect(nextOverlay).toBeTruthy();
|
|
451
|
+
return nextOverlay as HTMLDivElement;
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
await waitFor(() => {
|
|
455
|
+
expect(within(childOverlay).getByLabelText('flows-settings')).toBeTruthy();
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
await waitFor(() => {
|
|
459
|
+
expect(queryOverlay(appContainer, 'child-model')?.className).toContain('nb-toolbar-visible');
|
|
460
|
+
expect(queryOverlay(appContainer, 'parent-model')).toBeNull();
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
fireEvent.mouseLeave(childHost, { relatedTarget: document.createElement('div') });
|
|
464
|
+
|
|
465
|
+
await waitFor(() => {
|
|
466
|
+
expect(queryOverlay(appContainer, 'child-model')?.className).toContain('nb-toolbar-visible');
|
|
467
|
+
expect(queryOverlay(appContainer, 'parent-model')).toBeNull();
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('restores parent toolbar after leaving a child toolbar back into the parent block', async () => {
|
|
472
|
+
const engine = new FlowEngine();
|
|
473
|
+
engine.flowSettings.forceEnable();
|
|
474
|
+
const parentModel = createModel(engine, 'parent-restore-model');
|
|
475
|
+
const childModel = createModel(engine, 'child-restore-model');
|
|
476
|
+
const appContainer = createAppContainer();
|
|
477
|
+
mockRect(appContainer, { top: 0, left: 0, width: 1280, height: 900 });
|
|
478
|
+
|
|
479
|
+
const { getByTestId } = renderWithProviders(
|
|
480
|
+
engine,
|
|
481
|
+
<FlowsFloatContextMenu model={parentModel}>
|
|
482
|
+
<div data-testid="parent-content">
|
|
483
|
+
<div data-testid="parent-gap">gap</div>
|
|
484
|
+
<FlowsFloatContextMenu model={childModel}>
|
|
485
|
+
<div data-testid="child-content">child</div>
|
|
486
|
+
</FlowsFloatContextMenu>
|
|
487
|
+
</div>
|
|
488
|
+
</FlowsFloatContextMenu>,
|
|
489
|
+
{ container: appContainer },
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
const parentHost = getHost(getByTestId('parent-content'));
|
|
493
|
+
const childHost = getHost(getByTestId('child-content'));
|
|
494
|
+
const parentGap = getByTestId('parent-gap');
|
|
495
|
+
mockRect(parentHost, { top: 10, left: 10, width: 320, height: 160 });
|
|
496
|
+
mockRect(childHost, { top: 28, left: 36, width: 120, height: 48 });
|
|
497
|
+
|
|
498
|
+
fireEvent.mouseEnter(parentHost);
|
|
499
|
+
|
|
500
|
+
const parentOverlay = await waitFor(() => {
|
|
501
|
+
const nextOverlay = queryOverlay(appContainer, 'parent-restore-model');
|
|
502
|
+
expect(nextOverlay).toBeTruthy();
|
|
503
|
+
return nextOverlay as HTMLDivElement;
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
await waitFor(() => {
|
|
507
|
+
expect(within(parentOverlay).getByLabelText('flows-settings')).toBeTruthy();
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
fireEvent.mouseEnter(childHost);
|
|
511
|
+
fireEvent.mouseMove(childHost);
|
|
512
|
+
|
|
513
|
+
const childOverlay = await waitFor(() => {
|
|
514
|
+
const nextOverlay = queryOverlay(appContainer, 'child-restore-model');
|
|
515
|
+
expect(nextOverlay).toBeTruthy();
|
|
516
|
+
return nextOverlay as HTMLDivElement;
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
await waitFor(() => {
|
|
520
|
+
expect(within(childOverlay).getByLabelText('flows-settings')).toBeTruthy();
|
|
521
|
+
expect(queryOverlay(appContainer, 'parent-restore-model')).toBeNull();
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
const childIcons = childOverlay.querySelector('.nb-toolbar-container-icons') as HTMLDivElement;
|
|
525
|
+
|
|
526
|
+
fireEvent.mouseLeave(parentHost, { relatedTarget: childIcons });
|
|
527
|
+
fireEvent.mouseLeave(childHost, { relatedTarget: childIcons });
|
|
528
|
+
fireEvent.mouseEnter(childIcons, { relatedTarget: childHost });
|
|
529
|
+
|
|
530
|
+
await waitFor(() => {
|
|
531
|
+
expect(queryOverlay(appContainer, 'child-restore-model')?.className).toContain('nb-toolbar-visible');
|
|
532
|
+
expect(queryOverlay(appContainer, 'parent-restore-model')).toBeNull();
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
fireEvent.mouseLeave(childIcons, { relatedTarget: parentGap });
|
|
536
|
+
fireEvent.mouseEnter(parentHost, { relatedTarget: childIcons });
|
|
537
|
+
fireEvent.mouseEnter(parentGap, { relatedTarget: childIcons });
|
|
538
|
+
fireEvent.mouseMove(parentGap);
|
|
539
|
+
|
|
540
|
+
await waitFor(() => {
|
|
541
|
+
expect(queryOverlay(appContainer, 'child-restore-model')).toBeNull();
|
|
542
|
+
const parentOverlayAfterRestore = queryOverlay(appContainer, 'parent-restore-model');
|
|
543
|
+
expect(parentOverlayAfterRestore).toBeTruthy();
|
|
544
|
+
expect(parentOverlayAfterRestore?.className).toContain('nb-toolbar-visible');
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
});
|