@nocobase/client-v2 2.2.0-beta.7 → 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 (113) 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/actions/afterSuccess.d.ts +8 -1
  8. package/es/flow/actions/index.d.ts +1 -1
  9. package/es/flow/admin-shell/BaseLayoutModel.d.ts +6 -0
  10. package/es/flow/admin-shell/BaseLayoutRouteCoordinator.d.ts +7 -0
  11. package/es/flow/admin-shell/admin-layout/AdminLayoutSlotModels.d.ts +1 -0
  12. package/es/flow/admin-shell/admin-layout/AppListRender.d.ts +2 -1
  13. package/es/flow/admin-shell/admin-layout/AppSwitcherActionPanelModel.d.ts +24 -0
  14. package/es/flow/admin-shell/admin-layout/index.d.ts +1 -0
  15. package/es/flow/admin-shell/admin-layout/useApplications.d.ts +7 -2
  16. package/es/flow/models/base/ActionModelCore.d.ts +2 -0
  17. package/es/flow/models/blocks/form/submitHandler.d.ts +1 -1
  18. package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +9 -0
  19. package/es/flow/models/blocks/js-block/JSBlock.d.ts +1 -0
  20. package/es/flow/models/blocks/table/TableActionsColumnModel.d.ts +1 -0
  21. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  22. package/es/flow/models/blocks/table/dragSort/dragSortHooks.d.ts +1 -1
  23. package/es/flow/models/blocks/table/dragSort/dragSortSettings.d.ts +2 -1
  24. package/es/flow/models/blocks/table/dragSort/dragSortUtils.d.ts +6 -4
  25. package/es/flow/resolveViewParamsToViewList.d.ts +1 -1
  26. package/es/flow/routeTransientInputArgs.d.ts +14 -0
  27. package/es/index.d.ts +2 -0
  28. package/es/index.mjs +243 -141
  29. package/es/utils/markdownSanitize.d.ts +11 -0
  30. package/lib/index.js +232 -130
  31. package/package.json +7 -7
  32. package/src/BaseApplication.tsx +2 -0
  33. package/src/RouteRepository.ts +25 -0
  34. package/src/RouterManager.tsx +142 -1
  35. package/src/__tests__/RouteRepository.test.ts +23 -0
  36. package/src/__tests__/RouterManager.test.ts +125 -0
  37. package/src/__tests__/authRedirect.test.ts +17 -0
  38. package/src/authRedirect.ts +38 -0
  39. package/src/collection-manager/field-configure.ts +1 -1
  40. package/src/collection-manager/interfaces/id.ts +1 -1
  41. package/src/collection-manager/interfaces/m2m.tsx +2 -2
  42. package/src/collection-manager/interfaces/m2o.tsx +2 -2
  43. package/src/collection-manager/interfaces/nanoid.ts +1 -1
  44. package/src/collection-manager/interfaces/o2m.tsx +2 -2
  45. package/src/collection-manager/interfaces/obo.tsx +2 -2
  46. package/src/collection-manager/interfaces/oho.tsx +2 -2
  47. package/src/collection-manager/interfaces/properties/index.ts +2 -2
  48. package/src/collection-manager/interfaces/uuid.ts +1 -1
  49. package/src/entry-actions/EntryActionManager.ts +76 -0
  50. package/src/entry-actions/index.ts +10 -0
  51. package/src/flow/FlowPage.tsx +29 -2
  52. package/src/flow/__tests__/FlowPage.test.tsx +152 -25
  53. package/src/flow/__tests__/FlowRoute.test.tsx +547 -2
  54. package/src/flow/__tests__/getKey.test.ts +7 -0
  55. package/src/flow/__tests__/resolveViewParamsToViewList.test.ts +34 -0
  56. package/src/flow/actions/__tests__/afterSuccess.test.ts +73 -0
  57. package/src/flow/actions/__tests__/dataScopeFilter.test.ts +62 -0
  58. package/src/flow/actions/__tests__/openView.defineProps.route.test.tsx +164 -0
  59. package/src/flow/actions/afterSuccess.tsx +142 -3
  60. package/src/flow/actions/dataScopeFilter.ts +10 -1
  61. package/src/flow/actions/dateTimeFormat.tsx +2 -2
  62. package/src/flow/actions/index.ts +1 -1
  63. package/src/flow/actions/openView.tsx +59 -5
  64. package/src/flow/admin-shell/BaseLayoutModel.tsx +149 -3
  65. package/src/flow/admin-shell/BaseLayoutRouteCoordinator.ts +187 -42
  66. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +1020 -110
  67. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +42 -4
  68. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.test.tsx +177 -1
  69. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +20 -0
  70. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +5 -4
  71. package/src/flow/admin-shell/admin-layout/AppListRender.tsx +13 -2
  72. package/src/flow/admin-shell/admin-layout/AppSwitcherActionPanelModel.tsx +289 -0
  73. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +518 -2
  74. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +48 -0
  75. package/src/flow/admin-shell/admin-layout/index.ts +1 -0
  76. package/src/flow/admin-shell/admin-layout/useApplications.tsx +81 -12
  77. package/src/flow/common/Markdown/Display.tsx +14 -3
  78. package/src/flow/common/Markdown/Edit.tsx +19 -7
  79. package/src/flow/components/FlowRoute.tsx +128 -16
  80. package/src/flow/getViewDiffAndUpdateHidden.tsx +1 -0
  81. package/src/flow/internal/components/Markdown/util.ts +2 -1
  82. package/src/flow/models/base/ActionModel.tsx +10 -8
  83. package/src/flow/models/base/ActionModelCore.tsx +11 -4
  84. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +37 -0
  85. package/src/flow/models/blocks/filter-form/fields/FilterFormCustomFieldModel.tsx +1 -1
  86. package/src/flow/models/blocks/form/FormActionModel.tsx +1 -1
  87. package/src/flow/models/blocks/form/__tests__/submitHandler.test.ts +54 -1
  88. package/src/flow/models/blocks/form/submitHandler.ts +17 -3
  89. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +87 -0
  90. package/src/flow/models/blocks/form/value-runtime/runtime.ts +91 -0
  91. package/src/flow/models/blocks/js-block/JSBlock.tsx +223 -2
  92. package/src/flow/models/blocks/js-block/__tests__/JSBlockModel.test.tsx +150 -0
  93. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +35 -12
  94. package/src/flow/models/blocks/table/TableBlockModel.tsx +25 -14
  95. package/src/flow/models/blocks/table/TableColumnModel.tsx +6 -2
  96. package/src/flow/models/blocks/table/__tests__/TableActionsColumnModel.test.tsx +57 -1
  97. package/src/flow/models/blocks/table/__tests__/TableBlockModel.dragSort.test.ts +127 -0
  98. package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowSelection.test.tsx +1 -0
  99. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +51 -0
  100. package/src/flow/models/blocks/table/dragSort/dragSortHooks.tsx +28 -17
  101. package/src/flow/models/blocks/table/dragSort/dragSortSettings.ts +13 -6
  102. package/src/flow/models/blocks/table/dragSort/dragSortUtils.ts +15 -2
  103. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +45 -13
  104. package/src/flow/models/fields/TextareaFieldModel.tsx +42 -19
  105. package/src/flow/models/fields/__tests__/TextareaFieldModel.test.tsx +32 -1
  106. package/src/flow/models/topbar/TopbarActionModel.tsx +78 -3
  107. package/src/flow/resolveViewParamsToViewList.tsx +11 -3
  108. package/src/flow/routeTransientInputArgs.ts +27 -0
  109. package/src/flow/utils/__tests__/dateTimeFormat.test.ts +42 -0
  110. package/src/index.ts +2 -0
  111. package/src/nocobase-buildin-plugin/index.tsx +7 -1
  112. package/src/settings-center/SystemSettingsPage.tsx +1 -1
  113. 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,74 +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: '',
833
+ });
834
+
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
+ });
845
+
846
+ coordinator.syncRoute({
847
+ pageUid: 'test-route',
848
+ pathname: '/admin/test-route',
108
849
  });
850
+ await nextTick();
851
+
852
+ expect(popupDispatchEvent.mock.calls.length).toBe(0);
109
853
  });
110
854
 
111
- it('keeps configured associationName during route replay when sourceId is present', () => {
112
- const { dispatchEvent } = setupRouteReplay({ sourceId: '1' });
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
+ },
865
+ });
113
866
 
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,
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',
121
916
  });
917
+
918
+ coordinator.syncRoute({});
919
+ resolvePopupModel(createViewModel('popup', popupDispatchEvent));
920
+ await nextTick();
921
+
922
+ expect(popupDispatchEvent).not.toHaveBeenCalled();
122
923
  });
123
924
 
124
- 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 () => {
125
926
  const engine = new FlowEngine();
126
927
  engine.registerModels({ RouteModel });
127
928
  engine.context.defineProperty('route', {
@@ -133,35 +934,161 @@ describe('AdminLayoutRouteCoordinator', () => {
133
934
  },
134
935
  });
135
936
 
136
- const layoutContentElement = document.createElement('div');
137
- const routePageElement = document.createElement('div');
138
- const dispatchEvent = vi.fn((_eventName: string, _payload: any) => Promise.resolve());
139
- const viewItem = {
140
- params: { viewUid: 'test-route' },
141
- modelUid: 'test-route',
142
- 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,
143
959
  hidden: { value: false },
144
- index: 0,
960
+ index: 1,
145
961
  };
146
- mockResolveViewParamsToViewList.mockReturnValue([viewItem]);
147
- mockGetViewDiffAndUpdateHidden.mockReturnValue({
148
- viewsToClose: [],
149
- 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: '',
150
985
  });
151
- mockGetOpenViewStepParams.mockReturnValue({} as any);
152
986
 
153
987
  const coordinator = new BaseLayoutRouteCoordinator(engine, { basePathname: '/admin' });
154
- coordinator.setLayoutContentElement(layoutContentElement);
988
+ coordinator.setLayoutContentElement(document.createElement('div'));
155
989
  coordinator.registerPage('test-route', {
156
990
  active: true,
157
- layoutContentElement: routePageElement,
991
+ layoutContentElement: document.createElement('div'),
992
+ });
993
+ coordinator.registerPage('other-route', {
994
+ active: false,
995
+ layoutContentElement: document.createElement('div'),
158
996
  });
159
997
  coordinator.syncRoute({
160
998
  pageUid: 'test-route',
161
999
  pathname: '/admin/test-route',
162
1000
  });
163
1001
 
164
- 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);
165
1092
  });
166
1093
 
167
1094
  it('replaces stale non-route model before registering route page', () => {
@@ -268,18 +1195,12 @@ describe('AdminLayoutRouteCoordinator', () => {
268
1195
  },
269
1196
  });
270
1197
 
271
- const dispatchEvent = vi.fn((_eventName: string, payload: any) => {
1198
+ const dispatchEvent = createDispatchEventMock((_eventName, payload) => {
272
1199
  payload.activateRef.current = vi.fn();
273
1200
  payload.deactivateRef.current = vi.fn();
274
1201
  return Promise.resolve();
275
1202
  });
276
- const viewItem = {
277
- params: { viewUid: 'test-route' },
278
- modelUid: 'test-route',
279
- model: { uid: 'test-route', dispatchEvent } as any,
280
- hidden: { value: false },
281
- index: 0,
282
- };
1203
+ const viewItem = createViewItem('test-route', dispatchEvent);
283
1204
  mockResolveViewParamsToViewList.mockReturnValue([viewItem]);
284
1205
  mockGetViewDiffAndUpdateHidden.mockReturnValueOnce({
285
1206
  viewsToClose: [],
@@ -329,28 +1250,26 @@ describe('AdminLayoutRouteCoordinator', () => {
329
1250
  },
330
1251
  });
331
1252
 
332
- const viewItemsByPageUid = new Map<string, any>();
333
- const createViewItem = (pageUid: string) => {
334
- 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) => {
335
1256
  payload.activateRef.current = vi.fn();
336
1257
  payload.deactivateRef.current = vi.fn();
337
1258
  return Promise.resolve();
338
1259
  });
339
- return {
340
- params: { viewUid: pageUid },
341
- modelUid: pageUid,
342
- model: { uid: pageUid, dispatchEvent } as any,
343
- hidden: { value: false },
344
- index: 0,
345
- };
1260
+ return createViewItem(pageUid, dispatchEvent);
346
1261
  };
347
1262
 
348
1263
  mockResolveViewParamsToViewList.mockImplementation((_engine, viewParams) => {
349
1264
  const pageUid = viewParams[0].viewUid;
350
1265
  if (!viewItemsByPageUid.has(pageUid)) {
351
- 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}.`);
352
1271
  }
353
- return [viewItemsByPageUid.get(pageUid)];
1272
+ return [viewItem];
354
1273
  });
355
1274
  mockGetViewDiffAndUpdateHidden.mockImplementation((prevViewList, currentViewList) => ({
356
1275
  viewsToClose: prevViewList.filter((prevViewItem) => !currentViewList.includes(prevViewItem)),
@@ -407,18 +1326,12 @@ describe('AdminLayoutRouteCoordinator', () => {
407
1326
  },
408
1327
  });
409
1328
 
410
- const dispatchEvent = vi.fn((_eventName: string, payload: any) => {
1329
+ const dispatchEvent = createDispatchEventMock((_eventName, payload) => {
411
1330
  payload.activateRef.current = vi.fn();
412
1331
  payload.deactivateRef.current = vi.fn();
413
1332
  return Promise.resolve();
414
1333
  });
415
- const viewItem = {
416
- params: { viewUid: 'test-route' },
417
- modelUid: 'test-route',
418
- model: { uid: 'test-route', dispatchEvent } as any,
419
- hidden: { value: false },
420
- index: 0,
421
- };
1334
+ const viewItem = createViewItem('test-route', dispatchEvent);
422
1335
  mockResolveViewParamsToViewList.mockReturnValue([viewItem]);
423
1336
  mockGetViewDiffAndUpdateHidden.mockReturnValueOnce({
424
1337
  viewsToClose: [],
@@ -466,13 +1379,10 @@ describe('AdminLayoutRouteCoordinator', () => {
466
1379
  },
467
1380
  });
468
1381
 
469
- const popupViewItem = {
470
- params: { viewUid: 'popup', filterByTk: '1' },
471
- modelUid: 'popup',
472
- model: { uid: 'popup', dispatchEvent: vi.fn() } as any,
473
- hidden: { value: false },
474
- index: 1,
475
- };
1382
+ const popupViewItem = createViewItem('popup', createDispatchEventMock(), 1, {
1383
+ viewUid: 'popup',
1384
+ filterByTk: '1',
1385
+ });
476
1386
  mockResolveViewParamsToViewList.mockImplementation((_engine, viewParams, routeModel) => [
477
1387
  {
478
1388
  params: viewParams[0],