@nocobase/flow-engine 2.1.0-alpha.4 → 2.1.0-alpha.40

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 (194) hide show
  1. package/LICENSE +201 -661
  2. package/README.md +79 -10
  3. package/lib/JSRunner.d.ts +10 -1
  4. package/lib/JSRunner.js +50 -5
  5. package/lib/ViewScopedFlowEngine.js +5 -1
  6. package/lib/components/FieldModelRenderer.js +2 -2
  7. package/lib/components/FlowModelRenderer.d.ts +3 -1
  8. package/lib/components/FlowModelRenderer.js +12 -6
  9. package/lib/components/FormItem.d.ts +6 -0
  10. package/lib/components/FormItem.js +11 -3
  11. package/lib/components/MobilePopup.js +6 -5
  12. package/lib/components/dnd/gridDragPlanner.d.ts +59 -2
  13. package/lib/components/dnd/gridDragPlanner.js +613 -21
  14. package/lib/components/dnd/index.d.ts +31 -2
  15. package/lib/components/dnd/index.js +244 -23
  16. package/lib/components/settings/wrappers/component/SelectWithTitle.d.ts +2 -1
  17. package/lib/components/settings/wrappers/component/SelectWithTitle.js +14 -12
  18. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.d.ts +3 -0
  19. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +68 -10
  20. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.d.ts +23 -43
  21. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +352 -295
  22. package/lib/components/settings/wrappers/contextual/StepSettingsDialog.js +16 -2
  23. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.d.ts +36 -0
  24. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.js +274 -0
  25. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.d.ts +30 -0
  26. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +315 -0
  27. package/lib/components/subModel/AddSubModelButton.js +27 -1
  28. package/lib/components/subModel/LazyDropdown.js +96 -39
  29. package/lib/components/subModel/index.d.ts +1 -0
  30. package/lib/components/subModel/index.js +19 -0
  31. package/lib/components/subModel/utils.d.ts +1 -1
  32. package/lib/components/subModel/utils.js +9 -3
  33. package/lib/components/variables/VariableHybridInput.d.ts +27 -0
  34. package/lib/components/variables/VariableHybridInput.js +499 -0
  35. package/lib/components/variables/index.d.ts +2 -0
  36. package/lib/components/variables/index.js +3 -0
  37. package/lib/data-source/index.d.ts +75 -0
  38. package/lib/data-source/index.js +247 -5
  39. package/lib/executor/FlowExecutor.js +32 -9
  40. package/lib/flow-registry/DetachedFlowRegistry.d.ts +21 -0
  41. package/lib/flow-registry/DetachedFlowRegistry.js +80 -0
  42. package/lib/flow-registry/index.d.ts +1 -0
  43. package/lib/flow-registry/index.js +3 -1
  44. package/lib/flowContext.d.ts +3 -0
  45. package/lib/flowContext.js +43 -1
  46. package/lib/flowEngine.d.ts +151 -1
  47. package/lib/flowEngine.js +389 -15
  48. package/lib/flowI18n.js +2 -1
  49. package/lib/flowSettings.d.ts +14 -6
  50. package/lib/flowSettings.js +34 -6
  51. package/lib/index.d.ts +2 -0
  52. package/lib/index.js +7 -0
  53. package/lib/lazy-helper.d.ts +14 -0
  54. package/lib/lazy-helper.js +71 -0
  55. package/lib/locale/en-US.json +1 -0
  56. package/lib/locale/index.d.ts +2 -0
  57. package/lib/locale/zh-CN.json +1 -0
  58. package/lib/models/DisplayItemModel.d.ts +1 -1
  59. package/lib/models/EditableItemModel.d.ts +1 -1
  60. package/lib/models/FilterableItemModel.d.ts +1 -1
  61. package/lib/models/flowModel.d.ts +13 -10
  62. package/lib/models/flowModel.js +78 -18
  63. package/lib/provider.js +38 -23
  64. package/lib/reactive/observer.js +46 -16
  65. package/lib/runjs-context/registry.d.ts +1 -1
  66. package/lib/runjs-context/setup.js +20 -12
  67. package/lib/runjs-context/snippets/index.js +13 -2
  68. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.d.ts +11 -0
  69. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.js +50 -0
  70. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.d.ts +11 -0
  71. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.js +54 -0
  72. package/lib/scheduler/ModelOperationScheduler.d.ts +5 -1
  73. package/lib/scheduler/ModelOperationScheduler.js +3 -2
  74. package/lib/types.d.ts +50 -2
  75. package/lib/types.js +1 -0
  76. package/lib/utils/createCollectionContextMeta.js +6 -2
  77. package/lib/utils/index.d.ts +3 -2
  78. package/lib/utils/index.js +7 -0
  79. package/lib/utils/parsePathnameToViewParams.js +1 -1
  80. package/lib/utils/randomId.d.ts +39 -0
  81. package/lib/utils/randomId.js +45 -0
  82. package/lib/utils/runjsTemplateCompat.js +1 -1
  83. package/lib/utils/runjsValue.js +41 -11
  84. package/lib/utils/schema-utils.d.ts +7 -1
  85. package/lib/utils/schema-utils.js +19 -0
  86. package/lib/views/FlowView.d.ts +7 -1
  87. package/lib/views/FlowView.js +11 -1
  88. package/lib/views/PageComponent.js +8 -6
  89. package/lib/views/ViewNavigation.js +6 -2
  90. package/lib/views/runViewBeforeClose.d.ts +10 -0
  91. package/lib/views/runViewBeforeClose.js +45 -0
  92. package/lib/views/useDialog.d.ts +2 -1
  93. package/lib/views/useDialog.js +20 -3
  94. package/lib/views/useDrawer.d.ts +2 -1
  95. package/lib/views/useDrawer.js +20 -3
  96. package/lib/views/usePage.d.ts +5 -11
  97. package/lib/views/usePage.js +302 -144
  98. package/package.json +6 -5
  99. package/src/JSRunner.ts +68 -4
  100. package/src/ViewScopedFlowEngine.ts +4 -0
  101. package/src/__tests__/JSRunner.test.ts +27 -1
  102. package/src/__tests__/flow-engine.test.ts +166 -0
  103. package/src/__tests__/flowContext.test.ts +82 -1
  104. package/src/__tests__/flowEngine.modelLoaders.test.ts +245 -0
  105. package/src/__tests__/flowSettings.test.ts +94 -15
  106. package/src/__tests__/objectVariable.test.ts +24 -0
  107. package/src/__tests__/provider.test.tsx +24 -2
  108. package/src/__tests__/renderHiddenInConfig.test.tsx +6 -6
  109. package/src/__tests__/runjsContext.test.ts +16 -0
  110. package/src/__tests__/runjsContextRuntime.test.ts +2 -0
  111. package/src/__tests__/runjsPreprocessDefault.test.ts +23 -0
  112. package/src/__tests__/runjsSnippets.test.ts +21 -0
  113. package/src/__tests__/viewScopedFlowEngine.test.ts +3 -3
  114. package/src/components/FieldModelRenderer.tsx +2 -1
  115. package/src/components/FlowModelRenderer.tsx +18 -6
  116. package/src/components/FormItem.tsx +7 -1
  117. package/src/components/MobilePopup.tsx +4 -2
  118. package/src/components/__tests__/FlowModelRenderer.test.tsx +65 -2
  119. package/src/components/__tests__/FormItem.test.tsx +25 -0
  120. package/src/components/__tests__/dnd.test.ts +44 -0
  121. package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +20 -10
  122. package/src/components/__tests__/gridDragPlanner.test.ts +558 -3
  123. package/src/components/dnd/__tests__/DndProvider.test.tsx +98 -0
  124. package/src/components/dnd/gridDragPlanner.ts +758 -19
  125. package/src/components/dnd/index.tsx +305 -28
  126. package/src/components/settings/wrappers/component/SelectWithTitle.tsx +21 -9
  127. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +88 -10
  128. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +487 -440
  129. package/src/components/settings/wrappers/contextual/StepSettingsDialog.tsx +18 -2
  130. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +189 -3
  131. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +778 -0
  132. package/src/components/settings/wrappers/contextual/useFloatToolbarPortal.ts +360 -0
  133. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +361 -0
  134. package/src/components/subModel/AddSubModelButton.tsx +32 -2
  135. package/src/components/subModel/LazyDropdown.tsx +107 -43
  136. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +319 -36
  137. package/src/components/subModel/__tests__/utils.test.ts +24 -0
  138. package/src/components/subModel/index.ts +1 -0
  139. package/src/components/subModel/utils.ts +7 -1
  140. package/src/components/variables/VariableHybridInput.tsx +531 -0
  141. package/src/components/variables/index.ts +2 -0
  142. package/src/data-source/__tests__/collection.test.ts +41 -2
  143. package/src/data-source/__tests__/index.test.ts +68 -1
  144. package/src/data-source/index.ts +304 -6
  145. package/src/executor/FlowExecutor.ts +35 -10
  146. package/src/executor/__tests__/flowExecutor.test.ts +57 -0
  147. package/src/flow-registry/DetachedFlowRegistry.ts +46 -0
  148. package/src/flow-registry/__tests__/detachedFlowRegistry.test.ts +47 -0
  149. package/src/flow-registry/index.ts +1 -0
  150. package/src/flowContext.ts +47 -3
  151. package/src/flowEngine.ts +445 -11
  152. package/src/flowI18n.ts +2 -1
  153. package/src/flowSettings.ts +40 -6
  154. package/src/index.ts +2 -0
  155. package/src/lazy-helper.tsx +57 -0
  156. package/src/locale/en-US.json +1 -0
  157. package/src/locale/zh-CN.json +1 -0
  158. package/src/models/DisplayItemModel.tsx +1 -1
  159. package/src/models/EditableItemModel.tsx +1 -1
  160. package/src/models/FilterableItemModel.tsx +1 -1
  161. package/src/models/__tests__/dispatchEvent.when.test.ts +214 -0
  162. package/src/models/__tests__/flowModel.test.ts +47 -3
  163. package/src/models/flowModel.tsx +119 -33
  164. package/src/provider.tsx +41 -25
  165. package/src/reactive/__tests__/observer.test.tsx +82 -0
  166. package/src/reactive/observer.tsx +87 -25
  167. package/src/runjs-context/registry.ts +1 -1
  168. package/src/runjs-context/setup.ts +22 -12
  169. package/src/runjs-context/snippets/index.ts +12 -1
  170. package/src/runjs-context/snippets/scene/detail/set-field-style.snippet.ts +30 -0
  171. package/src/runjs-context/snippets/scene/table/set-cell-style.snippet.ts +34 -0
  172. package/src/scheduler/ModelOperationScheduler.ts +14 -3
  173. package/src/types.ts +62 -0
  174. package/src/utils/__tests__/createCollectionContextMeta.test.ts +48 -0
  175. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +7 -0
  176. package/src/utils/__tests__/runjsValue.test.ts +11 -0
  177. package/src/utils/__tests__/utils.test.ts +62 -0
  178. package/src/utils/createCollectionContextMeta.ts +6 -2
  179. package/src/utils/index.ts +5 -1
  180. package/src/utils/parsePathnameToViewParams.ts +2 -2
  181. package/src/utils/randomId.ts +48 -0
  182. package/src/utils/runjsTemplateCompat.ts +1 -1
  183. package/src/utils/runjsValue.ts +50 -11
  184. package/src/utils/schema-utils.ts +30 -1
  185. package/src/views/FlowView.tsx +22 -2
  186. package/src/views/PageComponent.tsx +7 -4
  187. package/src/views/ViewNavigation.ts +6 -2
  188. package/src/views/__tests__/FlowView.usePage.test.tsx +243 -3
  189. package/src/views/__tests__/runViewBeforeClose.test.ts +30 -0
  190. package/src/views/__tests__/useDialog.closeDestroy.test.tsx +13 -12
  191. package/src/views/runViewBeforeClose.ts +19 -0
  192. package/src/views/useDialog.tsx +25 -3
  193. package/src/views/useDrawer.tsx +25 -3
  194. package/src/views/usePage.tsx +365 -179
@@ -11,13 +11,23 @@ import { PopoverProps as AntdPopoverProps } from 'antd';
11
11
  import { FlowContext } from '../flowContext';
12
12
  import { ViewNavigation } from './ViewNavigation';
13
13
 
14
+ export type FlowViewBeforeClosePayload = {
15
+ result?: any;
16
+ force?: boolean;
17
+ };
18
+
19
+ export type FlowViewBeforeCloseHandler = (
20
+ payload: FlowViewBeforeClosePayload,
21
+ ) => Promise<boolean | void> | boolean | void;
22
+
14
23
  export type FlowView = {
15
24
  type: 'drawer' | 'popover' | 'dialog' | 'embed';
16
25
  inputArgs: any;
17
26
  Header: React.FC<{ title?: React.ReactNode; extra?: React.ReactNode }> | null;
18
27
  Footer: React.FC<{ children?: React.ReactNode }> | null;
19
- close: (result?: any, force?: boolean) => void;
28
+ close: (result?: any, force?: boolean) => Promise<boolean | void> | boolean | void;
20
29
  update: (newConfig: any) => void;
30
+ beforeClose?: FlowViewBeforeCloseHandler;
21
31
  navigation?: ViewNavigation;
22
32
  /** 页面的销毁方法 */
23
33
  destroy?: () => void;
@@ -74,11 +84,21 @@ export class FlowViewer {
74
84
  if (this.types[type]) {
75
85
  zIndex += 1;
76
86
  const onClose = others.onClose;
87
+ let zIndexReleased = false;
88
+ const releaseZIndex = () => {
89
+ if (!zIndexReleased) {
90
+ zIndexReleased = true;
91
+ zIndex -= 1;
92
+ }
93
+ };
77
94
  const _zIndex = others.zIndex;
78
95
  others.onClose = (...args) => {
79
96
  onClose?.(...args);
80
- zIndex -= 1;
97
+ releaseZIndex();
81
98
  };
99
+ if (type === 'embed') {
100
+ others.onOpenCancelled = releaseZIndex;
101
+ }
82
102
  // embed 不能设置过高的 zIndex,会遮挡菜单的折叠按钮图表
83
103
  if (type !== 'embed') {
84
104
  others.zIndex = _zIndex ?? this.getNextZIndex();
@@ -24,6 +24,7 @@ export const PageComponent = forwardRef((props: any, ref) => {
24
24
  title: _title,
25
25
  styles = {},
26
26
  zIndex = 4, // 这个默认值是为了防止表格的阴影显示到子页面上面
27
+ onClose,
27
28
  } = mergedProps;
28
29
  const closedRef = useRef(false);
29
30
  const flowEngine = useFlowEngine();
@@ -86,10 +87,12 @@ export const PageComponent = forwardRef((props: any, ref) => {
86
87
  type="text"
87
88
  size="small"
88
89
  icon={<CloseOutlined />}
89
- onClick={() => {
90
+ onClick={async () => {
90
91
  if (!closedRef.current) {
91
- closedRef.current = true;
92
- props.onClose?.();
92
+ const closed = await onClose?.();
93
+ if (closed !== false) {
94
+ closedRef.current = true;
95
+ }
93
96
  }
94
97
  }}
95
98
  style={{
@@ -111,7 +114,7 @@ export const PageComponent = forwardRef((props: any, ref) => {
111
114
  {extra && <div>{extra}</div>}
112
115
  </div>
113
116
  );
114
- }, [header, _title, flowEngine.context.themeToken, styles.header, props.onClose]);
117
+ }, [header, _title, flowEngine.context.themeToken, styles.header, onClose]);
115
118
 
116
119
  // Footer 组件
117
120
  const FooterComponent = useMemo(() => {
@@ -26,6 +26,10 @@ function encodeFilterByTk(val: SharedViewParam['filterByTk']): string {
26
26
  return encodeURIComponent(String(val));
27
27
  }
28
28
 
29
+ function hasUsableSourceId(sourceId: unknown): sourceId is string | number {
30
+ return sourceId !== undefined && sourceId !== null && String(sourceId) !== '';
31
+ }
32
+
29
33
  /**
30
34
  * 将 ViewParam 数组转换为 pathname
31
35
  *
@@ -65,8 +69,8 @@ export function generatePathnameFromViewParams(viewParams: ViewParams[]): string
65
69
  segments.push('filterbytk', encoded);
66
70
  }
67
71
  }
68
- if (viewParam.sourceId) {
69
- segments.push('sourceid', viewParam.sourceId);
72
+ if (hasUsableSourceId(viewParam.sourceId)) {
73
+ segments.push('sourceid', String(viewParam.sourceId));
70
74
  }
71
75
  });
72
76
 
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import React from 'react';
11
- import { describe, expect, it, beforeEach } from 'vitest';
11
+ import { describe, expect, it, beforeEach, vi } from 'vitest';
12
12
  import { render, act, waitFor, screen } from '@testing-library/react';
13
13
  import { FlowEngine } from '../../flowEngine';
14
14
  import { FlowEngineProvider } from '../../provider';
@@ -167,15 +167,16 @@ describe('FlowViewer zIndex with usePage', () => {
167
167
  );
168
168
 
169
169
  await waitFor(() => expect(api).toBeDefined());
170
+ const pageApi = api as NonNullable<typeof api>;
170
171
 
171
172
  await act(async () => {
172
- api!.open({ target, content: <div data-testid="page1">Page 1</div> }, engine.context);
173
+ pageApi.open({ target, content: <div data-testid="page1">Page 1</div> }, engine.context);
173
174
  });
174
175
  await waitFor(() => expect(screen.getByTestId('page1')).toBeInTheDocument());
175
176
 
176
177
  // Opening page2 into the global embed container should destroy page1 (replace behavior).
177
178
  await act(async () => {
178
- api!.open({ target, content: <div data-testid="page2">Page 2</div> }, engine.context);
179
+ pageApi.open({ target, content: <div data-testid="page2">Page 2</div> }, engine.context);
179
180
  });
180
181
  await waitFor(() => expect(screen.getByTestId('page2')).toBeInTheDocument());
181
182
  expect(screen.queryByTestId('page1')).not.toBeInTheDocument();
@@ -183,4 +184,243 @@ describe('FlowViewer zIndex with usePage', () => {
183
184
  unmount();
184
185
  document.body.removeChild(target);
185
186
  });
187
+
188
+ it('keeps active global embed view when replacement beforeClose blocks closing', async () => {
189
+ let getViewer: () => FlowViewer;
190
+ const beforeClose = vi.fn().mockResolvedValue(false);
191
+
192
+ const target = document.createElement('div');
193
+ target.id = GLOBAL_EMBED_CONTAINER_ID;
194
+ document.body.appendChild(target);
195
+
196
+ const { unmount } = render(
197
+ <Wrapper
198
+ onReady={(fn) => {
199
+ getViewer = fn;
200
+ }}
201
+ />,
202
+ );
203
+
204
+ await waitFor(() => expect(getViewer).toBeDefined());
205
+ const initialZIndex = getViewer().getNextZIndex();
206
+
207
+ let page1: any;
208
+ await act(async () => {
209
+ page1 = getViewer().embed({
210
+ target,
211
+ content: (currentPage) => {
212
+ currentPage.beforeClose = beforeClose;
213
+ return <div data-testid="page1">Page 1</div>;
214
+ },
215
+ });
216
+ });
217
+
218
+ await waitFor(() => expect(screen.getByTestId('page1')).toBeInTheDocument());
219
+ expect(getViewer().getNextZIndex()).toBe(initialZIndex + 1);
220
+
221
+ await act(async () => {
222
+ const page2 = getViewer().embed({ target, content: <div data-testid="page2">Page 2</div> });
223
+ await page2;
224
+ });
225
+
226
+ expect(beforeClose).toHaveBeenCalledTimes(1);
227
+ expect(screen.getByTestId('page1')).toBeInTheDocument();
228
+ expect(screen.queryByTestId('page2')).not.toBeInTheDocument();
229
+ expect(getViewer().getNextZIndex()).toBe(initialZIndex + 1);
230
+
231
+ await act(async () => {
232
+ page1.destroy();
233
+ });
234
+
235
+ unmount();
236
+ document.body.removeChild(target);
237
+ });
238
+
239
+ it('opens the replacement view after async beforeClose allows global embed replacement', async () => {
240
+ let getViewer: () => FlowViewer;
241
+ const beforeClose = vi.fn().mockResolvedValue(true);
242
+
243
+ const target = document.createElement('div');
244
+ target.id = GLOBAL_EMBED_CONTAINER_ID;
245
+ document.body.appendChild(target);
246
+
247
+ const { unmount } = render(
248
+ <Wrapper
249
+ onReady={(fn) => {
250
+ getViewer = fn;
251
+ }}
252
+ />,
253
+ );
254
+
255
+ await waitFor(() => expect(getViewer).toBeDefined());
256
+
257
+ await act(async () => {
258
+ getViewer().embed({
259
+ target,
260
+ content: (currentPage) => {
261
+ currentPage.beforeClose = beforeClose;
262
+ return <div data-testid="page1">Page 1</div>;
263
+ },
264
+ });
265
+ });
266
+
267
+ await waitFor(() => expect(screen.getByTestId('page1')).toBeInTheDocument());
268
+
269
+ await act(async () => {
270
+ getViewer().embed({ target, content: <div data-testid="page2">Page 2</div> });
271
+ });
272
+
273
+ await waitFor(() => expect(screen.getByTestId('page2')).toBeInTheDocument());
274
+ expect(beforeClose).toHaveBeenCalledTimes(1);
275
+ expect(screen.queryByTestId('page1')).not.toBeInTheDocument();
276
+
277
+ unmount();
278
+ document.body.removeChild(target);
279
+ });
280
+
281
+ it('runs a pending close only once and allows retry when beforeClose rejects', async () => {
282
+ let getViewer: () => FlowViewer;
283
+ let resolveFirstClose: (value: boolean) => void;
284
+ const beforeClose = vi
285
+ .fn()
286
+ .mockImplementationOnce(() => new Promise<boolean>((resolve) => (resolveFirstClose = resolve)))
287
+ .mockResolvedValueOnce(true);
288
+
289
+ const { unmount } = render(
290
+ <Wrapper
291
+ onReady={(fn) => {
292
+ getViewer = fn;
293
+ }}
294
+ />,
295
+ );
296
+
297
+ await waitFor(() => expect(getViewer).toBeDefined());
298
+
299
+ let page: any;
300
+ await act(async () => {
301
+ page = getViewer().embed({
302
+ content: (currentPage) => {
303
+ currentPage.beforeClose = beforeClose;
304
+ return <div data-testid="draft-editor">Draft editor</div>;
305
+ },
306
+ });
307
+ });
308
+
309
+ await waitFor(() => expect(screen.getByTestId('draft-editor')).toBeInTheDocument());
310
+
311
+ const firstClose = page.close();
312
+ const secondClose = page.close();
313
+ expect(firstClose).toBe(secondClose);
314
+ expect(beforeClose).toHaveBeenCalledTimes(1);
315
+
316
+ await act(async () => {
317
+ resolveFirstClose(false);
318
+ await firstClose;
319
+ });
320
+
321
+ expect(screen.getByTestId('draft-editor')).toBeInTheDocument();
322
+
323
+ await act(async () => {
324
+ await page.close();
325
+ });
326
+
327
+ expect(beforeClose).toHaveBeenCalledTimes(2);
328
+ await waitFor(() => expect(screen.queryByTestId('draft-editor')).not.toBeInTheDocument());
329
+
330
+ unmount();
331
+ });
332
+
333
+ it('keeps only the latest pending global embed replacement', async () => {
334
+ let getViewer: () => FlowViewer;
335
+ let resolveBeforeClose: (value: boolean) => void;
336
+ const beforeClose = vi.fn(() => new Promise<boolean>((resolve) => (resolveBeforeClose = resolve)));
337
+
338
+ const target = document.createElement('div');
339
+ target.id = GLOBAL_EMBED_CONTAINER_ID;
340
+ document.body.appendChild(target);
341
+
342
+ const { unmount } = render(
343
+ <Wrapper
344
+ onReady={(fn) => {
345
+ getViewer = fn;
346
+ }}
347
+ />,
348
+ );
349
+
350
+ await waitFor(() => expect(getViewer).toBeDefined());
351
+ const initialZIndex = getViewer().getNextZIndex();
352
+
353
+ await act(async () => {
354
+ getViewer().embed({
355
+ target,
356
+ content: (currentPage) => {
357
+ currentPage.beforeClose = beforeClose;
358
+ return <div data-testid="page1">Page 1</div>;
359
+ },
360
+ });
361
+ });
362
+
363
+ await waitFor(() => expect(screen.getByTestId('page1')).toBeInTheDocument());
364
+
365
+ const page2 = getViewer().embed({ target, content: <div data-testid="page2">Page 2</div> });
366
+ const page3 = getViewer().embed({ target, content: <div data-testid="page3">Page 3</div> });
367
+
368
+ await act(async () => {
369
+ resolveBeforeClose(true);
370
+ await page2;
371
+ });
372
+
373
+ expect(beforeClose).toHaveBeenCalledTimes(1);
374
+ expect(screen.queryByTestId('page1')).not.toBeInTheDocument();
375
+ expect(screen.queryByTestId('page2')).not.toBeInTheDocument();
376
+ await waitFor(() => expect(screen.getByTestId('page3')).toBeInTheDocument());
377
+ expect(getViewer().getNextZIndex()).toBe(initialZIndex + 1);
378
+
379
+ unmount();
380
+ document.body.removeChild(target);
381
+ });
382
+
383
+ it('keeps the embed close button usable after beforeClose blocks closing', async () => {
384
+ let getViewer: () => FlowViewer;
385
+ const beforeClose = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true);
386
+
387
+ const { unmount } = render(
388
+ <Wrapper
389
+ onReady={(fn) => {
390
+ getViewer = fn;
391
+ }}
392
+ />,
393
+ );
394
+
395
+ await waitFor(() => expect(getViewer).toBeDefined());
396
+
397
+ await act(async () => {
398
+ getViewer().embed({
399
+ title: 'Draft editor',
400
+ content: (currentPage) => {
401
+ currentPage.beforeClose = beforeClose;
402
+ return <div data-testid="draft-editor">Draft editor</div>;
403
+ },
404
+ });
405
+ });
406
+
407
+ await waitFor(() => expect(screen.getByTestId('draft-editor')).toBeInTheDocument());
408
+ const closeButton = screen.getByRole('button');
409
+
410
+ await act(async () => {
411
+ closeButton.click();
412
+ });
413
+
414
+ expect(beforeClose).toHaveBeenCalledTimes(1);
415
+ expect(screen.getByTestId('draft-editor')).toBeInTheDocument();
416
+
417
+ await act(async () => {
418
+ closeButton.click();
419
+ });
420
+
421
+ expect(beforeClose).toHaveBeenCalledTimes(2);
422
+ await waitFor(() => expect(screen.queryByTestId('draft-editor')).not.toBeInTheDocument());
423
+
424
+ unmount();
425
+ });
186
426
  });
@@ -0,0 +1,30 @@
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 { runViewBeforeClose } from '../runViewBeforeClose';
12
+
13
+ describe('runViewBeforeClose', () => {
14
+ it('returns true when no beforeClose handler is configured', async () => {
15
+ await expect(runViewBeforeClose({} as any, { force: false })).resolves.toBe(true);
16
+ });
17
+
18
+ it('skips beforeClose handler for force close', async () => {
19
+ const beforeClose = vi.fn();
20
+
21
+ await expect(runViewBeforeClose({ beforeClose } as any, { force: true })).resolves.toBe(true);
22
+ expect(beforeClose).not.toHaveBeenCalled();
23
+ });
24
+
25
+ it('returns false when beforeClose handler blocks the close', async () => {
26
+ const beforeClose = vi.fn().mockResolvedValue(false);
27
+
28
+ await expect(runViewBeforeClose({ beforeClose } as any, { force: false })).resolves.toBe(false);
29
+ });
30
+ });
@@ -26,6 +26,7 @@ vi.mock('../../ViewScopedFlowEngine', () => ({
26
26
  createViewScopedEngine: (engine) => ({
27
27
  context: new FlowContext(),
28
28
  unlinkFromStack: vi.fn(),
29
+ setDestroyView: vi.fn(),
29
30
  // mimic real view stack linkage: previousEngine points to the last engine in chain
30
31
  previousEngine: (engine as any)?.nextEngine || engine,
31
32
  }),
@@ -75,40 +76,40 @@ describe('useDialog - close/destroy logic', () => {
75
76
  return api;
76
77
  };
77
78
 
78
- it('should call destroy (and thus closeFunc) when close is called without preventClose', () => {
79
+ it('should call destroy (and thus closeFunc) when close is called without preventClose', async () => {
79
80
  const api = renderUseDialog();
80
81
  const flowContext = createMockFlowContext();
81
82
 
82
83
  const dialog = api.open({}, flowContext);
83
84
 
84
- dialog.close();
85
+ await dialog.close();
85
86
 
86
87
  expect(mockCloseFunc).toHaveBeenCalled();
87
88
  });
88
89
 
89
- it('should not call destroy (and thus closeFunc) when close is called with preventClose=true', () => {
90
+ it('should not call destroy (and thus closeFunc) when close is called with preventClose=true', async () => {
90
91
  const api = renderUseDialog();
91
92
  const flowContext = createMockFlowContext();
92
93
 
93
94
  const dialog = api.open({ preventClose: true }, flowContext);
94
95
 
95
- dialog.close();
96
+ await dialog.close();
96
97
 
97
98
  expect(mockCloseFunc).not.toHaveBeenCalled();
98
99
  });
99
100
 
100
- it('should call destroy (and thus closeFunc) when close is called with preventClose=true but force=true', () => {
101
+ it('should call destroy (and thus closeFunc) when close is called with preventClose=true but force=true', async () => {
101
102
  const api = renderUseDialog();
102
103
  const flowContext = createMockFlowContext();
103
104
 
104
105
  const dialog = api.open({ preventClose: true }, flowContext);
105
106
 
106
- dialog.close(undefined, true);
107
+ await dialog.close(undefined, true);
107
108
 
108
109
  expect(mockCloseFunc).toHaveBeenCalled();
109
110
  });
110
111
 
111
- it('should delegate to navigation.back when triggerByRouter is true', () => {
112
+ it('should delegate to navigation.back when triggerByRouter is true', async () => {
112
113
  const api = renderUseDialog();
113
114
  const flowContext = createMockFlowContext();
114
115
  const backMock = vi.fn();
@@ -125,25 +126,25 @@ describe('useDialog - close/destroy logic', () => {
125
126
  flowContext,
126
127
  );
127
128
 
128
- dialog.close();
129
+ await dialog.close();
129
130
 
130
131
  expect(backMock).toHaveBeenCalled();
131
132
  // Should not call destroy directly, let router handle it
132
133
  expect(mockCloseFunc).not.toHaveBeenCalled();
133
134
  });
134
135
 
135
- it('should emit view activated event on opener engine', () => {
136
+ it('should emit view activated event on opener engine', async () => {
136
137
  const api = renderUseDialog();
137
138
  const flowContext = createMockFlowContext();
138
139
  const emitSpy = flowContext.engine.emitter.emit;
139
140
 
140
141
  const dialog = api.open({ inputArgs: { viewUid: 'child-view' } }, flowContext);
141
142
 
142
- dialog.close();
143
+ await dialog.close();
143
144
  expect(emitSpy).toHaveBeenCalledWith('view:activated', expect.anything());
144
145
  });
145
146
 
146
- it('should emit view events on immediate opener engine (previousEngine) when present', () => {
147
+ it('should emit view events on immediate opener engine (previousEngine) when present', async () => {
147
148
  const api = renderUseDialog();
148
149
  const flowContext = createMockFlowContext();
149
150
  const rootEmitSpy = flowContext.engine.emitter.emit;
@@ -152,7 +153,7 @@ describe('useDialog - close/destroy logic', () => {
152
153
 
153
154
  const dialog = api.open({ inputArgs: { viewUid: 'child-view' } }, flowContext);
154
155
 
155
- dialog.close();
156
+ await dialog.close();
156
157
  expect(openerEmitSpy).toHaveBeenCalledWith('view:activated', expect.anything());
157
158
  expect(rootEmitSpy).not.toHaveBeenCalledWith('view:activated', expect.anything());
158
159
  });
@@ -0,0 +1,19 @@
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 type { FlowView, FlowViewBeforeClosePayload } from './FlowView';
11
+
12
+ export async function runViewBeforeClose(view: FlowView, payload: FlowViewBeforeClosePayload): Promise<boolean> {
13
+ if (payload.force) {
14
+ return true;
15
+ }
16
+
17
+ const result = await view.beforeClose?.(payload);
18
+ return result !== false;
19
+ }
@@ -19,6 +19,7 @@ import { VIEW_ACTIVATED_EVENT, bumpViewActivatedVersion, resolveOpenerEngine } f
19
19
  import { FlowEngineProvider } from '../provider';
20
20
  import { createViewScopedEngine } from '../ViewScopedFlowEngine';
21
21
  import { createViewRecordResolveOnServer, getViewRecordFromParent } from '../utils/variablesParams';
22
+ import { runViewBeforeClose } from './runViewBeforeClose';
22
23
 
23
24
  let uuid = 0;
24
25
 
@@ -89,12 +90,18 @@ export function useDialog() {
89
90
  ctx.addDelegate(flowContext.engine.context);
90
91
  }
91
92
 
93
+ // 幂等保护:防止 FlowPage 路由清理时二次调用 destroy
94
+ let destroyed = false;
95
+
92
96
  // 构造 currentDialog 实例
93
97
  const currentDialog = {
94
98
  type: 'dialog' as const,
95
99
  inputArgs: config.inputArgs || {},
96
100
  preventClose: !!config.preventClose,
101
+ beforeClose: undefined,
97
102
  destroy: (result?: any) => {
103
+ if (destroyed) return;
104
+ destroyed = true;
98
105
  config.onClose?.();
99
106
  dialogRef.current?.destroy();
100
107
  closeFunc?.();
@@ -107,18 +114,24 @@ export function useDialog() {
107
114
  scopedEngine.unlinkFromStack();
108
115
  },
109
116
  update: (newConfig) => dialogRef.current?.update(newConfig),
110
- close: (result?: any, force?: boolean) => {
117
+ close: async (result?: any, force?: boolean) => {
111
118
  if (config.preventClose && !force) {
112
- return;
119
+ return false;
120
+ }
121
+
122
+ const shouldClose = await runViewBeforeClose(currentDialog, { result, force });
123
+ if (!shouldClose) {
124
+ return false;
113
125
  }
114
126
 
115
127
  if (config.triggerByRouter && config.inputArgs?.navigation?.back) {
116
128
  // 交由路由系统来销毁当前视图
117
129
  config.inputArgs.navigation.back();
118
- return;
130
+ return true;
119
131
  }
120
132
 
121
133
  currentDialog.destroy(result);
134
+ return true;
122
135
  },
123
136
  Footer: FooterComponent,
124
137
  Header: HeaderComponent,
@@ -140,6 +153,15 @@ export function useDialog() {
140
153
  get: () => currentDialog,
141
154
  resolveOnServer: createViewRecordResolveOnServer(ctx, () => getViewRecordFromParent(flowContext, ctx)),
142
155
  });
156
+ // 注册视图销毁回调,供外部(如 afterSuccess)通过引擎栈遍历来关闭多层弹窗。
157
+ // 对路由触发的弹窗:先 navigation.back() 清理 URL(replace 方式),再 destroy() 立即移除元素;
158
+ // 对非路由弹窗:直接 destroy()。destroy() 已做幂等保护,FlowPage 后续清理不会重复执行。
159
+ scopedEngine.setDestroyView(() => {
160
+ if (config.triggerByRouter && config.inputArgs?.navigation?.back) {
161
+ config.inputArgs.navigation.back();
162
+ }
163
+ currentDialog.destroy();
164
+ });
143
165
  // 顶层 popup 变量:弹窗记录/数据源/上级弹窗链(去重封装)
144
166
  registerPopupVariable(ctx, currentDialog);
145
167
  // 内部组件,在 Provider 内部计算 content
@@ -19,6 +19,7 @@ import { VIEW_ACTIVATED_EVENT, bumpViewActivatedVersion, resolveOpenerEngine } f
19
19
  import { FlowEngineProvider } from '../provider';
20
20
  import { createViewScopedEngine } from '../ViewScopedFlowEngine';
21
21
  import { createViewRecordResolveOnServer, getViewRecordFromParent } from '../utils/variablesParams';
22
+ import { runViewBeforeClose } from './runViewBeforeClose';
22
23
 
23
24
  export function useDrawer() {
24
25
  const holderRef = React.useRef(null);
@@ -118,12 +119,18 @@ export function useDrawer() {
118
119
  ctx.addDelegate(flowContext.engine.context);
119
120
  }
120
121
 
122
+ // 幂等保护:防止 FlowPage 路由清理时二次调用 destroy
123
+ let destroyed = false;
124
+
121
125
  // 构造 currentDrawer 实例
122
126
  const currentDrawer = {
123
127
  type: 'drawer' as const,
124
128
  inputArgs: config.inputArgs || {},
125
129
  preventClose: !!config.preventClose,
130
+ beforeClose: undefined,
126
131
  destroy: (result?: any) => {
132
+ if (destroyed) return;
133
+ destroyed = true;
127
134
  config.onClose?.();
128
135
  drawerRef.current?.destroy();
129
136
  closeFunc?.();
@@ -136,18 +143,24 @@ export function useDrawer() {
136
143
  scopedEngine.unlinkFromStack();
137
144
  },
138
145
  update: (newConfig) => drawerRef.current?.update(newConfig),
139
- close: (result?: any, force?: boolean) => {
146
+ close: async (result?: any, force?: boolean) => {
140
147
  if (config.preventClose && !force) {
141
- return;
148
+ return false;
149
+ }
150
+
151
+ const shouldClose = await runViewBeforeClose(currentDrawer, { result, force });
152
+ if (!shouldClose) {
153
+ return false;
142
154
  }
143
155
 
144
156
  if (config.triggerByRouter && config.inputArgs?.navigation?.back) {
145
157
  // 交由路由系统来销毁当前视图
146
158
  config.inputArgs.navigation.back();
147
- return;
159
+ return true;
148
160
  }
149
161
 
150
162
  currentDrawer.destroy(result);
163
+ return true;
151
164
  },
152
165
  Footer: FooterComponent,
153
166
  Header: HeaderComponent,
@@ -169,6 +182,15 @@ export function useDrawer() {
169
182
  get: () => currentDrawer,
170
183
  resolveOnServer: createViewRecordResolveOnServer(ctx, () => getViewRecordFromParent(flowContext, ctx)),
171
184
  });
185
+ // 注册视图销毁回调,供外部(如 afterSuccess)通过引擎栈遍历来关闭多层弹窗。
186
+ // 对路由触发的弹窗:先 navigation.back() 清理 URL(replace 方式),再 destroy() 立即移除元素;
187
+ // 对非路由弹窗:直接 destroy()。destroy() 已做幂等保护,FlowPage 后续清理不会重复执行。
188
+ scopedEngine.setDestroyView(() => {
189
+ if (config.triggerByRouter && config.inputArgs?.navigation?.back) {
190
+ config.inputArgs.navigation.back();
191
+ }
192
+ currentDrawer.destroy();
193
+ });
172
194
  // 顶层 popup 变量:弹窗记录/数据源/上级弹窗链(去重封装)
173
195
  registerPopupVariable(ctx, currentDrawer);
174
196