@nocobase/flow-engine 2.0.51 → 2.0.53

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.
@@ -3034,6 +3034,7 @@ class BaseFlowEngineContext extends FlowContext {
3034
3034
  declare runAction: (actionName: string, params?: Record<string, any>) => Promise<any> | any;
3035
3035
  declare engine: FlowEngine;
3036
3036
  declare api: APIClient;
3037
+ declare locale: string;
3037
3038
  declare viewer: FlowViewer;
3038
3039
  declare view: FlowView;
3039
3040
  declare modal: HookAPI;
@@ -3144,6 +3145,15 @@ export class FlowEngineContext extends BaseFlowEngineContext {
3144
3145
  this.defineMethod('t', (keyOrTemplate: string, options?: any) => {
3145
3146
  return i18n.translate(keyOrTemplate, options);
3146
3147
  });
3148
+ this.defineProperty('locale', {
3149
+ get: () => this.api?.auth?.locale || this.i18n?.language,
3150
+ cache: false,
3151
+ meta: Object.assign(() => ({ type: 'string', title: this.t('Current language'), sort: 970 }), {
3152
+ title: escapeT('Current language'),
3153
+ sort: 970,
3154
+ hasChildren: false,
3155
+ }),
3156
+ });
3147
3157
  this.defineMethod('renderJson', function (template: any) {
3148
3158
  return this.resolveJsonTemplate(template);
3149
3159
  });
package/src/index.ts CHANGED
@@ -57,5 +57,7 @@ export {
57
57
  } from './views/viewEvents';
58
58
 
59
59
  export * from './FlowDefinition';
60
+ export { DetachedFlowRegistry, replaceFlowRegistry, serializeFlowRegistry } from './flow-registry';
61
+ export type { FlowRegistryData } from './flow-registry';
60
62
  export { createViewScopedEngine } from './ViewScopedFlowEngine';
61
63
  export { createBlockScopedEngine } from './BlockScopedFlowEngine';
@@ -84,11 +84,21 @@ export class FlowViewer {
84
84
  if (this.types[type]) {
85
85
  zIndex += 1;
86
86
  const onClose = others.onClose;
87
+ let zIndexReleased = false;
88
+ const releaseZIndex = () => {
89
+ if (!zIndexReleased) {
90
+ zIndexReleased = true;
91
+ zIndex -= 1;
92
+ }
93
+ };
87
94
  const _zIndex = others.zIndex;
88
95
  others.onClose = (...args) => {
89
96
  onClose?.(...args);
90
- zIndex -= 1;
97
+ releaseZIndex();
91
98
  };
99
+ if (type === 'embed') {
100
+ others.onOpenCancelled = releaseZIndex;
101
+ }
92
102
  // embed 不能设置过高的 zIndex,会遮挡菜单的折叠按钮图表
93
103
  if (type !== 'embed') {
94
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(() => {
@@ -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
  });