@nocobase/client-v2 2.2.0-beta.8 → 2.2.0-beta.9

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 (65) hide show
  1. package/es/BaseApplication.d.ts +2 -0
  2. package/es/RouteRepository.d.ts +8 -0
  3. package/es/RouterManager.d.ts +15 -0
  4. package/es/authRedirect.d.ts +1 -0
  5. package/es/entry-actions/EntryActionManager.d.ts +24 -0
  6. package/es/entry-actions/index.d.ts +9 -0
  7. package/es/flow/admin-shell/BaseLayoutModel.d.ts +6 -0
  8. package/es/flow/admin-shell/BaseLayoutRouteCoordinator.d.ts +7 -0
  9. package/es/flow/admin-shell/admin-layout/AppListRender.d.ts +2 -1
  10. package/es/flow/admin-shell/admin-layout/AppSwitcherActionPanelModel.d.ts +24 -0
  11. package/es/flow/admin-shell/admin-layout/index.d.ts +1 -0
  12. package/es/flow/admin-shell/admin-layout/useApplications.d.ts +7 -2
  13. package/es/flow/models/base/ActionModelCore.d.ts +2 -0
  14. package/es/flow/routeTransientInputArgs.d.ts +14 -0
  15. package/es/index.d.ts +2 -0
  16. package/es/index.mjs +212 -126
  17. package/es/utils/markdownSanitize.d.ts +11 -0
  18. package/lib/index.js +198 -112
  19. package/package.json +7 -7
  20. package/src/BaseApplication.tsx +2 -0
  21. package/src/RouteRepository.ts +25 -0
  22. package/src/RouterManager.tsx +142 -1
  23. package/src/__tests__/RouteRepository.test.ts +23 -0
  24. package/src/__tests__/RouterManager.test.ts +125 -0
  25. package/src/__tests__/authRedirect.test.ts +17 -0
  26. package/src/authRedirect.ts +38 -0
  27. package/src/entry-actions/EntryActionManager.ts +76 -0
  28. package/src/entry-actions/index.ts +10 -0
  29. package/src/flow/FlowPage.tsx +29 -2
  30. package/src/flow/__tests__/FlowPage.test.tsx +152 -25
  31. package/src/flow/__tests__/FlowRoute.test.tsx +547 -2
  32. package/src/flow/actions/__tests__/dataScopeFilter.test.ts +62 -0
  33. package/src/flow/actions/__tests__/openView.defineProps.route.test.tsx +80 -20
  34. package/src/flow/actions/dataScopeFilter.ts +10 -1
  35. package/src/flow/actions/dateTimeFormat.tsx +2 -2
  36. package/src/flow/actions/openView.tsx +21 -1
  37. package/src/flow/admin-shell/BaseLayoutModel.tsx +124 -0
  38. package/src/flow/admin-shell/BaseLayoutRouteCoordinator.ts +184 -42
  39. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +1016 -119
  40. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +41 -4
  41. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.test.tsx +177 -1
  42. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +20 -0
  43. package/src/flow/admin-shell/admin-layout/AppListRender.tsx +13 -2
  44. package/src/flow/admin-shell/admin-layout/AppSwitcherActionPanelModel.tsx +289 -0
  45. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +436 -2
  46. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +48 -0
  47. package/src/flow/admin-shell/admin-layout/index.ts +1 -0
  48. package/src/flow/admin-shell/admin-layout/useApplications.tsx +81 -12
  49. package/src/flow/common/Markdown/Display.tsx +14 -3
  50. package/src/flow/common/Markdown/Edit.tsx +19 -7
  51. package/src/flow/components/FlowRoute.tsx +128 -16
  52. package/src/flow/internal/components/Markdown/util.ts +2 -1
  53. package/src/flow/models/base/ActionModel.tsx +10 -8
  54. package/src/flow/models/base/ActionModelCore.tsx +5 -0
  55. package/src/flow/models/blocks/table/TableColumnModel.tsx +6 -2
  56. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +51 -0
  57. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +45 -13
  58. package/src/flow/models/fields/TextareaFieldModel.tsx +42 -19
  59. package/src/flow/models/fields/__tests__/TextareaFieldModel.test.tsx +32 -1
  60. package/src/flow/models/topbar/TopbarActionModel.tsx +78 -3
  61. package/src/flow/routeTransientInputArgs.ts +27 -0
  62. package/src/flow/utils/__tests__/dateTimeFormat.test.ts +42 -0
  63. package/src/index.ts +2 -0
  64. package/src/nocobase-buildin-plugin/index.tsx +7 -1
  65. package/src/utils/markdownSanitize.ts +88 -0
@@ -7,14 +7,16 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { FlowContext, FlowEngine } from '@nocobase/flow-engine';
10
+ import { FlowContext, FlowEngine, type FlowModel } from '@nocobase/flow-engine';
11
+ import type React from 'react';
11
12
  import { describe, expect, it, vi, beforeEach } from 'vitest';
12
13
  import { getViewDiffAndUpdateHidden } from '../../getViewDiffAndUpdateHidden';
13
14
  import { getOpenViewStepParams } from '../../flows/openViewFlow';
14
- import { resolveViewParamsToViewList, updateViewListHidden } from '../../resolveViewParamsToViewList';
15
+ import { resolveViewParamsToViewList, updateViewListHidden, type ViewItem } from '../../resolveViewParamsToViewList';
15
16
  import { AdminLayoutRouteCoordinator } from '../AdminLayoutRouteCoordinator';
16
17
  import { BaseLayoutRouteCoordinator, toViewStack } from '../BaseLayoutRouteCoordinator';
17
18
  import { RouteModel } from '../../models/base/RouteModel';
19
+ import { ROUTE_TRANSIENT_INPUT_ARGS_KEY } from '../../routeTransientInputArgs';
18
20
 
19
21
  vi.mock('../../resolveViewParamsToViewList', () => ({
20
22
  resolveViewParamsToViewList: vi.fn(),
@@ -27,9 +29,9 @@ vi.mock('../../getViewDiffAndUpdateHidden', () => ({
27
29
  }));
28
30
 
29
31
  vi.mock('../../flows/openViewFlow', async (importOriginal) => {
30
- const actual = await importOriginal();
32
+ const actual = await importOriginal<typeof import('../../flows/openViewFlow')>();
31
33
  return {
32
- ...(actual as any),
34
+ ...actual,
33
35
  getOpenViewStepParams: vi.fn(),
34
36
  };
35
37
  });
@@ -39,7 +41,48 @@ const mockGetViewDiffAndUpdateHidden = vi.mocked(getViewDiffAndUpdateHidden);
39
41
  const mockGetOpenViewStepParams = vi.mocked(getOpenViewStepParams);
40
42
  const mockUpdateViewListHidden = vi.mocked(updateViewListHidden);
41
43
 
42
- function setupRouteReplay(viewParams: Record<string, any>) {
44
+ type RouteOpenPayload = {
45
+ target?: HTMLElement | null;
46
+ destroyRef: React.RefObject<(result?: unknown, force?: boolean) => void>;
47
+ updateRef: React.RefObject<(value: unknown) => void>;
48
+ activateRef: React.RefObject<(forceRefresh?: boolean) => void>;
49
+ deactivateRef: React.RefObject<() => void>;
50
+ onOpen: () => void;
51
+ pageActive: boolean;
52
+ activationControlledByLayout: boolean;
53
+ hidden: { value: boolean };
54
+ triggerByRouter: boolean;
55
+ [key: string]: unknown;
56
+ };
57
+
58
+ type DispatchEventMock = ReturnType<typeof createDispatchEventMock>;
59
+
60
+ const createDispatchEventMock = (
61
+ implementation?: (eventName: string, payload: RouteOpenPayload) => Promise<unknown[] | void>,
62
+ ) => vi.fn((eventName: string, payload: RouteOpenPayload) => implementation?.(eventName, payload) ?? Promise.resolve());
63
+
64
+ const createViewModel = (uid: string, dispatchEvent: DispatchEventMock) =>
65
+ ({
66
+ uid,
67
+ dispatchEvent,
68
+ }) as unknown as FlowModel;
69
+
70
+ const createViewItem = (
71
+ uid: string,
72
+ dispatchEvent: DispatchEventMock,
73
+ index = 0,
74
+ params: ViewItem['params'] = { viewUid: uid },
75
+ ): ViewItem => ({
76
+ params,
77
+ modelUid: uid,
78
+ model: createViewModel(uid, dispatchEvent),
79
+ hidden: { value: false },
80
+ index,
81
+ });
82
+
83
+ const nextTick = () => new Promise((resolve) => setTimeout(resolve, 0));
84
+
85
+ function setupRouteReplay(viewParams: Record<string, unknown>) {
43
86
  const engine = new FlowEngine();
44
87
  engine.registerModels({ RouteModel });
45
88
  engine.context.defineProperty('route', {
@@ -54,87 +97,832 @@ function setupRouteReplay(viewParams: Record<string, any>) {
54
97
  },
55
98
  });
56
99
 
57
- const dispatchEvent = vi.fn((_eventName: string, _payload: any) => Promise.resolve());
58
- const viewItem = {
59
- params: {
60
- viewUid: 'popup',
61
- filterByTk: 'member',
62
- ...viewParams,
63
- },
64
- modelUid: 'popup',
65
- model: { uid: 'popup', dispatchEvent } as any,
66
- hidden: { value: false },
67
- index: 0,
68
- };
100
+ const dispatchEvent = createDispatchEventMock();
101
+ const viewItem = createViewItem('popup', dispatchEvent, 0, {
102
+ viewUid: 'popup',
103
+ filterByTk: 'member',
104
+ ...viewParams,
105
+ });
106
+
107
+ mockResolveViewParamsToViewList.mockReturnValue([viewItem]);
108
+ mockGetViewDiffAndUpdateHidden.mockReturnValue({
109
+ viewsToClose: [],
110
+ viewsToOpen: [viewItem],
111
+ });
112
+ mockGetOpenViewStepParams.mockReturnValue({
113
+ collectionName: 'roles',
114
+ associationName: 'users.roles',
115
+ dataSourceKey: 'main',
116
+ });
117
+
118
+ const coordinator = new AdminLayoutRouteCoordinator(engine);
119
+ coordinator.registerPage('test-route', {
120
+ active: true,
121
+ layoutContentElement: document.createElement('div'),
122
+ });
123
+ coordinator.syncRoute({
124
+ params: { name: 'test-route' },
125
+ pathname: '/admin/popup/filterbytk/member',
126
+ });
127
+
128
+ return { dispatchEvent };
129
+ }
130
+
131
+ describe('AdminLayoutRouteCoordinator', () => {
132
+ beforeEach(() => {
133
+ vi.clearAllMocks();
134
+ });
135
+
136
+ it('drops configured associationName during route replay when sourceId is absent', () => {
137
+ const { dispatchEvent } = setupRouteReplay({});
138
+
139
+ expect(dispatchEvent.mock.calls[0][1]).toMatchObject({
140
+ collectionName: 'roles',
141
+ associationName: null,
142
+ dataSourceKey: 'main',
143
+ filterByTk: 'member',
144
+ triggerByRouter: true,
145
+ });
146
+ });
147
+
148
+ it('keeps configured associationName during route replay when sourceId is present', () => {
149
+ const { dispatchEvent } = setupRouteReplay({ sourceId: '1' });
150
+
151
+ expect(dispatchEvent.mock.calls[0][1]).toMatchObject({
152
+ collectionName: 'roles',
153
+ associationName: 'users.roles',
154
+ dataSourceKey: 'main',
155
+ filterByTk: 'member',
156
+ sourceId: '1',
157
+ triggerByRouter: true,
158
+ });
159
+ });
160
+
161
+ it('passes RunJS openView route state as runtime mode and size during route replay', () => {
162
+ const { dispatchEvent } = setupRouteReplay({
163
+ openViewRouteState: { mode: 'dialog', size: 'large' },
164
+ });
165
+
166
+ expect(dispatchEvent.mock.calls[0][1]).toMatchObject({
167
+ mode: 'dialog',
168
+ size: 'large',
169
+ openViewRouteState: { mode: 'dialog', size: 'large' },
170
+ triggerByRouter: true,
171
+ });
172
+ });
173
+
174
+ it('uses layout content element before route page placeholder as view target', () => {
175
+ const engine = new FlowEngine();
176
+ engine.registerModels({ RouteModel });
177
+ engine.context.defineProperty('route', {
178
+ value: {},
179
+ });
180
+ engine.context.defineProperty('routeRepository', {
181
+ value: {
182
+ getRouteBySchemaUid: vi.fn(() => ({})),
183
+ },
184
+ });
185
+
186
+ const layoutContentElement = document.createElement('div');
187
+ const routePageElement = document.createElement('div');
188
+ const dispatchEvent = createDispatchEventMock();
189
+ const viewItem = createViewItem('test-route', dispatchEvent);
190
+ mockResolveViewParamsToViewList.mockReturnValue([viewItem]);
191
+ mockGetViewDiffAndUpdateHidden.mockReturnValue({
192
+ viewsToClose: [],
193
+ viewsToOpen: [viewItem],
194
+ });
195
+ mockGetOpenViewStepParams.mockReturnValue({
196
+ collectionName: '',
197
+ dataSourceKey: '',
198
+ });
199
+
200
+ const coordinator = new BaseLayoutRouteCoordinator(engine, { basePathname: '/admin' });
201
+ coordinator.setLayoutContentElement(layoutContentElement);
202
+ coordinator.registerPage('test-route', {
203
+ active: true,
204
+ layoutContentElement: routePageElement,
205
+ });
206
+ coordinator.syncRoute({
207
+ pageUid: 'test-route',
208
+ pathname: '/admin/test-route',
209
+ });
210
+
211
+ expect(dispatchEvent.mock.calls[0][1].target).toBe(layoutContentElement);
212
+ });
213
+
214
+ it('passes transient route state input args to the opened view', () => {
215
+ const engine = new FlowEngine();
216
+ engine.registerModels({ RouteModel });
217
+ engine.context.defineProperty('routeRepository', {
218
+ value: {
219
+ getRouteBySchemaUid: vi.fn(() => ({})),
220
+ },
221
+ });
222
+
223
+ const dispatchEvent = createDispatchEventMock();
224
+ const viewItem = createViewItem('test-route', dispatchEvent);
225
+ mockResolveViewParamsToViewList.mockReturnValue([viewItem]);
226
+ mockGetViewDiffAndUpdateHidden.mockReturnValue({
227
+ viewsToClose: [],
228
+ viewsToOpen: [viewItem],
229
+ });
230
+ mockGetOpenViewStepParams.mockReturnValue({
231
+ collectionName: '',
232
+ dataSourceKey: '',
233
+ });
234
+
235
+ const coordinator = new BaseLayoutRouteCoordinator(engine, { basePathname: '/admin' });
236
+ coordinator.registerPage('test-route', {
237
+ active: true,
238
+ layoutContentElement: document.createElement('div'),
239
+ });
240
+ coordinator.syncRoute({
241
+ pageUid: 'test-route',
242
+ pathname: '/admin/test-route',
243
+ state: {
244
+ usr: {
245
+ [ROUTE_TRANSIENT_INPUT_ARGS_KEY]: {
246
+ 'test-route': {
247
+ formData: {
248
+ start: '2026-06-24 08:00:00',
249
+ end: '2026-06-24 09:00:00',
250
+ },
251
+ },
252
+ },
253
+ },
254
+ },
255
+ });
256
+
257
+ expect(dispatchEvent.mock.calls[0][1]).toMatchObject({
258
+ formData: {
259
+ start: '2026-06-24 08:00:00',
260
+ end: '2026-06-24 09:00:00',
261
+ },
262
+ });
263
+ });
264
+
265
+ it('keeps transient route state input args during initial deep-link replay', async () => {
266
+ const engine = new FlowEngine();
267
+ engine.registerModels({ RouteModel });
268
+ const navigate = vi.fn();
269
+ engine.context.defineProperty('router', {
270
+ value: {
271
+ navigate,
272
+ },
273
+ });
274
+ engine.context.defineProperty('routeRepository', {
275
+ value: {
276
+ getRouteBySchemaUid: vi.fn(() => ({})),
277
+ },
278
+ });
279
+
280
+ const rootDispatchEvent = createDispatchEventMock((_eventName, payload) => {
281
+ payload.onOpen?.();
282
+ return Promise.resolve();
283
+ });
284
+ const popupDispatchEvent = createDispatchEventMock();
285
+ const rootViewItem = createViewItem('test-route', rootDispatchEvent);
286
+ const popupViewItem = createViewItem('popup', popupDispatchEvent, 1);
287
+
288
+ mockResolveViewParamsToViewList.mockReturnValue([rootViewItem, popupViewItem]);
289
+ mockGetViewDiffAndUpdateHidden.mockReturnValue({
290
+ viewsToClose: [],
291
+ viewsToOpen: [rootViewItem, popupViewItem],
292
+ });
293
+ mockGetOpenViewStepParams.mockReturnValue({
294
+ collectionName: '',
295
+ dataSourceKey: '',
296
+ });
297
+
298
+ const routeState = {
299
+ [ROUTE_TRANSIENT_INPUT_ARGS_KEY]: {
300
+ popup: {
301
+ formData: {
302
+ start: '2026-06-24',
303
+ end: '2026-06-25',
304
+ },
305
+ },
306
+ },
307
+ };
308
+ const coordinator = new BaseLayoutRouteCoordinator(engine, { basePathname: '/admin' });
309
+ coordinator.registerPage('test-route', {
310
+ active: true,
311
+ layoutContentElement: document.createElement('div'),
312
+ });
313
+ coordinator.syncRoute({
314
+ pageUid: 'test-route',
315
+ pathname: '/admin/test-route/view/popup',
316
+ state: routeState,
317
+ });
318
+
319
+ expect(navigate).toHaveBeenCalledWith('/admin/test-route', { replace: true, state: routeState });
320
+ expect(navigate).toHaveBeenCalledWith('/admin/test-route/view/popup', { state: routeState });
321
+ expect(popupDispatchEvent).not.toHaveBeenCalled();
322
+
323
+ await Promise.resolve();
324
+
325
+ expect(popupDispatchEvent.mock.calls[0][1]).toMatchObject({
326
+ formData: {
327
+ start: '2026-06-24',
328
+ end: '2026-06-25',
329
+ },
330
+ });
331
+ });
332
+
333
+ it('replays the active route view when the layout content element changes', () => {
334
+ const engine = new FlowEngine();
335
+ engine.registerModels({ RouteModel });
336
+ engine.context.defineProperty('route', {
337
+ value: {},
338
+ });
339
+ engine.context.defineProperty('routeRepository', {
340
+ value: {
341
+ getRouteBySchemaUid: vi.fn(() => ({})),
342
+ },
343
+ });
344
+
345
+ const firstDestroy = vi.fn();
346
+ const dispatchEvent = createDispatchEventMock((_eventName, payload) => {
347
+ if (dispatchEvent.mock.calls.length === 1) {
348
+ payload.destroyRef.current = firstDestroy;
349
+ }
350
+ return Promise.resolve();
351
+ });
352
+ const viewItem = createViewItem('test-route', dispatchEvent);
353
+ mockResolveViewParamsToViewList.mockReturnValue([viewItem]);
354
+ mockGetViewDiffAndUpdateHidden.mockReturnValue({
355
+ viewsToClose: [],
356
+ viewsToOpen: [viewItem],
357
+ });
358
+ mockGetOpenViewStepParams.mockReturnValue({
359
+ collectionName: '',
360
+ dataSourceKey: '',
361
+ });
362
+
363
+ const firstLayoutContentElement = document.createElement('div');
364
+ const secondLayoutContentElement = document.createElement('div');
365
+ const routePageElement = document.createElement('div');
366
+
367
+ const coordinator = new BaseLayoutRouteCoordinator(engine, { basePathname: '/admin' });
368
+ coordinator.setLayoutContentElement(firstLayoutContentElement);
369
+ const routeModel = coordinator.registerPage('test-route', {
370
+ active: true,
371
+ layoutContentElement: routePageElement,
372
+ });
373
+ coordinator.syncRoute({
374
+ pageUid: 'test-route',
375
+ pathname: '/admin/test-route',
376
+ });
377
+
378
+ expect(dispatchEvent).toHaveBeenCalledTimes(1);
379
+ expect(dispatchEvent.mock.calls[0][1].target).toBe(firstLayoutContentElement);
380
+
381
+ coordinator.setLayoutContentElement(null);
382
+ expect(dispatchEvent).toHaveBeenCalledTimes(1);
383
+
384
+ coordinator.setLayoutContentElement(secondLayoutContentElement);
385
+
386
+ expect(firstDestroy).toHaveBeenCalledTimes(1);
387
+ expect(dispatchEvent).toHaveBeenCalledTimes(2);
388
+ expect(dispatchEvent.mock.calls[1][1].target).toBe(secondLayoutContentElement);
389
+ expect(engine.getModel('test-route')).toBe(routeModel);
390
+ });
391
+
392
+ it('reopens an inactive cached route view on the new layout content element when it becomes active again', () => {
393
+ const engine = new FlowEngine();
394
+ engine.registerModels({ RouteModel });
395
+ engine.context.defineProperty('route', {
396
+ value: {},
397
+ });
398
+ engine.context.defineProperty('routeRepository', {
399
+ value: {
400
+ getRouteBySchemaUid: vi.fn(() => ({})),
401
+ },
402
+ });
403
+
404
+ const dispatchEvent = createDispatchEventMock((_eventName, payload) => {
405
+ payload.destroyRef.current = vi.fn();
406
+ payload.activateRef.current = vi.fn();
407
+ payload.deactivateRef.current = vi.fn();
408
+ return Promise.resolve();
409
+ });
410
+ const viewItem = createViewItem('test-route', dispatchEvent);
411
+ mockResolveViewParamsToViewList.mockReturnValue([viewItem]);
412
+ mockGetViewDiffAndUpdateHidden.mockImplementation((prevViewList, currentViewList) => ({
413
+ viewsToClose: prevViewList.filter((prevViewItem) => !currentViewList.includes(prevViewItem)),
414
+ viewsToOpen: currentViewList.filter((currentViewItem) => !prevViewList.includes(currentViewItem)),
415
+ }));
416
+ mockGetOpenViewStepParams.mockReturnValue({
417
+ collectionName: '',
418
+ dataSourceKey: '',
419
+ });
420
+
421
+ const firstLayoutContentElement = document.createElement('div');
422
+ const secondLayoutContentElement = document.createElement('div');
423
+ const coordinator = new BaseLayoutRouteCoordinator(engine, { basePathname: '/admin' });
424
+ coordinator.setLayoutContentElement(firstLayoutContentElement);
425
+ coordinator.registerPage('test-route', {
426
+ active: true,
427
+ layoutContentElement: document.createElement('div'),
428
+ });
429
+ coordinator.syncRoute({
430
+ pageUid: 'test-route',
431
+ pathname: '/admin/test-route',
432
+ });
433
+
434
+ coordinator.syncRoute({
435
+ pageUid: 'other-route',
436
+ pathname: '/admin/other-route',
437
+ });
438
+ coordinator.setLayoutContentElement(secondLayoutContentElement);
439
+ coordinator.syncRoute({
440
+ pageUid: 'test-route',
441
+ pathname: '/admin/test-route',
442
+ });
443
+
444
+ expect(dispatchEvent).toHaveBeenCalledTimes(2);
445
+ expect(dispatchEvent.mock.calls[1][1].target).toBe(secondLayoutContentElement);
446
+ });
447
+
448
+ it('ignores stale view onOpen callbacks after the layout content element changes', () => {
449
+ const engine = new FlowEngine();
450
+ engine.registerModels({ RouteModel });
451
+ engine.context.defineProperty('route', {
452
+ value: {},
453
+ });
454
+ engine.context.defineProperty('routeRepository', {
455
+ value: {
456
+ getRouteBySchemaUid: vi.fn(() => ({})),
457
+ },
458
+ });
459
+
460
+ const rootDispatchEvent = createDispatchEventMock((_eventName, payload) => {
461
+ payload.destroyRef.current = vi.fn();
462
+ return Promise.resolve();
463
+ });
464
+ let stalePopupOnOpen: (() => void) | undefined;
465
+ const popupDispatchEvent = createDispatchEventMock((_eventName, payload) => {
466
+ stalePopupOnOpen = payload.onOpen;
467
+ payload.destroyRef.current = vi.fn();
468
+ return Promise.resolve();
469
+ });
470
+ const detailDispatchEvent = createDispatchEventMock((_eventName, payload) => {
471
+ payload.destroyRef.current = vi.fn();
472
+ return Promise.resolve();
473
+ });
474
+ const rootViewItem = createViewItem('test-route', rootDispatchEvent);
475
+ const popupViewItem = createViewItem('popup', popupDispatchEvent, 1);
476
+ const detailViewItem = createViewItem('detail', detailDispatchEvent, 2);
477
+ let resolvedViewList: ViewItem[] = [rootViewItem];
478
+ mockResolveViewParamsToViewList.mockImplementation(() => resolvedViewList);
479
+ mockGetViewDiffAndUpdateHidden.mockImplementation((prevViewList, currentViewList) => ({
480
+ viewsToClose: prevViewList.filter((prevViewItem) => !currentViewList.includes(prevViewItem)),
481
+ viewsToOpen: currentViewList.filter((currentViewItem) => !prevViewList.includes(currentViewItem)),
482
+ }));
483
+ mockGetOpenViewStepParams.mockReturnValue({
484
+ collectionName: '',
485
+ dataSourceKey: '',
486
+ });
487
+
488
+ const coordinator = new BaseLayoutRouteCoordinator(engine, { basePathname: '/admin' });
489
+ coordinator.setLayoutContentElement(document.createElement('div'));
490
+ coordinator.registerPage('test-route', {
491
+ active: true,
492
+ layoutContentElement: document.createElement('div'),
493
+ });
494
+ coordinator.syncRoute({
495
+ pageUid: 'test-route',
496
+ pathname: '/admin/test-route',
497
+ });
498
+
499
+ resolvedViewList = [rootViewItem, popupViewItem, detailViewItem];
500
+ coordinator.syncRoute({
501
+ pageUid: 'test-route',
502
+ pathname: '/admin/test-route',
503
+ });
504
+
505
+ expect(popupDispatchEvent).toHaveBeenCalledTimes(1);
506
+ expect(detailDispatchEvent).not.toHaveBeenCalled();
507
+ expect(stalePopupOnOpen).toBeDefined();
508
+
509
+ resolvedViewList = [rootViewItem];
510
+ coordinator.setLayoutContentElement(document.createElement('div'));
511
+ stalePopupOnOpen?.();
512
+
513
+ expect(detailDispatchEvent).not.toHaveBeenCalled();
514
+ });
515
+
516
+ it('ignores stale async model loads after the layout content element changes', async () => {
517
+ const engine = new FlowEngine();
518
+ engine.registerModels({ RouteModel });
519
+ engine.context.defineProperty('route', {
520
+ value: {},
521
+ });
522
+ engine.context.defineProperty('routeRepository', {
523
+ value: {
524
+ getRouteBySchemaUid: vi.fn(() => ({})),
525
+ },
526
+ });
527
+
528
+ const rootDispatchEvent = createDispatchEventMock((_eventName, payload) => {
529
+ payload.destroyRef.current = vi.fn();
530
+ return Promise.resolve();
531
+ });
532
+ const popupDispatchEvent = createDispatchEventMock((_eventName, payload) => {
533
+ payload.destroyRef.current = vi.fn();
534
+ return Promise.resolve();
535
+ });
536
+ const rootViewItem = createViewItem('test-route', rootDispatchEvent);
537
+ const popupViewItem: ViewItem = {
538
+ params: { viewUid: 'popup' },
539
+ modelUid: 'popup',
540
+ model: undefined,
541
+ hidden: { value: false },
542
+ index: 1,
543
+ };
544
+ let resolvePopupModel: (model: FlowModel) => void = () => {};
545
+ vi.spyOn(engine, 'loadModel').mockImplementation(
546
+ () =>
547
+ new Promise((resolve) => {
548
+ resolvePopupModel = resolve;
549
+ }),
550
+ );
551
+ let resolvedViewList: ViewItem[] = [rootViewItem];
552
+ mockResolveViewParamsToViewList.mockImplementation(() => resolvedViewList);
553
+ mockGetViewDiffAndUpdateHidden.mockImplementation((prevViewList, currentViewList) => ({
554
+ viewsToClose: prevViewList.filter((prevViewItem) => !currentViewList.includes(prevViewItem)),
555
+ viewsToOpen: currentViewList.filter((currentViewItem) => !prevViewList.includes(currentViewItem)),
556
+ }));
557
+ mockGetOpenViewStepParams.mockReturnValue({
558
+ collectionName: '',
559
+ dataSourceKey: '',
560
+ });
561
+
562
+ const coordinator = new BaseLayoutRouteCoordinator(engine, { basePathname: '/admin' });
563
+ coordinator.setLayoutContentElement(document.createElement('div'));
564
+ coordinator.registerPage('test-route', {
565
+ active: true,
566
+ layoutContentElement: document.createElement('div'),
567
+ });
568
+ coordinator.syncRoute({
569
+ pageUid: 'test-route',
570
+ pathname: '/admin/test-route',
571
+ });
572
+
573
+ resolvedViewList = [rootViewItem, popupViewItem];
574
+ coordinator.syncRoute({
575
+ pageUid: 'test-route',
576
+ pathname: '/admin/test-route',
577
+ });
578
+
579
+ resolvedViewList = [rootViewItem];
580
+ coordinator.setLayoutContentElement(document.createElement('div'));
581
+ resolvePopupModel(createViewModel('popup', popupDispatchEvent));
582
+ await nextTick();
583
+
584
+ expect(popupDispatchEvent.mock.calls.length).toBe(0);
585
+ });
586
+
587
+ it('ignores stale async view opens after the pathname changes', async () => {
588
+ const engine = new FlowEngine();
589
+ engine.registerModels({ RouteModel });
590
+ engine.context.defineProperty('route', {
591
+ value: {},
592
+ });
593
+ engine.context.defineProperty('routeRepository', {
594
+ value: {
595
+ getRouteBySchemaUid: vi.fn(() => ({})),
596
+ },
597
+ });
598
+
599
+ const rootDispatchEvent = createDispatchEventMock((_eventName, payload) => {
600
+ payload.destroyRef.current = vi.fn();
601
+ return Promise.resolve();
602
+ });
603
+ const popupDispatchEvent = createDispatchEventMock((_eventName, payload) => {
604
+ payload.destroyRef.current = vi.fn();
605
+ return Promise.resolve();
606
+ });
607
+ const rootViewItem = createViewItem('test-route', rootDispatchEvent);
608
+ const popupViewItem: ViewItem = {
609
+ params: { viewUid: 'popup' },
610
+ modelUid: 'popup',
611
+ model: undefined,
612
+ hidden: { value: false },
613
+ index: 1,
614
+ };
615
+ const resolvePopupModels: Array<(model: FlowModel) => void> = [];
616
+ vi.spyOn(engine, 'loadModel').mockImplementation(
617
+ () =>
618
+ new Promise((resolve) => {
619
+ resolvePopupModels.push(resolve);
620
+ }),
621
+ );
622
+ let resolvedViewList: ViewItem[] = [rootViewItem];
623
+ mockResolveViewParamsToViewList.mockImplementation(() => resolvedViewList);
624
+ mockGetViewDiffAndUpdateHidden.mockImplementation((prevViewList, currentViewList) => ({
625
+ viewsToClose: prevViewList.filter((prevViewItem) => !currentViewList.includes(prevViewItem)),
626
+ viewsToOpen: currentViewList.filter((currentViewItem) => !prevViewList.includes(currentViewItem)),
627
+ }));
628
+ mockGetOpenViewStepParams.mockReturnValue({
629
+ collectionName: '',
630
+ dataSourceKey: '',
631
+ });
632
+
633
+ const coordinator = new BaseLayoutRouteCoordinator(engine, { basePathname: '/admin' });
634
+ coordinator.setLayoutContentElement(document.createElement('div'));
635
+ coordinator.registerPage('test-route', {
636
+ active: true,
637
+ layoutContentElement: document.createElement('div'),
638
+ });
639
+ coordinator.syncRoute({
640
+ pageUid: 'test-route',
641
+ pathname: '/admin/test-route',
642
+ });
643
+
644
+ resolvedViewList = [rootViewItem, popupViewItem];
645
+ coordinator.syncRoute({
646
+ pageUid: 'test-route',
647
+ pathname: '/admin/test-route/view/popup',
648
+ });
649
+
650
+ resolvedViewList = [rootViewItem];
651
+ coordinator.syncRoute({
652
+ pageUid: 'test-route',
653
+ pathname: '/admin/test-route',
654
+ });
655
+ resolvePopupModels.forEach((resolvePopupModel) => {
656
+ resolvePopupModel(createViewModel('popup', popupDispatchEvent));
657
+ });
658
+ await nextTick();
659
+
660
+ expect(popupDispatchEvent.mock.calls.length).toBe(0);
661
+ });
662
+
663
+ it('does not duplicate pending async view opens when the same pathname syncs again', async () => {
664
+ const engine = new FlowEngine();
665
+ engine.registerModels({ RouteModel });
666
+ engine.context.defineProperty('route', {
667
+ value: {},
668
+ });
669
+ engine.context.defineProperty('routeRepository', {
670
+ value: {
671
+ getRouteBySchemaUid: vi.fn(() => ({})),
672
+ },
673
+ });
674
+
675
+ const rootDispatchEvent = createDispatchEventMock((_eventName, payload) => {
676
+ payload.destroyRef.current = vi.fn();
677
+ return Promise.resolve();
678
+ });
679
+ const popupDispatchEvent = createDispatchEventMock((_eventName, payload) => {
680
+ payload.destroyRef.current = vi.fn();
681
+ return Promise.resolve();
682
+ });
683
+ const rootViewItem = createViewItem('test-route', rootDispatchEvent);
684
+ const popupViewItem: ViewItem = {
685
+ params: { viewUid: 'popup' },
686
+ modelUid: 'popup',
687
+ model: undefined,
688
+ hidden: { value: false },
689
+ index: 1,
690
+ };
691
+ const resolvePopupModels: Array<(model: FlowModel) => void> = [];
692
+ vi.spyOn(engine, 'loadModel').mockImplementation(
693
+ () =>
694
+ new Promise((resolve) => {
695
+ resolvePopupModels.push(resolve);
696
+ }),
697
+ );
698
+ let resolvedViewList: ViewItem[] = [rootViewItem];
699
+ mockResolveViewParamsToViewList.mockImplementation(() => resolvedViewList);
700
+ mockGetViewDiffAndUpdateHidden.mockImplementation((prevViewList, currentViewList) => ({
701
+ viewsToClose: prevViewList.filter((prevViewItem) => !currentViewList.includes(prevViewItem)),
702
+ viewsToOpen: currentViewList.filter((currentViewItem) => !prevViewList.includes(currentViewItem)),
703
+ }));
704
+ mockGetOpenViewStepParams.mockReturnValue({
705
+ collectionName: '',
706
+ dataSourceKey: '',
707
+ });
708
+
709
+ const coordinator = new BaseLayoutRouteCoordinator(engine, { basePathname: '/admin' });
710
+ coordinator.setLayoutContentElement(document.createElement('div'));
711
+ coordinator.registerPage('test-route', {
712
+ active: true,
713
+ layoutContentElement: document.createElement('div'),
714
+ });
715
+ coordinator.syncRoute({
716
+ pageUid: 'test-route',
717
+ pathname: '/admin/test-route',
718
+ });
719
+
720
+ resolvedViewList = [rootViewItem, popupViewItem];
721
+ coordinator.syncRoute({
722
+ pageUid: 'test-route',
723
+ pathname: '/admin/test-route/view/popup',
724
+ });
725
+ coordinator.syncRoute({
726
+ pageUid: 'test-route',
727
+ pathname: '/admin/test-route/view/popup',
728
+ });
729
+
730
+ resolvePopupModels.forEach((resolvePopupModel) => {
731
+ resolvePopupModel(createViewModel('popup', popupDispatchEvent));
732
+ });
733
+ await nextTick();
734
+
735
+ expect(popupDispatchEvent).toHaveBeenCalledTimes(1);
736
+ });
737
+
738
+ it('clears pending route view opens when dispatchEvent throws synchronously', async () => {
739
+ const engine = new FlowEngine();
740
+ engine.registerModels({ RouteModel });
741
+ engine.context.defineProperty('route', {
742
+ value: {},
743
+ });
744
+ engine.context.defineProperty('routeRepository', {
745
+ value: {
746
+ getRouteBySchemaUid: vi.fn(() => ({})),
747
+ },
748
+ });
749
+
750
+ const dispatchError = new Error('open failed');
751
+ const dispatchEvent = createDispatchEventMock((_eventName, payload) => {
752
+ if (dispatchEvent.mock.calls.length === 1) {
753
+ throw dispatchError;
754
+ }
69
755
 
70
- mockResolveViewParamsToViewList.mockReturnValue([viewItem]);
71
- mockGetViewDiffAndUpdateHidden.mockReturnValue({
72
- viewsToClose: [],
73
- viewsToOpen: [viewItem],
74
- });
75
- mockGetOpenViewStepParams.mockReturnValue({
76
- collectionName: 'roles',
77
- associationName: 'users.roles',
78
- dataSourceKey: 'main',
79
- } as any);
756
+ payload.destroyRef.current = vi.fn();
757
+ return Promise.resolve();
758
+ });
759
+ const viewItem = createViewItem('test-route', dispatchEvent);
760
+ mockResolveViewParamsToViewList.mockReturnValue([viewItem]);
761
+ mockGetViewDiffAndUpdateHidden.mockImplementation((prevViewList, currentViewList) => ({
762
+ viewsToClose: prevViewList.filter((prevViewItem) => !currentViewList.includes(prevViewItem)),
763
+ viewsToOpen: currentViewList.filter((currentViewItem) => !prevViewList.includes(currentViewItem)),
764
+ }));
765
+ mockGetOpenViewStepParams.mockReturnValue({
766
+ collectionName: '',
767
+ dataSourceKey: '',
768
+ });
769
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
80
770
 
81
- const coordinator = new AdminLayoutRouteCoordinator(engine);
82
- coordinator.registerPage('test-route', {
83
- active: true,
84
- layoutContentElement: document.createElement('div'),
85
- });
86
- coordinator.syncRoute({
87
- params: { name: 'test-route' },
88
- pathname: '/admin/popup/filterbytk/member',
89
- });
771
+ const coordinator = new BaseLayoutRouteCoordinator(engine, { basePathname: '/admin' });
772
+ coordinator.setLayoutContentElement(document.createElement('div'));
773
+ coordinator.registerPage('test-route', {
774
+ active: true,
775
+ layoutContentElement: document.createElement('div'),
776
+ });
777
+ coordinator.syncRoute({
778
+ pageUid: 'test-route',
779
+ pathname: '/admin/test-route',
780
+ });
781
+ await nextTick();
90
782
 
91
- return { dispatchEvent };
92
- }
783
+ coordinator.syncRoute({
784
+ pageUid: 'test-route',
785
+ pathname: '/admin/test-route',
786
+ });
93
787
 
94
- describe('AdminLayoutRouteCoordinator', () => {
95
- beforeEach(() => {
96
- vi.clearAllMocks();
788
+ expect(dispatchEvent).toHaveBeenCalledTimes(2);
789
+ consoleErrorSpy.mockRestore();
97
790
  });
98
791
 
99
- it('drops configured associationName during route replay when sourceId is absent', () => {
100
- const { dispatchEvent } = setupRouteReplay({});
792
+ it('ignores stale initial deep-link replay after the pathname changes before a view opens', async () => {
793
+ const engine = new FlowEngine();
794
+ engine.registerModels({ RouteModel });
795
+ engine.context.defineProperty('router', {
796
+ value: {
797
+ navigate: vi.fn(),
798
+ },
799
+ });
800
+ engine.context.defineProperty('route', {
801
+ value: {},
802
+ });
803
+ engine.context.defineProperty('routeRepository', {
804
+ value: {
805
+ getRouteBySchemaUid: vi.fn(() => ({})),
806
+ },
807
+ });
101
808
 
102
- expect(dispatchEvent.mock.calls[0][1]).toMatchObject({
103
- collectionName: 'roles',
104
- associationName: null,
105
- dataSourceKey: 'main',
106
- filterByTk: 'member',
107
- triggerByRouter: true,
809
+ const rootDispatchEvent = createDispatchEventMock((_eventName, payload) => {
810
+ payload.destroyRef.current = vi.fn();
811
+ payload.onOpen();
812
+ return Promise.resolve();
813
+ });
814
+ const popupDispatchEvent = createDispatchEventMock((_eventName, payload) => {
815
+ payload.destroyRef.current = vi.fn();
816
+ return Promise.resolve();
817
+ });
818
+ const rootViewItem = createViewItem('test-route', rootDispatchEvent);
819
+ const popupViewItem = createViewItem('popup', popupDispatchEvent, 1);
820
+ mockResolveViewParamsToViewList.mockImplementation((_engine, viewParams) => {
821
+ if (viewParams[1]?.viewUid === 'popup') {
822
+ return [rootViewItem, popupViewItem];
823
+ }
824
+ return [];
825
+ });
826
+ mockGetViewDiffAndUpdateHidden.mockImplementation((prevViewList, currentViewList) => ({
827
+ viewsToClose: prevViewList.filter((prevViewItem) => !currentViewList.includes(prevViewItem)),
828
+ viewsToOpen: currentViewList.filter((currentViewItem) => !prevViewList.includes(currentViewItem)),
829
+ }));
830
+ mockGetOpenViewStepParams.mockReturnValue({
831
+ collectionName: '',
832
+ dataSourceKey: '',
108
833
  });
109
- });
110
834
 
111
- it('keeps configured associationName during route replay when sourceId is present', () => {
112
- const { dispatchEvent } = setupRouteReplay({ sourceId: '1' });
835
+ const coordinator = new BaseLayoutRouteCoordinator(engine, { basePathname: '/admin' });
836
+ coordinator.setLayoutContentElement(document.createElement('div'));
837
+ coordinator.registerPage('test-route', {
838
+ active: true,
839
+ layoutContentElement: document.createElement('div'),
840
+ });
841
+ coordinator.syncRoute({
842
+ pageUid: 'test-route',
843
+ pathname: '/admin/test-route/view/popup',
844
+ });
113
845
 
114
- expect(dispatchEvent.mock.calls[0][1]).toMatchObject({
115
- collectionName: 'roles',
116
- associationName: 'users.roles',
117
- dataSourceKey: 'main',
118
- filterByTk: 'member',
119
- sourceId: '1',
120
- triggerByRouter: true,
846
+ coordinator.syncRoute({
847
+ pageUid: 'test-route',
848
+ pathname: '/admin/test-route',
121
849
  });
850
+ await nextTick();
851
+
852
+ expect(popupDispatchEvent.mock.calls.length).toBe(0);
122
853
  });
123
854
 
124
- it('passes RunJS openView route state as runtime mode and size during route replay', () => {
125
- const { dispatchEvent } = setupRouteReplay({
126
- openViewRouteState: { mode: 'dialog', size: 'large' },
855
+ it('ignores stale async view opens after the route is cleared', async () => {
856
+ const engine = new FlowEngine();
857
+ engine.registerModels({ RouteModel });
858
+ engine.context.defineProperty('route', {
859
+ value: {},
860
+ });
861
+ engine.context.defineProperty('routeRepository', {
862
+ value: {
863
+ getRouteBySchemaUid: vi.fn(() => ({})),
864
+ },
127
865
  });
128
866
 
129
- expect(dispatchEvent.mock.calls[0][1]).toMatchObject({
130
- mode: 'dialog',
131
- size: 'large',
132
- openViewRouteState: { mode: 'dialog', size: 'large' },
133
- triggerByRouter: true,
867
+ const rootDispatchEvent = createDispatchEventMock((_eventName, payload) => {
868
+ payload.destroyRef.current = vi.fn();
869
+ return Promise.resolve();
870
+ });
871
+ const popupDispatchEvent = createDispatchEventMock((_eventName, payload) => {
872
+ payload.destroyRef.current = vi.fn();
873
+ return Promise.resolve();
874
+ });
875
+ const rootViewItem = createViewItem('test-route', rootDispatchEvent);
876
+ const popupViewItem: ViewItem = {
877
+ params: { viewUid: 'popup' },
878
+ modelUid: 'popup',
879
+ model: undefined,
880
+ hidden: { value: false },
881
+ index: 1,
882
+ };
883
+ let resolvePopupModel: (model: FlowModel) => void = () => {};
884
+ vi.spyOn(engine, 'loadModel').mockImplementation(
885
+ () =>
886
+ new Promise((resolve) => {
887
+ resolvePopupModel = resolve;
888
+ }),
889
+ );
890
+ let resolvedViewList: ViewItem[] = [rootViewItem];
891
+ mockResolveViewParamsToViewList.mockImplementation(() => resolvedViewList);
892
+ mockGetViewDiffAndUpdateHidden.mockImplementation((prevViewList, currentViewList) => ({
893
+ viewsToClose: prevViewList.filter((prevViewItem) => !currentViewList.includes(prevViewItem)),
894
+ viewsToOpen: currentViewList.filter((currentViewItem) => !prevViewList.includes(currentViewItem)),
895
+ }));
896
+ mockGetOpenViewStepParams.mockReturnValue({
897
+ collectionName: '',
898
+ dataSourceKey: '',
899
+ });
900
+
901
+ const coordinator = new BaseLayoutRouteCoordinator(engine, { basePathname: '/admin' });
902
+ coordinator.setLayoutContentElement(document.createElement('div'));
903
+ coordinator.registerPage('test-route', {
904
+ active: true,
905
+ layoutContentElement: document.createElement('div'),
906
+ });
907
+ coordinator.syncRoute({
908
+ pageUid: 'test-route',
909
+ pathname: '/admin/test-route',
910
+ });
911
+
912
+ resolvedViewList = [rootViewItem, popupViewItem];
913
+ coordinator.syncRoute({
914
+ pageUid: 'test-route',
915
+ pathname: '/admin/test-route/view/popup',
134
916
  });
917
+
918
+ coordinator.syncRoute({});
919
+ resolvePopupModel(createViewModel('popup', popupDispatchEvent));
920
+ await nextTick();
921
+
922
+ expect(popupDispatchEvent).not.toHaveBeenCalled();
135
923
  });
136
924
 
137
- it('uses layout content element before route page placeholder as view target', () => {
925
+ it('ignores stale async view opens after switching away and back to the same pathname', async () => {
138
926
  const engine = new FlowEngine();
139
927
  engine.registerModels({ RouteModel });
140
928
  engine.context.defineProperty('route', {
@@ -146,35 +934,161 @@ describe('AdminLayoutRouteCoordinator', () => {
146
934
  },
147
935
  });
148
936
 
149
- const layoutContentElement = document.createElement('div');
150
- const routePageElement = document.createElement('div');
151
- const dispatchEvent = vi.fn((_eventName: string, _payload: any) => Promise.resolve());
152
- const viewItem = {
153
- params: { viewUid: 'test-route' },
154
- modelUid: 'test-route',
155
- model: { uid: 'test-route', dispatchEvent } as any,
937
+ const pageRootDispatchEvent = createDispatchEventMock((_eventName, payload) => {
938
+ payload.destroyRef.current = vi.fn();
939
+ return Promise.resolve();
940
+ });
941
+ const otherRootDispatchEvent = createDispatchEventMock((_eventName, payload) => {
942
+ payload.destroyRef.current = vi.fn();
943
+ return Promise.resolve();
944
+ });
945
+ const stalePopupDispatchEvent = createDispatchEventMock((_eventName, payload) => {
946
+ payload.destroyRef.current = vi.fn();
947
+ return Promise.resolve();
948
+ });
949
+ const currentPopupDispatchEvent = createDispatchEventMock((_eventName, payload) => {
950
+ payload.destroyRef.current = vi.fn();
951
+ return Promise.resolve();
952
+ });
953
+ const pageRootViewItem = createViewItem('test-route', pageRootDispatchEvent);
954
+ const otherRootViewItem = createViewItem('other-route', otherRootDispatchEvent);
955
+ const firstPopupViewItem: ViewItem = {
956
+ params: { viewUid: 'popup' },
957
+ modelUid: 'popup',
958
+ model: undefined,
156
959
  hidden: { value: false },
157
- index: 0,
960
+ index: 1,
158
961
  };
159
- mockResolveViewParamsToViewList.mockReturnValue([viewItem]);
160
- mockGetViewDiffAndUpdateHidden.mockReturnValue({
161
- viewsToClose: [],
162
- viewsToOpen: [viewItem],
962
+ const secondPopupViewItem: ViewItem = {
963
+ params: { viewUid: 'popup' },
964
+ modelUid: 'popup',
965
+ model: undefined,
966
+ hidden: { value: false },
967
+ index: 1,
968
+ };
969
+ const resolvePopupModels: Array<(model: FlowModel) => void> = [];
970
+ vi.spyOn(engine, 'loadModel').mockImplementation(
971
+ () =>
972
+ new Promise((resolve) => {
973
+ resolvePopupModels.push(resolve);
974
+ }),
975
+ );
976
+ let resolvedViewList: ViewItem[] = [pageRootViewItem];
977
+ mockResolveViewParamsToViewList.mockImplementation(() => resolvedViewList);
978
+ mockGetViewDiffAndUpdateHidden.mockImplementation((prevViewList, currentViewList) => ({
979
+ viewsToClose: prevViewList.filter((prevViewItem) => !currentViewList.includes(prevViewItem)),
980
+ viewsToOpen: currentViewList.filter((currentViewItem) => !prevViewList.includes(currentViewItem)),
981
+ }));
982
+ mockGetOpenViewStepParams.mockReturnValue({
983
+ collectionName: '',
984
+ dataSourceKey: '',
163
985
  });
164
- mockGetOpenViewStepParams.mockReturnValue({} as any);
165
986
 
166
987
  const coordinator = new BaseLayoutRouteCoordinator(engine, { basePathname: '/admin' });
167
- coordinator.setLayoutContentElement(layoutContentElement);
988
+ coordinator.setLayoutContentElement(document.createElement('div'));
168
989
  coordinator.registerPage('test-route', {
169
990
  active: true,
170
- layoutContentElement: routePageElement,
991
+ layoutContentElement: document.createElement('div'),
992
+ });
993
+ coordinator.registerPage('other-route', {
994
+ active: false,
995
+ layoutContentElement: document.createElement('div'),
171
996
  });
172
997
  coordinator.syncRoute({
173
998
  pageUid: 'test-route',
174
999
  pathname: '/admin/test-route',
175
1000
  });
176
1001
 
177
- expect(dispatchEvent.mock.calls[0][1].target).toBe(layoutContentElement);
1002
+ resolvedViewList = [pageRootViewItem, firstPopupViewItem];
1003
+ coordinator.syncRoute({
1004
+ pageUid: 'test-route',
1005
+ pathname: '/admin/test-route/view/popup',
1006
+ });
1007
+
1008
+ resolvedViewList = [otherRootViewItem];
1009
+ coordinator.syncRoute({
1010
+ pageUid: 'other-route',
1011
+ pathname: '/admin/other-route',
1012
+ });
1013
+
1014
+ resolvedViewList = [pageRootViewItem, secondPopupViewItem];
1015
+ coordinator.syncRoute({
1016
+ pageUid: 'test-route',
1017
+ pathname: '/admin/test-route/view/popup',
1018
+ });
1019
+
1020
+ resolvePopupModels[0]?.(createViewModel('popup', stalePopupDispatchEvent));
1021
+ resolvePopupModels[1]?.(createViewModel('popup', currentPopupDispatchEvent));
1022
+ await nextTick();
1023
+
1024
+ expect(stalePopupDispatchEvent).not.toHaveBeenCalled();
1025
+ expect(currentPopupDispatchEvent).toHaveBeenCalledTimes(1);
1026
+ });
1027
+
1028
+ it('does not run initial deep-link step navigation while replaying after layout content element changes', () => {
1029
+ const engine = new FlowEngine();
1030
+ engine.registerModels({ RouteModel });
1031
+ const navigate = vi.fn();
1032
+ engine.context.defineProperty('router', {
1033
+ value: {
1034
+ navigate,
1035
+ },
1036
+ });
1037
+ engine.context.defineProperty('route', {
1038
+ value: {},
1039
+ });
1040
+ engine.context.defineProperty('routeRepository', {
1041
+ value: {
1042
+ getRouteBySchemaUid: vi.fn(() => ({})),
1043
+ },
1044
+ });
1045
+
1046
+ const rootDispatchEvent = createDispatchEventMock((_eventName, payload) => {
1047
+ payload.destroyRef.current = vi.fn();
1048
+ payload.onOpen();
1049
+ return Promise.resolve();
1050
+ });
1051
+ const popupDispatchEvent = createDispatchEventMock((_eventName, payload) => {
1052
+ payload.destroyRef.current = vi.fn();
1053
+ return Promise.resolve();
1054
+ });
1055
+ const rootViewItem = createViewItem('test-route', rootDispatchEvent);
1056
+ const popupViewItem = createViewItem('popup', popupDispatchEvent, 1);
1057
+ let resolvedViewList: ViewItem[] = [rootViewItem];
1058
+ mockResolveViewParamsToViewList.mockImplementation(() => resolvedViewList);
1059
+ mockGetViewDiffAndUpdateHidden.mockImplementation((prevViewList, currentViewList) => ({
1060
+ viewsToClose: prevViewList.filter((prevViewItem) => !currentViewList.includes(prevViewItem)),
1061
+ viewsToOpen: currentViewList.filter((currentViewItem) => !prevViewList.includes(currentViewItem)),
1062
+ }));
1063
+ mockGetOpenViewStepParams.mockReturnValue({
1064
+ collectionName: '',
1065
+ dataSourceKey: '',
1066
+ });
1067
+
1068
+ const coordinator = new BaseLayoutRouteCoordinator(engine, { basePathname: '/admin' });
1069
+ coordinator.setLayoutContentElement(document.createElement('div'));
1070
+ coordinator.registerPage('test-route', {
1071
+ active: true,
1072
+ layoutContentElement: document.createElement('div'),
1073
+ });
1074
+ coordinator.syncRoute({
1075
+ pageUid: 'test-route',
1076
+ pathname: '/admin/test-route',
1077
+ });
1078
+
1079
+ resolvedViewList = [rootViewItem, popupViewItem];
1080
+ coordinator.syncRoute({
1081
+ pageUid: 'test-route',
1082
+ pathname: '/admin/test-route/view/popup',
1083
+ });
1084
+ navigate.mockClear();
1085
+
1086
+ const nextLayoutContentElement = document.createElement('div');
1087
+ coordinator.setLayoutContentElement(nextLayoutContentElement);
1088
+
1089
+ expect(navigate).not.toHaveBeenCalled();
1090
+ expect(popupDispatchEvent).toHaveBeenCalledTimes(2);
1091
+ expect(popupDispatchEvent.mock.calls[1][1].target).toBe(nextLayoutContentElement);
178
1092
  });
179
1093
 
180
1094
  it('replaces stale non-route model before registering route page', () => {
@@ -281,18 +1195,12 @@ describe('AdminLayoutRouteCoordinator', () => {
281
1195
  },
282
1196
  });
283
1197
 
284
- const dispatchEvent = vi.fn((_eventName: string, payload: any) => {
1198
+ const dispatchEvent = createDispatchEventMock((_eventName, payload) => {
285
1199
  payload.activateRef.current = vi.fn();
286
1200
  payload.deactivateRef.current = vi.fn();
287
1201
  return Promise.resolve();
288
1202
  });
289
- const viewItem = {
290
- params: { viewUid: 'test-route' },
291
- modelUid: 'test-route',
292
- model: { uid: 'test-route', dispatchEvent } as any,
293
- hidden: { value: false },
294
- index: 0,
295
- };
1203
+ const viewItem = createViewItem('test-route', dispatchEvent);
296
1204
  mockResolveViewParamsToViewList.mockReturnValue([viewItem]);
297
1205
  mockGetViewDiffAndUpdateHidden.mockReturnValueOnce({
298
1206
  viewsToClose: [],
@@ -342,28 +1250,26 @@ describe('AdminLayoutRouteCoordinator', () => {
342
1250
  },
343
1251
  });
344
1252
 
345
- const viewItemsByPageUid = new Map<string, any>();
346
- const createViewItem = (pageUid: string) => {
347
- const dispatchEvent = vi.fn((_eventName: string, payload: any) => {
1253
+ const viewItemsByPageUid = new Map<string, ViewItem>();
1254
+ const createCachedViewItem = (pageUid: string) => {
1255
+ const dispatchEvent = createDispatchEventMock((_eventName, payload) => {
348
1256
  payload.activateRef.current = vi.fn();
349
1257
  payload.deactivateRef.current = vi.fn();
350
1258
  return Promise.resolve();
351
1259
  });
352
- return {
353
- params: { viewUid: pageUid },
354
- modelUid: pageUid,
355
- model: { uid: pageUid, dispatchEvent } as any,
356
- hidden: { value: false },
357
- index: 0,
358
- };
1260
+ return createViewItem(pageUid, dispatchEvent);
359
1261
  };
360
1262
 
361
1263
  mockResolveViewParamsToViewList.mockImplementation((_engine, viewParams) => {
362
1264
  const pageUid = viewParams[0].viewUid;
363
1265
  if (!viewItemsByPageUid.has(pageUid)) {
364
- viewItemsByPageUid.set(pageUid, createViewItem(pageUid));
1266
+ viewItemsByPageUid.set(pageUid, createCachedViewItem(pageUid));
1267
+ }
1268
+ const viewItem = viewItemsByPageUid.get(pageUid);
1269
+ if (!viewItem) {
1270
+ throw new Error(`Expected cached view item for ${pageUid}.`);
365
1271
  }
366
- return [viewItemsByPageUid.get(pageUid)];
1272
+ return [viewItem];
367
1273
  });
368
1274
  mockGetViewDiffAndUpdateHidden.mockImplementation((prevViewList, currentViewList) => ({
369
1275
  viewsToClose: prevViewList.filter((prevViewItem) => !currentViewList.includes(prevViewItem)),
@@ -420,18 +1326,12 @@ describe('AdminLayoutRouteCoordinator', () => {
420
1326
  },
421
1327
  });
422
1328
 
423
- const dispatchEvent = vi.fn((_eventName: string, payload: any) => {
1329
+ const dispatchEvent = createDispatchEventMock((_eventName, payload) => {
424
1330
  payload.activateRef.current = vi.fn();
425
1331
  payload.deactivateRef.current = vi.fn();
426
1332
  return Promise.resolve();
427
1333
  });
428
- const viewItem = {
429
- params: { viewUid: 'test-route' },
430
- modelUid: 'test-route',
431
- model: { uid: 'test-route', dispatchEvent } as any,
432
- hidden: { value: false },
433
- index: 0,
434
- };
1334
+ const viewItem = createViewItem('test-route', dispatchEvent);
435
1335
  mockResolveViewParamsToViewList.mockReturnValue([viewItem]);
436
1336
  mockGetViewDiffAndUpdateHidden.mockReturnValueOnce({
437
1337
  viewsToClose: [],
@@ -479,13 +1379,10 @@ describe('AdminLayoutRouteCoordinator', () => {
479
1379
  },
480
1380
  });
481
1381
 
482
- const popupViewItem = {
483
- params: { viewUid: 'popup', filterByTk: '1' },
484
- modelUid: 'popup',
485
- model: { uid: 'popup', dispatchEvent: vi.fn() } as any,
486
- hidden: { value: false },
487
- index: 1,
488
- };
1382
+ const popupViewItem = createViewItem('popup', createDispatchEventMock(), 1, {
1383
+ viewUid: 'popup',
1384
+ filterByTk: '1',
1385
+ });
489
1386
  mockResolveViewParamsToViewList.mockImplementation((_engine, viewParams, routeModel) => [
490
1387
  {
491
1388
  params: viewParams[0],