@nocobase/flow-engine 2.1.0-alpha.13 → 2.1.0-alpha.14

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.
Files changed (31) hide show
  1. package/lib/components/FlowModelRenderer.d.ts +1 -1
  2. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.d.ts +3 -0
  3. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +48 -9
  4. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.d.ts +19 -43
  5. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +332 -296
  6. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.d.ts +36 -0
  7. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.js +272 -0
  8. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.d.ts +30 -0
  9. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +247 -0
  10. package/lib/flowEngine.js +1 -4
  11. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.js +7 -7
  12. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.js +1 -1
  13. package/lib/types.d.ts +0 -12
  14. package/package.json +4 -4
  15. package/src/__tests__/flow-engine.test.ts +0 -48
  16. package/src/__tests__/flowEngine.modelLoaders.test.ts +1 -5
  17. package/src/__tests__/flowEngine.saveModel.test.ts +0 -4
  18. package/src/__tests__/runjsSnippets.test.ts +2 -2
  19. package/src/components/FlowModelRenderer.tsx +3 -1
  20. package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +17 -7
  21. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +63 -9
  22. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +457 -440
  23. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +95 -0
  24. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +547 -0
  25. package/src/components/settings/wrappers/contextual/useFloatToolbarPortal.ts +358 -0
  26. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +281 -0
  27. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +0 -1
  28. package/src/flowEngine.ts +1 -7
  29. package/src/runjs-context/snippets/scene/detail/set-field-style.snippet.ts +7 -7
  30. package/src/runjs-context/snippets/scene/table/set-cell-style.snippet.ts +1 -1
  31. package/src/types.ts +0 -11
@@ -89,13 +89,7 @@ describe('FlowEngine', () => {
89
89
  class MockFlowModelRepository implements IFlowModelRepository {
90
90
  // 使用可配置返回值,便于不同用例控制 findOne 行为
91
91
  findOneResult: any = null;
92
- ensureResult: any = null;
93
- ensureCalls = 0;
94
92
  save = vi.fn(async (model: FlowModel) => ({ success: true, uid: model.uid }));
95
- async ensure() {
96
- this.ensureCalls += 1;
97
- return this.ensureResult ? JSON.parse(JSON.stringify(this.ensureResult)) : null;
98
- }
99
93
  async findOne() {
100
94
  // 返回深拷贝,避免被测试过程修改
101
95
  return this.findOneResult ? JSON.parse(JSON.stringify(this.findOneResult)) : null;
@@ -194,47 +188,5 @@ describe('FlowEngine', () => {
194
188
  expect(Array.isArray(mounted)).toBe(false);
195
189
  expect(mounted?.uid).toBe('c3');
196
190
  });
197
-
198
- it('should call repository.ensure with repository context preserved', async () => {
199
- const parent = engine.createModel({ uid: 'p4', use: 'FlowModel' });
200
-
201
- repo.ensureResult = {
202
- uid: 'c4',
203
- use: 'FlowModel',
204
- parentId: parent.uid,
205
- subKey: 'page',
206
- subType: 'object',
207
- };
208
-
209
- const child = await engine.loadOrCreateModel({
210
- parentId: parent.uid,
211
- subKey: 'page',
212
- subType: 'object',
213
- use: 'FlowModel',
214
- async: true,
215
- });
216
-
217
- expect(child).toBeTruthy();
218
- expect(repo.ensureCalls).toBe(1);
219
- expect((parent.subModels as any).page).toBe(child);
220
- expect(repo.save).not.toHaveBeenCalled();
221
- });
222
-
223
- it('should not persist through ensure when skipSave is true', async () => {
224
- repo.findOneResult = null;
225
-
226
- const model = await engine.loadOrCreateModel(
227
- {
228
- uid: 'c5',
229
- use: 'FlowModel',
230
- },
231
- { skipSave: true },
232
- );
233
-
234
- expect(model).toBeTruthy();
235
- expect(model?.uid).toBe('c5');
236
- expect(repo.ensureCalls).toBe(0);
237
- expect(repo.save).not.toHaveBeenCalled();
238
- });
239
191
  });
240
192
  });
@@ -16,14 +16,10 @@ class MockFlowModelRepository implements IFlowModelRepository {
16
16
  findOneResult: any = null;
17
17
  save = vi.fn(async (model: FlowModel) => ({ success: true, uid: model.uid }));
18
18
 
19
- async findOne(_query?: any) {
19
+ async findOne() {
20
20
  return this.findOneResult ? JSON.parse(JSON.stringify(this.findOneResult)) : null;
21
21
  }
22
22
 
23
- async ensure(options: any) {
24
- return await this.findOne(options);
25
- }
26
-
27
23
  async destroy() {
28
24
  return true;
29
25
  }
@@ -40,10 +40,6 @@ class MockFlowModelRepository implements IFlowModelRepository {
40
40
  return null;
41
41
  }
42
42
 
43
- async ensure(options: any): Promise<any> {
44
- return await this.findOne(options);
45
- }
46
-
47
43
  async destroy(uid: string): Promise<boolean> {
48
44
  return true;
49
45
  }
@@ -125,8 +125,8 @@ describe('RunJS Snippets', () => {
125
125
  expect(tableStyle?.scenes).toEqual(['tableFieldEvent']);
126
126
 
127
127
  const fieldStyle = fieldSnippets.find((s) => s.ref === 'scene/detail/set-field-style');
128
- expect(fieldStyle?.name).toBe('表单、详情字段样式设置');
129
- expect(fieldStyle?.body).toContain('ctx.model.subModels.field.props.style');
128
+ expect(fieldStyle?.name).toBe('设置表单项/详情项样式');
129
+ expect(fieldStyle?.body).toContain('ctx.model.props.style');
130
130
  expect(fieldStyle?.scenes).toEqual(expect.arrayContaining(['detailFieldEvent', 'formFieldEvent']));
131
131
  expect(fieldStyle?.groups).toEqual(expect.arrayContaining(['scene/detail', 'scene/form']));
132
132
 
@@ -69,7 +69,7 @@ export interface FlowModelRendererProps {
69
69
  showBackground?: boolean;
70
70
  showBorder?: boolean;
71
71
  showDragHandle?: boolean;
72
- /** 自定义工具栏样式 */
72
+ /** 自定义工具栏样式,`top/left/right/bottom` 会作为 portal overlay 的 inset 使用 */
73
73
  style?: React.CSSProperties;
74
74
  /**
75
75
  * @default 'inside'
@@ -112,6 +112,7 @@ const FlowModelRendererWithAutoFlows: React.FC<{
112
112
  showBackground?: boolean;
113
113
  showBorder?: boolean;
114
114
  showDragHandle?: boolean;
115
+ /** `top/left/right/bottom` 会作为 portal overlay 的 inset 使用 */
115
116
  style?: React.CSSProperties;
116
117
  /**
117
118
  * @default 'inside'
@@ -182,6 +183,7 @@ const FlowModelRendererCore: React.FC<{
182
183
  showBackground?: boolean;
183
184
  showBorder?: boolean;
184
185
  showDragHandle?: boolean;
186
+ /** `top/left/right/bottom` 会作为 portal overlay 的 inset 使用 */
185
187
  style?: React.CSSProperties;
186
188
  /**
187
189
  * @default 'inside'
@@ -9,7 +9,7 @@
9
9
 
10
10
  import React from 'react';
11
11
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
12
- import { render, cleanup, waitFor } from '@testing-library/react';
12
+ import { render, cleanup, fireEvent, waitFor } from '@testing-library/react';
13
13
  import { App, ConfigProvider } from 'antd';
14
14
  import { FlowEngine } from '../../flowEngine';
15
15
  import { FlowModel, ModelRenderMode } from '../../models/flowModel';
@@ -94,6 +94,16 @@ const clickDeleteFromLastDropdown = async () => {
94
94
  menu.onClick?.({ key: 'delete' });
95
95
  };
96
96
 
97
+ const getHost = (element: HTMLElement) => element.closest('[data-has-float-menu="true"]') as HTMLDivElement;
98
+
99
+ const hoverHostAndClickDelete = async (element: HTMLElement) => {
100
+ const host = getHost(element);
101
+ if (host) {
102
+ fireEvent.mouseEnter(host);
103
+ }
104
+ await clickDeleteFromLastDropdown();
105
+ };
106
+
97
107
  // ---------------- Tests ----------------
98
108
  describe('Delete problematic model via FlowSettings menu', () => {
99
109
  beforeEach(() => {
@@ -120,7 +130,7 @@ describe('Delete problematic model via FlowSettings menu', () => {
120
130
  // satisfy FlowsFloatContextMenu styles
121
131
  model.context.defineProperty('themeToken', { value: { borderRadiusLG: 8 } });
122
132
 
123
- render(
133
+ const { findByTestId } = render(
124
134
  <ConfigProvider>
125
135
  <App>
126
136
  <FlowEngineProvider engine={engine}>
@@ -130,7 +140,7 @@ describe('Delete problematic model via FlowSettings menu', () => {
130
140
  </ConfigProvider>,
131
141
  );
132
142
 
133
- await clickDeleteFromLastDropdown();
143
+ await hoverHostAndClickDelete(await findByTestId('result'));
134
144
  expect(engine.getModel(model.uid)).toBeUndefined();
135
145
  });
136
146
 
@@ -163,7 +173,7 @@ describe('Delete problematic model via FlowSettings menu', () => {
163
173
  parent.context.defineProperty('themeToken', { value: { borderRadiusLG: 8 } });
164
174
  child.context.defineProperty('themeToken', { value: { borderRadiusLG: 8 } });
165
175
 
166
- render(
176
+ const { findByTestId } = render(
167
177
  <ConfigProvider>
168
178
  <App>
169
179
  <FlowEngineProvider engine={engine}>
@@ -173,7 +183,7 @@ describe('Delete problematic model via FlowSettings menu', () => {
173
183
  </ConfigProvider>,
174
184
  );
175
185
 
176
- await clickDeleteFromLastDropdown();
186
+ await hoverHostAndClickDelete(await findByTestId('result'));
177
187
  expect(engine.getModel(child.uid)).toBeUndefined();
178
188
  const remain = (parent.subModels as any).items || [];
179
189
  expect(remain.find((m: FlowModel) => m.uid === child.uid)).toBeUndefined();
@@ -208,7 +218,7 @@ describe('Delete problematic model via FlowSettings menu', () => {
208
218
  parent.context.defineProperty('themeToken', { value: { borderRadiusLG: 8 } });
209
219
  child.context.defineProperty('themeToken', { value: { borderRadiusLG: 8 } });
210
220
 
211
- render(
221
+ const { findByTestId } = render(
212
222
  <ConfigProvider>
213
223
  <App>
214
224
  <FlowEngineProvider engine={engine}>
@@ -218,7 +228,7 @@ describe('Delete problematic model via FlowSettings menu', () => {
218
228
  </ConfigProvider>,
219
229
  );
220
230
 
221
- await clickDeleteFromLastDropdown();
231
+ await hoverHostAndClickDelete(await findByTestId('result'));
222
232
  expect(engine.getModel(child.uid)).toBeUndefined();
223
233
  const remain = (parent.subModels as any).cells || [];
224
234
  expect(remain.find((m: FlowModel) => m.uid === child.uid)).toBeUndefined();
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { ExclamationCircleOutlined, MenuOutlined, QuestionCircleOutlined } from '@ant-design/icons';
11
+ import { css } from '@emotion/css';
11
12
  import type { DropdownProps, MenuProps } from 'antd';
12
13
  import { App, Dropdown, Modal, Tooltip, theme } from 'antd';
13
14
  import React, { startTransition, useCallback, useEffect, useMemo, useState, FC } from 'react';
@@ -188,15 +189,42 @@ interface DefaultSettingsIconProps {
188
189
  showCopyUidButton?: boolean;
189
190
  menuLevels?: number; // Menu levels: 1=current model only (default), 2=include sub-models
190
191
  flattenSubMenus?: boolean; // Whether to flatten sub-menus: false=group by model (default), true=flatten all
192
+ onDropdownVisibleChange?: (open: boolean) => void;
193
+ getPopupContainer?: DropdownProps['getPopupContainer'];
191
194
  [key: string]: any; // Allow additional props
192
195
  }
193
196
 
197
+ const TOOLBAR_ICONS_SELECTOR = '.nb-toolbar-container-icons';
198
+ const TOOLBAR_CONTAINER_SELECTOR = '.nb-toolbar-container';
199
+ const TOOLBAR_DROPDOWN_OVERLAY_CLASS = css`
200
+ width: max-content;
201
+ min-width: max-content;
202
+
203
+ .ant-dropdown-menu {
204
+ width: max-content;
205
+ min-width: max-content;
206
+ }
207
+ `;
208
+
209
+ const getToolbarPopupContainer = (triggerNode?: HTMLElement | null) => {
210
+ if (!triggerNode) {
211
+ return null;
212
+ }
213
+
214
+ return (
215
+ (triggerNode.closest(TOOLBAR_ICONS_SELECTOR) as HTMLElement | null) ||
216
+ (triggerNode.closest(TOOLBAR_CONTAINER_SELECTOR) as HTMLElement | null)
217
+ );
218
+ };
219
+
194
220
  export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
195
221
  model,
196
222
  showDeleteButton = true,
197
223
  showCopyUidButton = true,
198
224
  menuLevels = 1, // 默认一级菜单
199
225
  flattenSubMenus = true,
226
+ onDropdownVisibleChange,
227
+ getPopupContainer,
200
228
  }) => {
201
229
  const { message } = App.useApp();
202
230
  const t = useMemo(() => getT(model), [model]);
@@ -210,15 +238,38 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
210
238
  const [isLoading, setIsLoading] = useState(true);
211
239
  const closeDropdown = useCallback(() => {
212
240
  setVisible(false);
213
- }, []);
214
- const handleOpenChange: DropdownProps['onOpenChange'] = useCallback((nextOpen: boolean, info) => {
215
- if (info.source === 'trigger' || nextOpen) {
216
- // 当鼠标快速滑过时,终止菜单的渲染,防止卡顿
217
- startTransition(() => {
218
- setVisible(nextOpen);
219
- });
220
- }
221
- }, []);
241
+ onDropdownVisibleChange?.(false);
242
+ }, [onDropdownVisibleChange]);
243
+ const resolvePopupContainer = useCallback<NonNullable<DropdownProps['getPopupContainer']>>(
244
+ (triggerNode) => {
245
+ // 工具栏自身容器必须优先,保证鼠标从 icon 移到菜单时仍处于同一 hover 树。
246
+ // 弹窗场景的裁剪问题由 useFloatToolbarPortal 负责把 toolbar 挂到正确的 popup host。
247
+ return (
248
+ getToolbarPopupContainer(triggerNode) ||
249
+ getPopupContainer?.(triggerNode) ||
250
+ triggerNode?.parentElement ||
251
+ document.body
252
+ );
253
+ },
254
+ [getPopupContainer],
255
+ );
256
+ const handleOpenChange: DropdownProps['onOpenChange'] = useCallback(
257
+ (nextOpen: boolean, info) => {
258
+ if (info.source === 'trigger' || nextOpen) {
259
+ // 当鼠标快速滑过时,终止菜单的渲染,防止卡顿
260
+ startTransition(() => {
261
+ setVisible(nextOpen);
262
+ });
263
+ onDropdownVisibleChange?.(nextOpen);
264
+ }
265
+ },
266
+ [onDropdownVisibleChange],
267
+ );
268
+ useEffect(() => {
269
+ return () => {
270
+ onDropdownVisibleChange?.(false);
271
+ };
272
+ }, [onDropdownVisibleChange]);
222
273
  const dropdownMaxHeight = useNiceDropdownMaxHeight([visible]);
223
274
  useEffect(() => {
224
275
  let mounted = true;
@@ -833,6 +884,9 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
833
884
 
834
885
  return (
835
886
  <Dropdown
887
+ getPopupContainer={resolvePopupContainer}
888
+ overlayClassName={TOOLBAR_DROPDOWN_OVERLAY_CLASS}
889
+ overlayStyle={{ width: 'max-content', minWidth: 'max-content' }}
836
890
  onOpenChange={handleOpenChange}
837
891
  open={visible}
838
892
  menu={{