@nocobase/client-v2 2.2.0-beta.9 → 3.0.0-alpha.1

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 (208) hide show
  1. package/es/APIClient.d.ts +1 -3
  2. package/es/BaseApplication.d.ts +1 -0
  3. package/es/acl/ACLProvider.d.ts +10 -0
  4. package/es/authRedirect.d.ts +5 -0
  5. package/es/collection-field-interface/CollectionFieldInterface.d.ts +1 -0
  6. package/es/collection-field-interface/CollectionFieldInterfaceManager.d.ts +1 -0
  7. package/es/components/AttachmentUpload.d.ts +51 -0
  8. package/es/components/category-tabs/SortableCategoryTabs.d.ts +55 -0
  9. package/es/components/category-tabs/index.d.ts +9 -0
  10. package/es/components/form/ScanInput/useCodeScanner.d.ts +3 -2
  11. package/es/components/form/TypedVariableInput.d.ts +31 -5
  12. package/es/components/form/filter/CollectionFilter.d.ts +2 -0
  13. package/es/components/form/filter/CollectionFilterItem.d.ts +11 -1
  14. package/es/components/form/filter/CollectionFilterPanel.d.ts +2 -0
  15. package/es/components/form/filter/index.d.ts +2 -0
  16. package/es/components/form/filter/useFilterActionProps.d.ts +2 -0
  17. package/es/components/index.d.ts +2 -0
  18. package/es/flow/actions/index.d.ts +1 -1
  19. package/es/flow/actions/linkageRules.d.ts +3 -0
  20. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +1 -0
  21. package/es/flow/admin-shell/admin-layout/NocoBaseLogo.d.ts +10 -0
  22. package/es/flow/components/FieldAssignExactDatePicker.d.ts +1 -0
  23. package/es/flow/components/FieldAssignValueInput.d.ts +9 -0
  24. package/es/flow/components/FlowRoute.d.ts +2 -2
  25. package/es/flow/components/RunJSValueEditor.d.ts +1 -0
  26. package/es/flow/components/filter/FilterGroup.d.ts +1 -0
  27. package/es/flow/components/filter/VariableFilterItem.d.ts +6 -1
  28. package/es/flow/index.d.ts +1 -0
  29. package/es/flow/internal/registerDeviceTypeContext.d.ts +10 -0
  30. package/es/flow/models/base/ActionModelCore.d.ts +4 -2
  31. package/es/flow/models/base/BlockGridModel.d.ts +3 -0
  32. package/es/flow/models/base/PageModel/PageModel.d.ts +12 -0
  33. package/es/flow/models/base/PageModel/PageModelTabBar.d.ts +22 -0
  34. package/es/flow/models/base/PageModel/PageTabModel.d.ts +12 -0
  35. package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +17 -2
  36. package/es/flow/models/blocks/table/TableColumnModel.d.ts +1 -0
  37. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +15 -7
  38. package/es/flow-compat/fieldValidationConstants.d.ts +1 -1
  39. package/es/flow-compat/routeTypes.d.ts +1 -0
  40. package/es/index.d.ts +8 -1
  41. package/es/index.mjs +190 -141
  42. package/es/nocobase-buildin-plugin/currentUserAuthStatus.d.ts +11 -0
  43. package/es/nocobase-buildin-plugin/index.d.ts +9 -0
  44. package/es/settings-app/SettingsApplication.d.ts +18 -0
  45. package/es/settings-app/SettingsBrand.d.ts +16 -0
  46. package/es/settings-app/SettingsBuildInPlugin.d.ts +9 -0
  47. package/es/settings-app/SettingsDocumentRedirect.d.ts +10 -0
  48. package/es/settings-app/SettingsGroupNav.d.ts +17 -0
  49. package/es/settings-app/SettingsPluginSettingsManager.d.ts +14 -0
  50. package/es/settings-app/SettingsRouterManager.d.ts +15 -0
  51. package/es/settings-app/SettingsSearch.d.ts +17 -0
  52. package/es/settings-app/SettingsShell.d.ts +10 -0
  53. package/es/settings-app/isSettingsApp.d.ts +18 -0
  54. package/es/settings-app/runtimePaths.d.ts +18 -0
  55. package/es/settings-app/settingsDocumentPath.d.ts +14 -0
  56. package/es/settings-app/settingsTheme.d.ts +87 -0
  57. package/es/settings-app/useSettingsThemeConfig.d.ts +22 -0
  58. package/es/settings-center/AdminSettingsLayout.d.ts +2 -2
  59. package/es/settings-center/groups.d.ts +66 -0
  60. package/es/settings-center/useSettingsGroups.d.ts +36 -0
  61. package/es/settings-center/useSettingsSearch.d.ts +35 -0
  62. package/es/settings-center/utils.d.ts +24 -0
  63. package/es/theme/index.d.ts +1 -0
  64. package/es/theme/itemActive.d.ts +24 -0
  65. package/es/theme/type.d.ts +9 -0
  66. package/es/utils/getRouteRuntimeVersion.d.ts +23 -0
  67. package/es/utils/index.d.ts +1 -0
  68. package/lib/index.js +195 -146
  69. package/lib/locale/languageCodes.js +3 -1
  70. package/package.json +9 -7
  71. package/src/APIClient.ts +1 -10
  72. package/src/Application.tsx +4 -0
  73. package/src/BaseApplication.tsx +11 -6
  74. package/src/__tests__/app.test.tsx +73 -0
  75. package/src/__tests__/authRedirect.test.ts +137 -0
  76. package/src/__tests__/browserChecker.test.ts +185 -6
  77. package/src/__tests__/getRouteRuntimeVersion.test.ts +68 -0
  78. package/src/__tests__/mockSettingsApplication.ts +29 -0
  79. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +79 -10
  80. package/src/__tests__/plugin-manager.test.tsx +7 -6
  81. package/src/__tests__/settings-application.test.ts +164 -0
  82. package/src/__tests__/settings-auth-routes.test.ts +71 -0
  83. package/src/__tests__/settings-center.test.tsx +38 -37
  84. package/src/__tests__/settings-layout-root.test.tsx +260 -0
  85. package/src/__tests__/settings-runtime-paths.test.ts +99 -0
  86. package/src/__tests__/settings-shell.test.tsx +200 -0
  87. package/src/acl/ACLProvider.tsx +21 -0
  88. package/src/authRedirect.ts +72 -3
  89. package/src/collection-field-interface/CollectionFieldInterface.ts +1 -0
  90. package/src/collection-field-interface/CollectionFieldInterfaceManager.ts +1 -0
  91. package/src/collection-manager/field-validation.ts +1 -1
  92. package/src/components/AttachmentUpload.tsx +275 -0
  93. package/src/components/README.md +7 -1
  94. package/src/components/README.zh-CN.md +6 -1
  95. package/src/components/category-tabs/SortableCategoryTabs.tsx +210 -0
  96. package/src/components/category-tabs/index.ts +10 -0
  97. package/src/components/form/JsonTextArea.tsx +4 -0
  98. package/src/components/form/ScanInput/CodeScanner.tsx +79 -35
  99. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +101 -0
  100. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +284 -11
  101. package/src/components/form/ScanInput/useCodeScanner.ts +332 -24
  102. package/src/components/form/TypedVariableInput.tsx +452 -101
  103. package/src/components/form/__tests__/TypedVariableInput.test.tsx +364 -12
  104. package/src/components/form/filter/CollectionFilter.tsx +4 -0
  105. package/src/components/form/filter/CollectionFilterItem.tsx +32 -7
  106. package/src/components/form/filter/CollectionFilterPanel.tsx +4 -0
  107. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +38 -0
  108. package/src/components/form/filter/__tests__/useFilterActionProps.test.tsx +95 -0
  109. package/src/components/form/filter/index.ts +2 -0
  110. package/src/components/form/filter/useFilterActionProps.ts +37 -6
  111. package/src/components/index.ts +2 -0
  112. package/src/flow/FlowPage.tsx +9 -1
  113. package/src/flow/__tests__/FlowPage.test.tsx +50 -3
  114. package/src/flow/__tests__/FlowRoute.test.tsx +128 -10
  115. package/src/flow/__tests__/PluginFlowEngine.test.ts +58 -0
  116. package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +314 -0
  117. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +102 -0
  118. package/src/flow/actions/__tests__/linkageRules.tab.test.ts +171 -0
  119. package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +7 -3
  120. package/src/flow/actions/index.ts +2 -0
  121. package/src/flow/actions/linkageRules.tsx +111 -16
  122. package/src/flow/actions/linkageRulesRefresh.tsx +2 -6
  123. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -57
  124. package/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx +77 -0
  125. package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +37 -14
  126. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +253 -6
  127. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +77 -13
  128. package/src/flow/common/Markdown/style.ts +4 -0
  129. package/src/flow/components/BlockItemCard.tsx +2 -2
  130. package/src/flow/components/FieldAssignExactDatePicker.tsx +25 -11
  131. package/src/flow/components/FieldAssignValueInput.tsx +91 -20
  132. package/src/flow/components/FlowRoute.tsx +78 -32
  133. package/src/flow/components/RunJSValueEditor.tsx +9 -1
  134. package/src/flow/components/__tests__/FieldAssignValueInput.context.test.tsx +216 -0
  135. package/src/flow/components/filter/FilterGroup.tsx +33 -5
  136. package/src/flow/components/filter/VariableFilterItem.tsx +158 -10
  137. package/src/flow/components/filter/__tests__/FilterGroup.test.tsx +45 -0
  138. package/src/flow/components/filter/__tests__/VariableFilterItem.leftMetaTree.test.tsx +9 -0
  139. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +70 -1
  140. package/src/flow/index.ts +3 -0
  141. package/src/flow/internal/registerDeviceTypeContext.ts +39 -0
  142. package/src/flow/models/base/ActionModelCore.tsx +11 -2
  143. package/src/flow/models/base/BlockGridModel.tsx +26 -0
  144. package/src/flow/models/base/CollectionBlockModel.tsx +6 -2
  145. package/src/flow/models/base/PageModel/PageModel.tsx +387 -53
  146. package/src/flow/models/base/PageModel/PageModelTabBar.tsx +114 -0
  147. package/src/flow/models/base/PageModel/PageTabModel.tsx +249 -18
  148. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +790 -10
  149. package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.module-isolation.test.tsx +130 -0
  150. package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.test.tsx +118 -0
  151. package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +1075 -7
  152. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +49 -0
  153. package/src/flow/models/base/__tests__/BlockGridModel.selectSceneActivation.test.ts +124 -0
  154. package/src/flow/models/base/__tests__/CollectionBlockModel.addAppends.test.ts +41 -0
  155. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +28 -3
  156. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +27 -12
  157. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +36 -0
  158. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +66 -4
  159. package/src/flow/models/blocks/form/QuickEditFormModel.tsx +189 -36
  160. package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +350 -2
  161. package/src/flow/models/blocks/js-block/JSBlock.tsx +1 -1
  162. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +2 -1
  163. package/src/flow/models/blocks/table/TableBlockModel.tsx +4 -2
  164. package/src/flow/models/blocks/table/TableColumnModel.tsx +30 -1
  165. package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +12 -0
  166. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +33 -0
  167. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +2 -1
  168. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +149 -0
  169. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +149 -113
  170. package/src/flow/models/fields/JsonFieldModel.tsx +31 -8
  171. package/src/flow/models/fields/VariableFieldFormModel.tsx +3 -3
  172. package/src/flow/models/fields/__tests__/JsonFieldModel.test.ts +82 -0
  173. package/src/flow/models/fields/__tests__/VariableFieldFormModel.test.tsx +51 -0
  174. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +20 -10
  175. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +24 -12
  176. package/src/flow/models/topbar/TopbarActionModel.tsx +52 -180
  177. package/src/flow/utils/dataScopeFormValueClear.ts +4 -3
  178. package/src/flow-compat/fieldValidationConstants.ts +1 -1
  179. package/src/flow-compat/routeTypes.ts +1 -0
  180. package/src/index.ts +14 -1
  181. package/src/locale/languageCodes.ts +3 -1
  182. package/src/nocobase-buildin-plugin/currentUserAuthStatus.ts +58 -0
  183. package/src/nocobase-buildin-plugin/index.tsx +110 -70
  184. package/src/settings-app/SettingsApplication.ts +31 -0
  185. package/src/settings-app/SettingsBrand.tsx +108 -0
  186. package/src/settings-app/SettingsBuildInPlugin.ts +10 -0
  187. package/src/settings-app/SettingsDocumentRedirect.tsx +26 -0
  188. package/src/settings-app/SettingsGroupNav.tsx +124 -0
  189. package/src/settings-app/SettingsPluginSettingsManager.ts +38 -0
  190. package/src/settings-app/SettingsRouterManager.ts +92 -0
  191. package/src/settings-app/SettingsSearch.tsx +238 -0
  192. package/src/settings-app/SettingsShell.tsx +167 -0
  193. package/src/settings-app/isSettingsApp.ts +21 -0
  194. package/src/settings-app/runtimePaths.ts +104 -0
  195. package/src/settings-app/settingsDocumentPath.ts +66 -0
  196. package/src/settings-app/settingsTheme.ts +301 -0
  197. package/src/settings-app/useSettingsThemeConfig.ts +100 -0
  198. package/src/settings-center/AdminSettingsLayout.tsx +122 -141
  199. package/src/settings-center/SystemSettingsPage.tsx +4 -170
  200. package/src/settings-center/groups.ts +108 -0
  201. package/src/settings-center/useSettingsGroups.ts +105 -0
  202. package/src/settings-center/useSettingsSearch.ts +173 -0
  203. package/src/settings-center/utils.tsx +50 -1
  204. package/src/theme/index.tsx +1 -0
  205. package/src/theme/itemActive.ts +31 -0
  206. package/src/theme/type.ts +10 -0
  207. package/src/utils/getRouteRuntimeVersion.ts +185 -0
  208. package/src/utils/index.tsx +1 -0
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { beforeEach, describe, expect, it, vi } from 'vitest';
11
11
  import type { DragEndEvent } from '@dnd-kit/core';
12
+ import { render, waitFor } from '@testing-library/react';
12
13
 
13
14
  // Mock FlowModel and other dependencies
14
15
  vi.mock('@nocobase/flow-engine', () => {
@@ -221,6 +222,17 @@ describe('PageModel', () => {
221
222
  let pageModel: InstanceType<typeof PageModelClass>;
222
223
  let mockDragEndEvent: DragEndEvent;
223
224
 
225
+ const getDndChildren = (result: { props: { children?: unknown } }) => {
226
+ const children = result.props.children;
227
+ return Array.isArray(children) ? children : [children].filter(Boolean);
228
+ };
229
+
230
+ const getTabsElement = (result: { props: { children?: unknown } }) => getDndChildren(result)[1] as any;
231
+
232
+ const getTabSyncElement = (result: { props: { children?: unknown } }) => getDndChildren(result)[0] as any;
233
+
234
+ const getHiddenActiveTabContent = (result: { props: { children?: unknown } }) => getDndChildren(result)[2];
235
+
224
236
  beforeEach(async () => {
225
237
  vi.resetModules();
226
238
  vi.clearAllMocks();
@@ -299,9 +311,82 @@ describe('PageModel', () => {
299
311
  expect(blockOnActive).toHaveBeenCalledWith(true);
300
312
  expect(blockOnInactive).toHaveBeenCalledWith(false);
301
313
  });
314
+
315
+ it('should use the first actual tab for lifecycle when tabs are disabled even if it is hidden', () => {
316
+ const hiddenFirstTab = {
317
+ uid: 'tab-hidden-first',
318
+ hidden: true,
319
+ context: {},
320
+ subModels: { grid: { mapSubModels: vi.fn() } },
321
+ };
322
+ const visibleSecondTab = {
323
+ uid: 'tab-visible-second',
324
+ context: {},
325
+ subModels: { grid: { mapSubModels: vi.fn() } },
326
+ };
327
+ (pageModel as any).subModels = { tabs: [hiddenFirstTab, visibleSecondTab] };
328
+ pageModel.props = { enableTabs: false, tabActiveKey: 'tab-visible-second' } as any;
329
+ (pageModel as any).context = { view: { navigation: null } };
330
+ const invokeSpy = vi.spyOn(pageModel as any, 'invokeTabModelLifecycleMethod').mockImplementation(() => undefined);
331
+
332
+ pageModel.activateCurrentTab();
333
+ pageModel.deactivateCurrentTab();
334
+
335
+ expect(invokeSpy).toHaveBeenNthCalledWith(1, 'tab-hidden-first', 'onActive', false);
336
+ expect(invokeSpy).toHaveBeenNthCalledWith(2, 'tab-hidden-first', 'onInactive');
337
+ });
338
+
339
+ it('should keep a hidden active tab responsible for lifecycle when tabs are enabled', () => {
340
+ const hiddenActiveTab = {
341
+ uid: 'tab-hidden-active',
342
+ hidden: true,
343
+ context: {},
344
+ subModels: { grid: { mapSubModels: vi.fn() } },
345
+ };
346
+ const visibleTab = {
347
+ uid: 'tab-visible',
348
+ context: {},
349
+ subModels: { grid: { mapSubModels: vi.fn() } },
350
+ };
351
+ (pageModel as any).subModels = { tabs: [hiddenActiveTab, visibleTab] };
352
+ pageModel.props = { enableTabs: true, tabActiveKey: 'tab-hidden-active' } as any;
353
+ (pageModel as any).context = {
354
+ view: { navigation: { viewParams: { tabUid: 'tab-hidden-active' } } },
355
+ };
356
+ const invokeSpy = vi.spyOn(pageModel as any, 'invokeTabModelLifecycleMethod').mockImplementation(() => undefined);
357
+
358
+ pageModel.activateCurrentTab();
359
+ pageModel.deactivateCurrentTab();
360
+
361
+ expect(invokeSpy).toHaveBeenNthCalledWith(1, 'tab-hidden-active', 'onActive', false);
362
+ expect(invokeSpy).toHaveBeenNthCalledWith(2, 'tab-hidden-active', 'onInactive');
363
+ });
302
364
  });
303
365
 
304
366
  describe('renderTabs activeKey logic', () => {
367
+ const createTab = (uid: string, hidden = false) => ({
368
+ uid,
369
+ hidden,
370
+ context: {},
371
+ subModels: { grid: { mapSubModels: vi.fn() } },
372
+ renderChildren: vi.fn(() => uid),
373
+ });
374
+
375
+ const initialSyncState = {
376
+ previousEffectiveActiveKey: undefined,
377
+ previousAllHidden: false,
378
+ lastCorrectionSignature: undefined,
379
+ };
380
+
381
+ const runTabSync = (
382
+ result: { props: { children?: unknown } },
383
+ state: typeof initialSyncState | Record<string, unknown> = initialSyncState,
384
+ ) => {
385
+ const syncElement = getTabSyncElement(result);
386
+ expect(typeof syncElement?.props?.onSync).toBe('function');
387
+ return syncElement.props.onSync(state);
388
+ };
389
+
305
390
  beforeEach(() => {
306
391
  // Mock mapTabs to avoid complex rendering logic inside it
307
392
  pageModel.mapTabs = vi.fn().mockReturnValue([]);
@@ -325,7 +410,7 @@ describe('PageModel', () => {
325
410
 
326
411
  const result = pageModel.renderTabs() as any;
327
412
  // result is <DndProvider><Tabs ... /></DndProvider>
328
- const tabsElement = result.props.children;
413
+ const tabsElement = getTabsElement(result);
329
414
 
330
415
  expect(tabsElement.props.activeKey).toBe('tab-from-params');
331
416
  });
@@ -341,10 +426,589 @@ describe('PageModel', () => {
341
426
  };
342
427
 
343
428
  const result = pageModel.renderTabs() as any;
344
- const tabsElement = result.props.children;
429
+ const tabsElement = getTabsElement(result);
345
430
  expect(tabsElement.props.activeKey).toBe('first-tab-uid');
346
431
  });
347
432
 
433
+ it('should use the first visible tab when activeKey is unspecified', () => {
434
+ const hiddenTab = createTab('tab-hidden', true);
435
+ const visibleTab = createTab('tab-visible');
436
+ (pageModel as any).subModels = { tabs: [hiddenTab, visibleTab] };
437
+ pageModel.context.view = {
438
+ navigation: {
439
+ viewParams: {},
440
+ changeTo: vi.fn(),
441
+ },
442
+ } as any;
443
+
444
+ const tabsElement = getTabsElement(pageModel.renderTabs() as any);
445
+
446
+ expect(tabsElement.props.activeKey).toBe('tab-visible');
447
+ expect(pageModel.context.view.navigation.changeTo).not.toHaveBeenCalled();
448
+ });
449
+
450
+ it('should keep a visible URL activeKey unchanged', () => {
451
+ (pageModel as any).subModels = { tabs: [createTab('tab-1'), createTab('tab-2')] };
452
+ const changeTo = vi.fn();
453
+ pageModel.context.view = {
454
+ navigation: {
455
+ viewParams: { tabUid: 'tab-2' },
456
+ changeTo,
457
+ },
458
+ } as any;
459
+
460
+ const result = pageModel.renderTabs() as any;
461
+ const tabsElement = getTabsElement(result);
462
+ const nextState = runTabSync(result);
463
+
464
+ expect(tabsElement.props.activeKey).toBe('tab-2');
465
+ expect(nextState.effectiveActiveKey).toBe('tab-2');
466
+ expect(changeTo).not.toHaveBeenCalled();
467
+ });
468
+
469
+ it('should keep an unknown URL activeKey for the existing deep-link behavior', () => {
470
+ (pageModel as any).subModels = { tabs: [createTab('tab-1'), createTab('tab-2')] };
471
+ const changeTo = vi.fn();
472
+ const invokeSpy = vi.spyOn(pageModel as any, 'invokeTabModelLifecycleMethod');
473
+ pageModel.context.view = {
474
+ navigation: {
475
+ viewParams: { tabUid: 'unknown-tab' },
476
+ changeTo,
477
+ },
478
+ } as any;
479
+
480
+ const result = pageModel.renderTabs() as any;
481
+ const tabsElement = getTabsElement(result);
482
+ const nextState = runTabSync(result);
483
+
484
+ expect(tabsElement.props.activeKey).toBe('unknown-tab');
485
+ expect(nextState.effectiveActiveKey).toBe('unknown-tab');
486
+ expect(changeTo).not.toHaveBeenCalled();
487
+ expect(invokeSpy).not.toHaveBeenCalled();
488
+ });
489
+
490
+ it('should keep a hidden URL activeKey and render its content without exposing a tab item', () => {
491
+ (pageModel as any).subModels = { tabs: [createTab('tab-hidden', true), createTab('tab-visible')] };
492
+ pageModel.mapTabs = PageModelClass.prototype.mapTabs.bind(pageModel);
493
+ pageModel.props = { tabActiveKey: 'tab-hidden' } as any;
494
+ const changeTo = vi.fn();
495
+ const invokeSpy = vi.spyOn(pageModel as any, 'invokeTabModelLifecycleMethod').mockImplementation(() => undefined);
496
+ pageModel.context.view = {
497
+ navigation: {
498
+ viewParams: { tabUid: 'tab-hidden' },
499
+ changeTo,
500
+ },
501
+ } as any;
502
+
503
+ const result = pageModel.renderTabs() as any;
504
+ const tabsElement = getTabsElement(result);
505
+ const nextState = runTabSync(result, {
506
+ ...initialSyncState,
507
+ previousEffectiveActiveKey: 'tab-hidden',
508
+ });
509
+
510
+ expect(tabsElement.props.activeKey).toBe('tab-hidden');
511
+ expect(tabsElement.props.items.map((item: { key: string }) => item.key)).toEqual(['tab-hidden', 'tab-visible']);
512
+ expect(tabsElement.props.items[0].children).toBe('tab-hidden');
513
+ expect(getHiddenActiveTabContent(result)).toBeUndefined();
514
+ expect(typeof tabsElement.props.renderTabBar).toBe('function');
515
+ expect(changeTo).not.toHaveBeenCalled();
516
+ expect(pageModel.props.tabActiveKey).toBe('tab-hidden');
517
+ expect(invokeSpy).not.toHaveBeenCalled();
518
+ expect(nextState.effectiveActiveKey).toBe('tab-hidden');
519
+ });
520
+
521
+ it('should keep a hidden explicit props activeKey and render its content without lifecycle switching', () => {
522
+ (pageModel as any).subModels = { tabs: [createTab('tab-hidden', true), createTab('tab-visible')] };
523
+ pageModel.props = { tabActiveKey: 'tab-hidden' } as any;
524
+ const invokeSpy = vi.spyOn(pageModel as any, 'invokeTabModelLifecycleMethod').mockImplementation(() => undefined);
525
+ pageModel.context.view = { navigation: null } as any;
526
+
527
+ const result = pageModel.renderTabs() as any;
528
+ const nextState = runTabSync(result, {
529
+ ...initialSyncState,
530
+ previousEffectiveActiveKey: 'tab-hidden',
531
+ });
532
+
533
+ expect(getTabsElement(result).props.activeKey).toBe('tab-hidden');
534
+ expect(pageModel.props.tabActiveKey).toBe('tab-hidden');
535
+ expect(invokeSpy).not.toHaveBeenCalled();
536
+ expect(nextState.effectiveActiveKey).toBe('tab-hidden');
537
+ });
538
+
539
+ it('should not switch when a non-active tab becomes hidden', () => {
540
+ (pageModel as any).subModels = { tabs: [createTab('tab-active'), createTab('tab-hidden', true)] };
541
+ pageModel.props = { tabActiveKey: 'tab-active' } as any;
542
+ const changeTo = vi.fn();
543
+ const invokeSpy = vi.spyOn(pageModel as any, 'invokeTabModelLifecycleMethod');
544
+ pageModel.context.view = {
545
+ navigation: {
546
+ viewParams: { tabUid: 'tab-active' },
547
+ changeTo,
548
+ },
549
+ } as any;
550
+
551
+ const nextState = runTabSync(pageModel.renderTabs() as any, {
552
+ ...initialSyncState,
553
+ previousEffectiveActiveKey: 'tab-active',
554
+ });
555
+
556
+ expect(nextState.effectiveActiveKey).toBe('tab-active');
557
+ expect(changeTo).not.toHaveBeenCalled();
558
+ expect(invokeSpy).not.toHaveBeenCalled();
559
+ });
560
+
561
+ it('should switch an implicitly active tab when it becomes hidden without adding tabUid to URL', () => {
562
+ const oldTab = createTab('tab-old');
563
+ (pageModel as any).subModels = { tabs: [oldTab, createTab('tab-new')] };
564
+ const changeTo = vi.fn();
565
+ const invokeSpy = vi.spyOn(pageModel as any, 'invokeTabModelLifecycleMethod');
566
+ const updateTitleSpy = vi.spyOn(pageModel as any, 'updateDocumentTitle').mockResolvedValue(undefined);
567
+ pageModel.context.view = {
568
+ navigation: {
569
+ viewParams: {},
570
+ changeTo,
571
+ },
572
+ } as any;
573
+
574
+ let syncState = runTabSync(pageModel.renderTabs() as any);
575
+ expect(pageModel.props.tabActiveKey).toBe('tab-old');
576
+
577
+ invokeSpy.mockClear();
578
+ updateTitleSpy.mockClear();
579
+ oldTab.hidden = true;
580
+ const result = pageModel.renderTabs() as any;
581
+ syncState = runTabSync(result, syncState);
582
+
583
+ expect(changeTo).not.toHaveBeenCalled();
584
+ expect(getTabsElement(result).props.activeKey).toBe('tab-new');
585
+ expect(pageModel.props.tabActiveKey).toBe('tab-new');
586
+ expect(invokeSpy).toHaveBeenNthCalledWith(1, 'tab-new', 'onActive');
587
+ expect(invokeSpy).toHaveBeenNthCalledWith(2, 'tab-old', 'onInactive');
588
+ expect(updateTitleSpy).toHaveBeenCalledWith('tab-new', 0);
589
+ expect(syncState.effectiveActiveKey).toBe('tab-new');
590
+
591
+ invokeSpy.mockClear();
592
+ updateTitleSpy.mockClear();
593
+ syncState = runTabSync(pageModel.renderTabs() as any, syncState);
594
+
595
+ expect(invokeSpy).not.toHaveBeenCalled();
596
+ expect(updateTitleSpy).not.toHaveBeenCalled();
597
+ expect(syncState.effectiveActiveKey).toBe('tab-new');
598
+ });
599
+
600
+ it('should switch the implicit default in UI Editor while keeping hidden tab items', () => {
601
+ const oldTab = createTab('tab-old');
602
+ (pageModel as any).subModels = { tabs: [oldTab, createTab('tab-new')] };
603
+ pageModel.mapTabs = PageModelClass.prototype.mapTabs.bind(pageModel);
604
+ const changeTo = vi.fn();
605
+ const invokeSpy = vi.spyOn(pageModel as any, 'invokeTabModelLifecycleMethod');
606
+ const updateTitleSpy = vi.spyOn(pageModel as any, 'updateDocumentTitle').mockResolvedValue(undefined);
607
+ pageModel.context = {
608
+ t: (value: string) => value,
609
+ flowSettingsEnabled: true,
610
+ view: {
611
+ navigation: {
612
+ viewParams: {},
613
+ changeTo,
614
+ },
615
+ },
616
+ } as any;
617
+
618
+ let syncState = runTabSync(pageModel.renderTabs() as any);
619
+ invokeSpy.mockClear();
620
+ updateTitleSpy.mockClear();
621
+
622
+ oldTab.hidden = true;
623
+ const result = pageModel.renderTabs() as any;
624
+ syncState = runTabSync(result, syncState);
625
+
626
+ const tabsElement = getTabsElement(result);
627
+ expect(tabsElement.props.items.map((item: { key: string }) => item.key)).toEqual(['tab-old', 'tab-new']);
628
+ expect(tabsElement.props.activeKey).toBe('tab-new');
629
+ expect(changeTo).not.toHaveBeenCalled();
630
+ expect(pageModel.props.tabActiveKey).toBe('tab-new');
631
+ expect(invokeSpy).toHaveBeenNthCalledWith(1, 'tab-new', 'onActive');
632
+ expect(invokeSpy).toHaveBeenNthCalledWith(2, 'tab-old', 'onInactive');
633
+ expect(updateTitleSpy).toHaveBeenCalledWith('tab-new', 0);
634
+ expect(syncState.effectiveActiveKey).toBe('tab-new');
635
+ });
636
+
637
+ it('should distinguish an implicit activeKey from explicit props without route navigation', () => {
638
+ const oldTab = createTab('tab-old');
639
+ (pageModel as any).subModels = { tabs: [oldTab, createTab('tab-new')] };
640
+ pageModel.context.view = { navigation: null } as any;
641
+
642
+ let syncState = runTabSync(pageModel.renderTabs() as any);
643
+ expect(pageModel.props.tabActiveKey).toBe('tab-old');
644
+
645
+ oldTab.hidden = true;
646
+ const result = pageModel.renderTabs() as any;
647
+ syncState = runTabSync(result, syncState);
648
+
649
+ expect(getTabsElement(result).props.activeKey).toBe('tab-new');
650
+ expect(pageModel.props.tabActiveKey).toBe('tab-new');
651
+ expect(syncState.effectiveActiveKey).toBe('tab-new');
652
+ });
653
+
654
+ it('should preserve an explicit props update after establishing an implicit activeKey', () => {
655
+ const hiddenTab = createTab('tab-hidden', true);
656
+ (pageModel as any).subModels = { tabs: [createTab('tab-default'), hiddenTab] };
657
+ const invokeSpy = vi.spyOn(pageModel as any, 'invokeTabModelLifecycleMethod').mockImplementation(() => undefined);
658
+ pageModel.context.view = { navigation: null } as any;
659
+
660
+ let syncState = runTabSync(pageModel.renderTabs() as any);
661
+ expect(pageModel.props.tabActiveKey).toBe('tab-default');
662
+
663
+ invokeSpy.mockClear();
664
+ pageModel.setProps('tabActiveKey', 'tab-hidden');
665
+ const result = pageModel.renderTabs() as any;
666
+ syncState = runTabSync(result, syncState);
667
+
668
+ expect(getTabsElement(result).props.activeKey).toBe('tab-hidden');
669
+ expect(pageModel.props.tabActiveKey).toBe('tab-hidden');
670
+ expect(invokeSpy).toHaveBeenNthCalledWith(1, 'tab-hidden', 'onActive');
671
+ expect(invokeSpy).toHaveBeenNthCalledWith(2, 'tab-default', 'onInactive');
672
+ expect(syncState.effectiveActiveKey).toBe('tab-hidden');
673
+ });
674
+
675
+ it('should treat an input tabUid as explicit when a page model mounts again', () => {
676
+ const oldTab = createTab('tab-old');
677
+ (pageModel as any).subModels = { tabs: [oldTab, createTab('tab-new')] };
678
+ vi.spyOn(pageModel as any, 'updateDocumentTitle').mockResolvedValue(undefined);
679
+ pageModel.context.view = { navigation: null, inputArgs: {} } as any;
680
+
681
+ runTabSync(pageModel.renderTabs() as any);
682
+ expect(pageModel.props.tabActiveKey).toBe('tab-old');
683
+
684
+ oldTab.hidden = true;
685
+ pageModel.context.view.inputArgs.tabUid = 'tab-old';
686
+ pageModel.onMount();
687
+
688
+ expect(getTabsElement(pageModel.renderTabs() as any).props.activeKey).toBe('tab-old');
689
+ });
690
+
691
+ it('should replace an implicit activeKey when its tab no longer exists', () => {
692
+ const oldTab = createTab('tab-old');
693
+ const newTab = createTab('tab-new');
694
+ (pageModel as any).subModels = { tabs: [oldTab, newTab] };
695
+ (pageModel as any).flowEngine = { getModel: vi.fn(() => undefined) };
696
+ pageModel.context.view = {
697
+ navigation: {
698
+ viewParams: {},
699
+ changeTo: vi.fn(),
700
+ },
701
+ } as any;
702
+
703
+ let syncState = runTabSync(pageModel.renderTabs() as any);
704
+ (pageModel as any).subModels = { tabs: [newTab] };
705
+ const result = pageModel.renderTabs() as any;
706
+ syncState = runTabSync(result, syncState);
707
+
708
+ expect(getTabsElement(result).props.activeKey).toBe('tab-new');
709
+ expect(pageModel.props.tabActiveKey).toBe('tab-new');
710
+ expect(syncState.effectiveActiveKey).toBe('tab-new');
711
+ expect(pageModel.context.view.navigation.changeTo).not.toHaveBeenCalled();
712
+ });
713
+
714
+ it('should clear an implicit activeKey and restore the page title when its last tab no longer exists', async () => {
715
+ const oldTab = createTab('tab-old');
716
+ (pageModel as any).subModels = { tabs: [oldTab] };
717
+ pageModel.props = { enableTabs: true, title: 'Page title' } as any;
718
+ (pageModel as any).flowEngine = { context: {}, getModel: vi.fn(() => undefined) };
719
+ const changeTo = vi.fn();
720
+ const invokeSpy = vi.spyOn(pageModel as any, 'invokeTabModelLifecycleMethod').mockImplementation(() => undefined);
721
+ const updateTitleSpy = vi.spyOn(pageModel as any, 'updateDocumentTitle');
722
+ pageModel.context.view = {
723
+ navigation: {
724
+ viewParams: {},
725
+ changeTo,
726
+ },
727
+ } as any;
728
+
729
+ let syncState = runTabSync(pageModel.renderTabs() as any);
730
+ invokeSpy.mockClear();
731
+ updateTitleSpy.mockClear();
732
+
733
+ (pageModel as any).subModels = { tabs: [] };
734
+ syncState = runTabSync(pageModel.renderTabs() as any, syncState);
735
+
736
+ expect(pageModel.props.tabActiveKey).toBeUndefined();
737
+ expect(changeTo).not.toHaveBeenCalled();
738
+ expect(invokeSpy).toHaveBeenCalledWith('tab-old', 'onInactive');
739
+ expect(updateTitleSpy).toHaveBeenCalledWith(undefined, 0);
740
+ expect(syncState.effectiveActiveKey).toBeUndefined();
741
+ await waitFor(() => expect(document.title).toBe('Page title'));
742
+ });
743
+
744
+ it('should synchronize an implicit hidden tab through the mounted effect', async () => {
745
+ const oldTab = createTab('tab-old');
746
+ (pageModel as any).subModels = { tabs: [oldTab, createTab('tab-new')] };
747
+ const changeTo = vi.fn();
748
+ const invokeSpy = vi.spyOn(pageModel as any, 'invokeTabModelLifecycleMethod');
749
+ const updateTitleSpy = vi.spyOn(pageModel as any, 'updateDocumentTitle').mockResolvedValue(undefined);
750
+ pageModel.context.view = {
751
+ navigation: {
752
+ viewParams: {},
753
+ changeTo,
754
+ },
755
+ } as any;
756
+
757
+ const view = render(pageModel.renderTabs() as any);
758
+ await waitFor(() => expect(pageModel.props.tabActiveKey).toBe('tab-old'));
759
+
760
+ invokeSpy.mockClear();
761
+ updateTitleSpy.mockClear();
762
+ oldTab.hidden = true;
763
+ view.rerender(pageModel.renderTabs() as any);
764
+
765
+ await waitFor(() => expect(pageModel.props.tabActiveKey).toBe('tab-new'));
766
+ expect(changeTo).not.toHaveBeenCalled();
767
+ expect(invokeSpy).toHaveBeenNthCalledWith(1, 'tab-new', 'onActive');
768
+ expect(invokeSpy).toHaveBeenNthCalledWith(2, 'tab-old', 'onInactive');
769
+ expect(updateTitleSpy).toHaveBeenCalledWith('tab-new', 0);
770
+
771
+ invokeSpy.mockClear();
772
+ updateTitleSpy.mockClear();
773
+ view.rerender(pageModel.renderTabs() as any);
774
+ await waitFor(() => expect(pageModel.props.tabActiveKey).toBe('tab-new'));
775
+ expect(invokeSpy).not.toHaveBeenCalled();
776
+ expect(updateTitleSpy).not.toHaveBeenCalled();
777
+ });
778
+
779
+ it('should clear an implicit activeKey when all tabs become hidden', () => {
780
+ const firstTab = createTab('tab-1');
781
+ const secondTab = createTab('tab-2');
782
+ (pageModel as any).subModels = { tabs: [firstTab, secondTab] };
783
+ const changeTo = vi.fn();
784
+ const invokeSpy = vi.spyOn(pageModel as any, 'invokeTabModelLifecycleMethod');
785
+ const updateTitleSpy = vi.spyOn(pageModel as any, 'updateDocumentTitle').mockResolvedValue(undefined);
786
+ pageModel.context.view = {
787
+ navigation: {
788
+ viewParams: {},
789
+ changeTo,
790
+ },
791
+ } as any;
792
+
793
+ let syncState = runTabSync(pageModel.renderTabs() as any);
794
+ invokeSpy.mockClear();
795
+ updateTitleSpy.mockClear();
796
+
797
+ firstTab.hidden = true;
798
+ secondTab.hidden = true;
799
+ const result = pageModel.renderTabs() as any;
800
+ syncState = runTabSync(result, syncState);
801
+
802
+ expect(getTabsElement(result).props.activeKey).not.toBe('tab-1');
803
+ expect(getTabsElement(result).props.activeKey).not.toBe('tab-2');
804
+ expect(pageModel.props.tabActiveKey).toBeUndefined();
805
+ expect(changeTo).not.toHaveBeenCalled();
806
+ expect(invokeSpy).toHaveBeenCalledWith('tab-1', 'onInactive');
807
+ expect(updateTitleSpy).toHaveBeenCalledWith(undefined, 0);
808
+ expect(syncState).toMatchObject({ effectiveActiveKey: undefined, previousAllHidden: true });
809
+ });
810
+
811
+ it('should restore the implicit default when a tab becomes visible after all tabs were hidden', () => {
812
+ const firstTab = createTab('tab-1');
813
+ const secondTab = createTab('tab-2');
814
+ (pageModel as any).subModels = { tabs: [firstTab, secondTab] };
815
+ const changeTo = vi.fn();
816
+ const invokeSpy = vi.spyOn(pageModel as any, 'invokeTabModelLifecycleMethod');
817
+ pageModel.context.view = {
818
+ navigation: {
819
+ viewParams: {},
820
+ changeTo,
821
+ },
822
+ } as any;
823
+
824
+ let syncState = runTabSync(pageModel.renderTabs() as any);
825
+ firstTab.hidden = true;
826
+ secondTab.hidden = true;
827
+ syncState = runTabSync(pageModel.renderTabs() as any, syncState);
828
+ invokeSpy.mockClear();
829
+
830
+ secondTab.hidden = false;
831
+ const result = pageModel.renderTabs() as any;
832
+ syncState = runTabSync(result, syncState);
833
+
834
+ expect(getTabsElement(result).props.activeKey).toBe('tab-2');
835
+ expect(pageModel.props.tabActiveKey).toBe('tab-2');
836
+ expect(changeTo).not.toHaveBeenCalled();
837
+ expect(invokeSpy).toHaveBeenCalledWith('tab-2', 'onActive');
838
+ expect(syncState).toMatchObject({ effectiveActiveKey: 'tab-2', previousAllHidden: false });
839
+ });
840
+
841
+ it('should keep an explicitly requested activeKey when all tabs are hidden', () => {
842
+ (pageModel as any).subModels = { tabs: [createTab('tab-1', true), createTab('tab-2', true)] };
843
+ pageModel.mapTabs = PageModelClass.prototype.mapTabs.bind(pageModel);
844
+ pageModel.props = { tabActiveKey: 'tab-1' } as any;
845
+ const changeTo = vi.fn();
846
+ const invokeSpy = vi.spyOn(pageModel as any, 'invokeTabModelLifecycleMethod').mockImplementation(() => undefined);
847
+ pageModel.context.view = {
848
+ navigation: {
849
+ viewParams: { tabUid: 'tab-1' },
850
+ changeTo,
851
+ },
852
+ } as any;
853
+
854
+ const result = pageModel.renderTabs() as any;
855
+ const nextState = runTabSync(result, {
856
+ ...initialSyncState,
857
+ previousEffectiveActiveKey: 'tab-1',
858
+ });
859
+
860
+ expect(getTabsElement(result).props.activeKey).toBe('tab-1');
861
+ expect(getTabsElement(result).props.items.map((item: { key: string }) => item.key)).toEqual(['tab-1', 'tab-2']);
862
+ expect(getTabsElement(result).props.items[0].children).toBe('tab-1');
863
+ expect(getHiddenActiveTabContent(result)).toBeUndefined();
864
+ expect(changeTo).not.toHaveBeenCalled();
865
+ expect(pageModel.props.tabActiveKey).toBe('tab-1');
866
+ expect(invokeSpy).not.toHaveBeenCalled();
867
+ expect(nextState).toMatchObject({ effectiveActiveKey: 'tab-1', previousAllHidden: true });
868
+ });
869
+
870
+ it('should not implicitly activate a hidden pane when all entries are hidden and activeKey is unspecified', () => {
871
+ (pageModel as any).subModels = { tabs: [createTab('tab-1', true), createTab('tab-2', true)] };
872
+ pageModel.mapTabs = PageModelClass.prototype.mapTabs.bind(pageModel);
873
+ pageModel.context.view = {
874
+ navigation: {
875
+ viewParams: {},
876
+ changeTo: vi.fn(),
877
+ },
878
+ } as any;
879
+
880
+ const tabsElement = getTabsElement(pageModel.renderTabs() as any);
881
+
882
+ expect(tabsElement.props.items.map((item: { key: string }) => item.key)).toEqual(['tab-1', 'tab-2']);
883
+ expect(tabsElement.props.activeKey).not.toBeUndefined();
884
+ expect(tabsElement.props.activeKey).not.toBe('tab-1');
885
+ expect(tabsElement.props.activeKey).not.toBe('tab-2');
886
+ expect(pageModel.context.view.navigation.changeTo).not.toHaveBeenCalled();
887
+ });
888
+
889
+ it('should keep the requested hidden tab active when other tab entries become visible again', () => {
890
+ const firstTab = createTab('tab-1', true);
891
+ const secondTab = createTab('tab-2', true);
892
+ (pageModel as any).subModels = { tabs: [firstTab, secondTab] };
893
+ pageModel.props = { tabActiveKey: 'tab-1' } as any;
894
+ const changeTo = vi.fn();
895
+ const invokeSpy = vi.spyOn(pageModel as any, 'invokeTabModelLifecycleMethod').mockImplementation(() => undefined);
896
+ vi.spyOn(pageModel as any, 'updateDocumentTitle').mockResolvedValue(undefined);
897
+ pageModel.context.view = {
898
+ navigation: {
899
+ viewParams: { tabUid: 'tab-1' },
900
+ changeTo,
901
+ },
902
+ } as any;
903
+
904
+ let syncState = runTabSync(pageModel.renderTabs() as any, {
905
+ ...initialSyncState,
906
+ previousEffectiveActiveKey: 'tab-1',
907
+ });
908
+ secondTab.hidden = false;
909
+ syncState = runTabSync(pageModel.renderTabs() as any, syncState);
910
+
911
+ expect(changeTo).not.toHaveBeenCalled();
912
+ expect(pageModel.props.tabActiveKey).toBe('tab-1');
913
+ expect(invokeSpy).not.toHaveBeenCalled();
914
+ expect(syncState.effectiveActiveKey).toBe('tab-1');
915
+
916
+ firstTab.hidden = false;
917
+ syncState = runTabSync(pageModel.renderTabs() as any, syncState);
918
+
919
+ expect(syncState.effectiveActiveKey).toBe('tab-1');
920
+ expect(pageModel.props.tabActiveKey).toBe('tab-1');
921
+ expect(changeTo).not.toHaveBeenCalled();
922
+ expect(invokeSpy).not.toHaveBeenCalled();
923
+ });
924
+
925
+ it('should keep the hidden active tab after UI Editor closes', () => {
926
+ (pageModel as any).subModels = { tabs: [createTab('tab-hidden', true), createTab('tab-visible')] };
927
+ pageModel.props = { tabActiveKey: 'tab-hidden' } as any;
928
+ const changeTo = vi.fn();
929
+ const invokeSpy = vi.spyOn(pageModel as any, 'invokeTabModelLifecycleMethod').mockImplementation(() => undefined);
930
+ pageModel.context = {
931
+ t: (value: string) => value,
932
+ flowSettingsEnabled: true,
933
+ view: {
934
+ navigation: {
935
+ viewParams: { tabUid: 'tab-hidden' },
936
+ changeTo,
937
+ },
938
+ },
939
+ } as any;
940
+
941
+ let syncState = runTabSync(pageModel.renderTabs() as any, {
942
+ ...initialSyncState,
943
+ previousEffectiveActiveKey: 'tab-hidden',
944
+ });
945
+ expect(changeTo).not.toHaveBeenCalled();
946
+
947
+ pageModel.context.flowSettingsEnabled = false;
948
+ syncState = runTabSync(pageModel.renderTabs() as any, syncState);
949
+
950
+ expect(syncState.effectiveActiveKey).toBe('tab-hidden');
951
+ expect(changeTo).not.toHaveBeenCalled();
952
+ expect(invokeSpy).not.toHaveBeenCalled();
953
+ });
954
+
955
+ it('should keep hidden active synchronization idempotent across repeated effect passes', () => {
956
+ (pageModel as any).subModels = { tabs: [createTab('tab-hidden', true), createTab('tab-visible')] };
957
+ pageModel.props = { tabActiveKey: 'tab-hidden' } as any;
958
+ const changeTo = vi.fn();
959
+ const invokeSpy = vi.spyOn(pageModel as any, 'invokeTabModelLifecycleMethod').mockImplementation(() => undefined);
960
+ pageModel.context.view = {
961
+ navigation: {
962
+ viewParams: { tabUid: 'tab-hidden' },
963
+ changeTo,
964
+ },
965
+ } as any;
966
+
967
+ let syncState = runTabSync(pageModel.renderTabs() as any, {
968
+ ...initialSyncState,
969
+ previousEffectiveActiveKey: 'tab-hidden',
970
+ });
971
+ syncState = runTabSync(pageModel.renderTabs() as any, syncState);
972
+
973
+ expect(syncState.effectiveActiveKey).toBe('tab-hidden');
974
+ expect(changeTo).not.toHaveBeenCalled();
975
+ expect(invokeSpy).not.toHaveBeenCalled();
976
+ });
977
+
978
+ it('should switch lifecycle and title when URL navigation selects a hidden tab', () => {
979
+ (pageModel as any).subModels = { tabs: [createTab('tab-visible'), createTab('tab-hidden', true)] };
980
+ pageModel.props = { tabActiveKey: 'tab-visible' } as any;
981
+ const changeTo = vi.fn();
982
+ const invokeSpy = vi.spyOn(pageModel as any, 'invokeTabModelLifecycleMethod');
983
+ const updateTitleSpy = vi.spyOn(pageModel as any, 'updateDocumentTitle').mockResolvedValue(undefined);
984
+ pageModel.context.view = {
985
+ navigation: {
986
+ viewParams: { tabUid: 'tab-hidden' },
987
+ changeTo,
988
+ },
989
+ } as any;
990
+
991
+ let syncState = runTabSync(pageModel.renderTabs() as any, {
992
+ ...initialSyncState,
993
+ previousEffectiveActiveKey: 'tab-visible',
994
+ });
995
+
996
+ expect(changeTo).not.toHaveBeenCalled();
997
+ expect(pageModel.props.tabActiveKey).toBe('tab-hidden');
998
+ expect(invokeSpy).toHaveBeenNthCalledWith(1, 'tab-hidden', 'onActive');
999
+ expect(invokeSpy).toHaveBeenNthCalledWith(2, 'tab-visible', 'onInactive');
1000
+ expect(updateTitleSpy).toHaveBeenCalledWith('tab-hidden', 0);
1001
+ expect(syncState.effectiveActiveKey).toBe('tab-hidden');
1002
+
1003
+ invokeSpy.mockClear();
1004
+ updateTitleSpy.mockClear();
1005
+ syncState = runTabSync(pageModel.renderTabs() as any, syncState);
1006
+
1007
+ expect(invokeSpy).not.toHaveBeenCalled();
1008
+ expect(updateTitleSpy).not.toHaveBeenCalled();
1009
+ expect(syncState.effectiveActiveKey).toBe('tab-hidden');
1010
+ });
1011
+
348
1012
  it('should use props.tabActiveKey if viewParams is missing', () => {
349
1013
  pageModel.props = { tabActiveKey: 'tab-from-props' } as any;
350
1014
  // @ts-ignore
@@ -353,7 +1017,7 @@ describe('PageModel', () => {
353
1017
  };
354
1018
 
355
1019
  const result = pageModel.renderTabs() as any;
356
- const tabsElement = result.props.children;
1020
+ const tabsElement = getTabsElement(result);
357
1021
  expect(tabsElement.props.activeKey).toBe('tab-from-props');
358
1022
  });
359
1023
 
@@ -366,7 +1030,7 @@ describe('PageModel', () => {
366
1030
  } as any;
367
1031
 
368
1032
  const result = pageModel.renderTabs() as any;
369
- const tabsElement = result.props.children;
1033
+ const tabsElement = getTabsElement(result);
370
1034
 
371
1035
  expect(tabsElement.props.className).toBeUndefined();
372
1036
  });
@@ -380,7 +1044,7 @@ describe('PageModel', () => {
380
1044
  } as any;
381
1045
 
382
1046
  const result = pageModel.renderTabs() as any;
383
- const tabsElement = result.props.children;
1047
+ const tabsElement = getTabsElement(result);
384
1048
 
385
1049
  expect(tabsElement.props.className).toBeUndefined();
386
1050
  });
@@ -394,7 +1058,7 @@ describe('PageModel', () => {
394
1058
  } as any;
395
1059
 
396
1060
  const result = pageModel.renderTabs() as any;
397
- const tabsElement = result.props.children;
1061
+ const tabsElement = getTabsElement(result);
398
1062
 
399
1063
  expect(tabsElement.props.tabBarExtraContent.left).toBeTruthy();
400
1064
  });
@@ -409,7 +1073,7 @@ describe('PageModel', () => {
409
1073
  pageModel.tabBarExtraContent = { left: 'back' } as any;
410
1074
 
411
1075
  const result = pageModel.renderTabs() as any;
412
- const tabsElement = result.props.children;
1076
+ const tabsElement = getTabsElement(result);
413
1077
 
414
1078
  expect(tabsElement.props.tabBarExtraContent.left).toBe('back');
415
1079
  });
@@ -424,7 +1088,7 @@ describe('PageModel', () => {
424
1088
  pageModel.tabBarExtraContent = { right: 'custom-right' } as any;
425
1089
 
426
1090
  const result = pageModel.renderTabs() as any;
427
- const tabsElement = result.props.children;
1091
+ const tabsElement = getTabsElement(result);
428
1092
 
429
1093
  expect(tabsElement.props.tabBarExtraContent.right).toBe('custom-right');
430
1094
  });
@@ -439,7 +1103,7 @@ describe('PageModel', () => {
439
1103
  } as any;
440
1104
 
441
1105
  const result = pageModel.renderTabs() as any;
442
- const tabsElement = result.props.children;
1106
+ const tabsElement = getTabsElement(result);
443
1107
  const rightExtraContent = tabsElement.props.tabBarExtraContent.right;
444
1108
 
445
1109
  expect(rightExtraContent).toBeTruthy();
@@ -466,7 +1130,7 @@ describe('PageModel', () => {
466
1130
  } as any;
467
1131
 
468
1132
  const result = pageModel.renderTabs() as any;
469
- const tabsElement = result.props.children;
1133
+ const tabsElement = getTabsElement(result);
470
1134
 
471
1135
  tabsElement.props.onChange('tab-new');
472
1136
 
@@ -476,6 +1140,29 @@ describe('PageModel', () => {
476
1140
  expect(invokeSpy).not.toHaveBeenCalledWith('tab-new', 'onInactive');
477
1141
  expect(pageModel.props.tabActiveKey).toBe('tab-new');
478
1142
  });
1143
+
1144
+ it('should not repeat lifecycle when synchronization observes a locally committed tab change', () => {
1145
+ (pageModel as any).subModels = { tabs: [createTab('tab-old'), createTab('tab-new')] };
1146
+ pageModel.props = { tabActiveKey: 'tab-old' } as any;
1147
+ pageModel.context.view = { navigation: null } as any;
1148
+ const invokeSpy = vi.spyOn(pageModel as any, 'invokeTabModelLifecycleMethod').mockImplementation(() => undefined);
1149
+
1150
+ let result = pageModel.renderTabs() as any;
1151
+ let syncState = runTabSync(result, {
1152
+ ...initialSyncState,
1153
+ previousEffectiveActiveKey: 'tab-old',
1154
+ });
1155
+ getTabsElement(result).props.onChange('tab-new');
1156
+
1157
+ expect(invokeSpy).toHaveBeenNthCalledWith(1, 'tab-new', 'onActive');
1158
+ expect(invokeSpy).toHaveBeenNthCalledWith(2, 'tab-old', 'onInactive');
1159
+
1160
+ result = pageModel.renderTabs() as any;
1161
+ syncState = runTabSync(result, syncState);
1162
+
1163
+ expect(invokeSpy).toHaveBeenCalledTimes(2);
1164
+ expect(syncState.effectiveActiveKey).toBe('tab-new');
1165
+ });
479
1166
  });
480
1167
 
481
1168
  describe('render header spacing with tabs', () => {
@@ -793,6 +1480,99 @@ describe('PageModel', () => {
793
1480
  expect(document.title).toBe('Fallback tab title');
794
1481
  });
795
1482
 
1483
+ it('should use the hidden active tab documentTitle when its entry is hidden', async () => {
1484
+ pageModel.props = { enableTabs: true, tabActiveKey: 'tab-hidden' } as any;
1485
+ const hiddenTab = {
1486
+ uid: 'tab-hidden',
1487
+ hidden: true,
1488
+ stepParams: {
1489
+ pageTabSettings: {
1490
+ tab: {
1491
+ documentTitle: 'Hidden tab document title',
1492
+ },
1493
+ },
1494
+ },
1495
+ getTabTitle: vi.fn(() => 'Hidden tab'),
1496
+ };
1497
+ const visibleTab = {
1498
+ uid: 'tab-visible',
1499
+ stepParams: {
1500
+ pageTabSettings: {
1501
+ tab: {
1502
+ documentTitle: 'Visible tab document title',
1503
+ },
1504
+ },
1505
+ },
1506
+ getTabTitle: vi.fn(() => 'Visible tab'),
1507
+ };
1508
+ (pageModel as any).subModels = { tabs: [hiddenTab, visibleTab] };
1509
+ (pageModel as any).context.view.navigation = {
1510
+ viewParams: { tabUid: 'tab-hidden' },
1511
+ };
1512
+
1513
+ await (pageModel as any).updateDocumentTitle();
1514
+
1515
+ expect(document.title).toBe('Hidden tab document title');
1516
+ });
1517
+
1518
+ it('should use page documentTitle when tabs are enabled but all tabs are hidden', async () => {
1519
+ pageModel.props = { enableTabs: true, title: 'Page title' } as any;
1520
+ const hiddenTab = {
1521
+ uid: 'tab-hidden',
1522
+ hidden: true,
1523
+ stepParams: {
1524
+ pageTabSettings: {
1525
+ tab: {
1526
+ documentTitle: 'Hidden tab title',
1527
+ },
1528
+ },
1529
+ },
1530
+ getTabTitle: vi.fn(() => 'Hidden tab'),
1531
+ };
1532
+ (pageModel as any).subModels = { tabs: [hiddenTab] };
1533
+ (pageModel as any).stepParams = {
1534
+ pageSettings: {
1535
+ general: {
1536
+ documentTitle: 'Page document title',
1537
+ },
1538
+ },
1539
+ };
1540
+ (pageModel as any).flowEngine.getModel = vi.fn(() => hiddenTab);
1541
+
1542
+ await (pageModel as any).updateDocumentTitle();
1543
+
1544
+ expect(document.title).toBe('Page document title');
1545
+ });
1546
+
1547
+ it('should fallback to page title when all tabs are hidden and page documentTitle is empty', async () => {
1548
+ pageModel.props = { enableTabs: true, title: 'Fallback page title' } as any;
1549
+ const hiddenTab = {
1550
+ uid: 'tab-hidden',
1551
+ hidden: true,
1552
+ stepParams: {
1553
+ pageTabSettings: {
1554
+ tab: {
1555
+ documentTitle: 'Hidden tab title',
1556
+ },
1557
+ },
1558
+ },
1559
+ getTabTitle: vi.fn(() => 'Hidden tab'),
1560
+ };
1561
+ (pageModel as any).subModels = { tabs: [hiddenTab] };
1562
+ (pageModel as any).stepParams = {
1563
+ pageSettings: {
1564
+ general: {
1565
+ documentTitle: '',
1566
+ },
1567
+ },
1568
+ };
1569
+ (pageModel as any).flowEngine.getModel = vi.fn(() => hiddenTab);
1570
+
1571
+ await (pageModel as any).updateDocumentTitle();
1572
+
1573
+ expect(document.title).toBe('Fallback page title');
1574
+ });
1575
+
796
1576
  it('should also update document title for closable page (popup)', async () => {
797
1577
  pageModel.props = { enableTabs: false, title: 'Popup page title' } as any;
798
1578
  (pageModel as any).context = {