@nocobase/client-v2 2.1.31 → 2.2.0-alpha.10

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 (170) hide show
  1. package/es/APIClient.d.ts +1 -3
  2. package/es/BaseApplication.d.ts +2 -0
  3. package/es/PluginSettingsManager.d.ts +33 -0
  4. package/es/RouteRepository.d.ts +29 -6
  5. package/es/RouterManager.d.ts +15 -0
  6. package/es/authRedirect.d.ts +1 -0
  7. package/es/components/category-tabs/SortableCategoryTabs.d.ts +55 -0
  8. package/es/components/category-tabs/index.d.ts +9 -0
  9. package/es/components/form/TypedVariableInput.d.ts +31 -5
  10. package/es/components/form/filter/CollectionFilter.d.ts +2 -0
  11. package/es/components/form/filter/CollectionFilterItem.d.ts +11 -1
  12. package/es/components/form/filter/CollectionFilterPanel.d.ts +2 -0
  13. package/es/components/form/filter/index.d.ts +2 -0
  14. package/es/components/form/filter/useFilterActionProps.d.ts +2 -0
  15. package/es/components/index.d.ts +2 -0
  16. package/es/entry-actions/EntryActionManager.d.ts +24 -0
  17. package/es/entry-actions/index.d.ts +9 -0
  18. package/es/flow/actions/index.d.ts +1 -1
  19. package/es/flow/actions/linkageRules.d.ts +2 -0
  20. package/es/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.d.ts +2 -0
  21. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.d.ts +2 -1
  22. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +3 -0
  23. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.d.ts +3 -2
  24. package/es/flow/admin-shell/admin-layout/AdminLayoutSlotModels.d.ts +5 -0
  25. package/es/flow/admin-shell/admin-layout/AppListRender.d.ts +2 -1
  26. package/es/flow/admin-shell/admin-layout/AppSwitcherActionPanelModel.d.ts +24 -0
  27. package/es/flow/admin-shell/admin-layout/index.d.ts +1 -0
  28. package/es/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.d.ts +6 -0
  29. package/es/flow/admin-shell/admin-layout/useApplications.d.ts +7 -2
  30. package/es/flow/components/FieldAssignExactDatePicker.d.ts +1 -0
  31. package/es/flow/components/FieldAssignValueInput.d.ts +9 -0
  32. package/es/flow/components/RunJSValueEditor.d.ts +1 -0
  33. package/es/flow/components/filter/FilterGroup.d.ts +1 -0
  34. package/es/flow/components/filter/VariableFilterItem.d.ts +6 -1
  35. package/es/flow/index.d.ts +1 -0
  36. package/es/flow/models/base/ActionModelCore.d.ts +2 -0
  37. package/es/flow/models/base/GridModel.d.ts +1 -1
  38. package/es/flow/models/base/PageModel/PageModel.d.ts +12 -0
  39. package/es/flow/models/base/PageModel/PageModelTabBar.d.ts +22 -0
  40. package/es/flow/models/base/PageModel/PageTabModel.d.ts +12 -0
  41. package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +9 -0
  42. package/es/flow-compat/routeTypes.d.ts +1 -0
  43. package/es/index.d.ts +7 -1
  44. package/es/index.mjs +223 -135
  45. package/es/utils/getRouteRuntimeVersion.d.ts +23 -0
  46. package/es/utils/index.d.ts +1 -0
  47. package/lib/index.js +220 -132
  48. package/package.json +7 -7
  49. package/src/APIClient.ts +1 -10
  50. package/src/Application.tsx +4 -0
  51. package/src/BaseApplication.tsx +2 -0
  52. package/src/PluginSettingsManager.ts +53 -0
  53. package/src/RouteRepository.ts +151 -24
  54. package/src/RouterManager.tsx +142 -1
  55. package/src/__tests__/PluginSettingsManager.test.ts +13 -0
  56. package/src/__tests__/RouteRepository.test.ts +239 -0
  57. package/src/__tests__/RouterManager.test.ts +125 -0
  58. package/src/__tests__/app.test.tsx +18 -0
  59. package/src/__tests__/authRedirect.test.ts +17 -0
  60. package/src/__tests__/browserChecker.test.ts +61 -0
  61. package/src/__tests__/exports.test.ts +16 -0
  62. package/src/__tests__/getRouteRuntimeVersion.test.ts +68 -0
  63. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +28 -0
  64. package/src/__tests__/plugin-manager.test.tsx +44 -2
  65. package/src/__tests__/settings-center.test.tsx +40 -1
  66. package/src/authRedirect.ts +38 -0
  67. package/src/components/README.md +7 -1
  68. package/src/components/README.zh-CN.md +6 -1
  69. package/src/components/category-tabs/SortableCategoryTabs.tsx +210 -0
  70. package/src/components/category-tabs/index.ts +10 -0
  71. package/src/components/form/JsonTextArea.tsx +4 -0
  72. package/src/components/form/TypedVariableInput.tsx +452 -101
  73. package/src/components/form/__tests__/TypedVariableInput.test.tsx +364 -12
  74. package/src/components/form/filter/CollectionFilter.tsx +4 -0
  75. package/src/components/form/filter/CollectionFilterItem.tsx +32 -7
  76. package/src/components/form/filter/CollectionFilterPanel.tsx +4 -0
  77. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +38 -0
  78. package/src/components/form/filter/__tests__/useFilterActionProps.test.tsx +95 -0
  79. package/src/components/form/filter/index.ts +2 -0
  80. package/src/components/form/filter/useFilterActionProps.ts +37 -6
  81. package/src/components/index.ts +2 -0
  82. package/src/entry-actions/EntryActionManager.ts +76 -0
  83. package/src/entry-actions/index.ts +10 -0
  84. package/src/flow/__tests__/FlowRoute.test.tsx +1084 -145
  85. package/src/flow/actions/__tests__/linkageRules.tab.test.ts +171 -0
  86. package/src/flow/actions/customVariable.tsx +1 -2
  87. package/src/flow/actions/index.ts +2 -0
  88. package/src/flow/actions/linkageRules.tsx +65 -8
  89. package/src/flow/actions/runjs.tsx +2 -14
  90. package/src/flow/admin-shell/BaseLayoutModel.tsx +13 -0
  91. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +46 -13
  92. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.test.tsx +486 -0
  93. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +64 -8
  94. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.ts +5 -3
  95. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +61 -9
  96. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +71 -8
  97. package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +1 -1
  98. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +23 -9
  99. package/src/flow/admin-shell/admin-layout/AppListRender.tsx +13 -2
  100. package/src/flow/admin-shell/admin-layout/AppSwitcherActionPanelModel.tsx +289 -0
  101. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +435 -0
  102. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +99 -0
  103. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +77 -0
  104. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +214 -2
  105. package/src/flow/admin-shell/admin-layout/index.ts +1 -0
  106. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +20 -0
  107. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +33 -5
  108. package/src/flow/admin-shell/admin-layout/useApplications.tsx +81 -12
  109. package/src/flow/components/DefaultValue.tsx +1 -2
  110. package/src/flow/components/FieldAssignExactDatePicker.tsx +25 -11
  111. package/src/flow/components/FieldAssignValueInput.tsx +76 -9
  112. package/src/flow/components/FlowRoute.tsx +185 -28
  113. package/src/flow/components/RunJSValueEditor.tsx +9 -1
  114. package/src/flow/components/__tests__/FieldAssignValueInput.context.test.tsx +84 -2
  115. package/src/flow/components/code-editor/__tests__/useCodeRunner.test.tsx +2 -17
  116. package/src/flow/components/code-editor/hooks/useCodeRunner.ts +2 -14
  117. package/src/flow/components/code-editor/runjsDiagnostics.ts +8 -45
  118. package/src/flow/components/filter/FilterGroup.tsx +33 -5
  119. package/src/flow/components/filter/VariableFilterItem.tsx +158 -10
  120. package/src/flow/components/filter/__tests__/FilterGroup.test.tsx +45 -0
  121. package/src/flow/components/filter/__tests__/VariableFilterItem.leftMetaTree.test.tsx +9 -0
  122. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +70 -1
  123. package/src/flow/index.ts +1 -0
  124. package/src/flow/models/actions/JSActionModel.tsx +2 -7
  125. package/src/flow/models/actions/JSCollectionActionModel.tsx +2 -7
  126. package/src/flow/models/actions/JSItemActionModel.tsx +2 -7
  127. package/src/flow/models/actions/JSRecordActionModel.tsx +2 -7
  128. package/src/flow/models/base/ActionModel.tsx +21 -8
  129. package/src/flow/models/base/ActionModelCore.tsx +5 -0
  130. package/src/flow/models/base/GridModel.tsx +38 -7
  131. package/src/flow/models/base/PageModel/PageModel.tsx +387 -53
  132. package/src/flow/models/base/PageModel/PageModelTabBar.tsx +114 -0
  133. package/src/flow/models/base/PageModel/PageTabModel.tsx +186 -3
  134. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +790 -14
  135. package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.module-isolation.test.tsx +130 -0
  136. package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.test.tsx +118 -0
  137. package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +586 -1
  138. package/src/flow/models/base/__tests__/ActionModel.test.ts +83 -0
  139. package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +239 -1
  140. package/src/flow/models/base/__tests__/transformRowsToSingleColumn.test.ts +48 -0
  141. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +1 -2
  142. package/src/flow/models/blocks/filter-form/FilterFormJSActionModel.tsx +2 -7
  143. package/src/flow/models/blocks/form/JSFormActionModel.tsx +2 -7
  144. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +87 -0
  145. package/src/flow/models/blocks/form/value-runtime/rules.ts +2 -2
  146. package/src/flow/models/blocks/form/value-runtime/runtime.ts +91 -0
  147. package/src/flow/models/blocks/js-block/JSBlock.tsx +2 -7
  148. package/src/flow/models/blocks/table/JSColumnModel.tsx +1 -9
  149. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +1 -1
  150. package/src/flow/models/blocks/table/TableBlockModel.tsx +13 -3
  151. package/src/flow/models/blocks/table/__tests__/TableActionsColumnModel.test.tsx +54 -0
  152. package/src/flow/models/blocks/table/__tests__/TableBlockModel.mobileSettingsButtons.test.tsx +78 -0
  153. package/src/flow/models/fields/JSEditableFieldModel.tsx +2 -11
  154. package/src/flow/models/fields/JSFieldModel.tsx +2 -7
  155. package/src/flow/models/fields/JSItemModel.tsx +2 -14
  156. package/src/flow/models/fields/VariableFieldFormModel.tsx +3 -3
  157. package/src/flow/models/fields/__tests__/VariableFieldFormModel.test.tsx +51 -0
  158. package/src/flow/models/topbar/TopbarActionModel.tsx +168 -10
  159. package/src/flow-compat/Popover.tsx +43 -4
  160. package/src/flow-compat/__tests__/Popover.test.tsx +34 -0
  161. package/src/flow-compat/routeTypes.ts +1 -0
  162. package/src/index.ts +13 -1
  163. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +41 -1
  164. package/src/nocobase-buildin-plugin/index.tsx +19 -3
  165. package/src/settings-center/AdminSettingsLayout.tsx +10 -2
  166. package/src/settings-center/SystemSettingsPage.tsx +0 -1
  167. package/src/settings-center/plugin-manager/PluginCard.tsx +2 -2
  168. package/src/settings-center/utils.tsx +0 -6
  169. package/src/utils/getRouteRuntimeVersion.ts +185 -0
  170. package/src/utils/index.tsx +1 -0
@@ -9,10 +9,12 @@
9
9
 
10
10
  import React from 'react';
11
11
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
12
- import { MemoryRouter, Route, Routes } from 'react-router-dom';
13
- import { render, screen, waitFor } from '@testing-library/react';
12
+ import { MemoryRouter, Route, Router, Routes, useNavigate } from 'react-router-dom';
13
+ import { act, render, screen, waitFor } from '@testing-library/react';
14
14
  import { FlowContextProvider, FlowEngine, FlowEngineProvider, type FlowModel } from '@nocobase/flow-engine';
15
15
  import FlowRoute from '../components/FlowRoute';
16
+ import { RouteRepository } from '../../RouteRepository';
17
+ import { NocoBaseDesktopRouteType } from '../../flow-compat';
16
18
 
17
19
  type MockAdminLayoutModel = FlowModel & {
18
20
  registerRoutePage: ReturnType<typeof vi.fn>;
@@ -20,6 +22,8 @@ type MockAdminLayoutModel = FlowModel & {
20
22
  unregisterRoutePage: ReturnType<typeof vi.fn>;
21
23
  };
22
24
 
25
+ type RouterNavigator = React.ComponentProps<typeof Router>['navigator'];
26
+
23
27
  const { hookState } = vi.hoisted(() => {
24
28
  return {
25
29
  hookState: {
@@ -322,6 +326,94 @@ describe('FlowRoute', () => {
322
326
  });
323
327
  });
324
328
 
329
+ it('should ensure accessible routes for the current layout before bridging', async () => {
330
+ const engine = new FlowEngine();
331
+ const request = vi.fn().mockResolvedValue({
332
+ data: {
333
+ data: [
334
+ {
335
+ type: 'flowPage',
336
+ schemaUid: 'mobile-page',
337
+ },
338
+ ],
339
+ },
340
+ });
341
+ const routeRepository = new RouteRepository({
342
+ api: {
343
+ request,
344
+ resource: vi.fn(),
345
+ },
346
+ } as never);
347
+ routeRepository.setRoutes([], 'admin-layout-model');
348
+ const deactivateLayout = routeRepository.activateLayout({
349
+ uid: 'mobile-layout-model',
350
+ });
351
+ engine.context.defineProperty('routeRepository', {
352
+ value: routeRepository,
353
+ });
354
+ engine.context.defineProperty('app', {
355
+ value: {
356
+ getPublicPath: () => '/v2/',
357
+ router: {
358
+ getBasename: () => '/v2',
359
+ },
360
+ },
361
+ });
362
+ const routeModel = engine.createModel({
363
+ uid: 'mobile-route-model',
364
+ use: 'FlowModel',
365
+ });
366
+ routeModel.context.defineProperty('layout', {
367
+ value: {
368
+ routeName: 'mobile',
369
+ routePath: '/mobile',
370
+ rootRouteName: 'mobile',
371
+ uid: 'mobile-layout-model',
372
+ layoutModelClass: 'MobileLayoutModel',
373
+ rootPageModelClass: 'MobileRootPageModel',
374
+ childPageModelClass: 'MobileChildPageModel',
375
+ authCheck: true,
376
+ },
377
+ });
378
+
379
+ const mobileLayoutModel: MockAdminLayoutModel = Object.assign(
380
+ engine.createModel({
381
+ uid: 'mobile-layout-model',
382
+ use: 'FlowModel',
383
+ }),
384
+ {
385
+ registerRoutePage: vi.fn(),
386
+ updateRoutePage: vi.fn(),
387
+ unregisterRoutePage: vi.fn(),
388
+ },
389
+ );
390
+
391
+ render(
392
+ <FlowEngineProvider engine={engine}>
393
+ <FlowContextProvider context={routeModel.context}>
394
+ <MemoryRouter initialEntries={['/mobile/mobile-page']}>
395
+ <Routes>
396
+ <Route path="/mobile/:name" element={<FlowRoute />} />
397
+ </Routes>
398
+ </MemoryRouter>
399
+ </FlowContextProvider>
400
+ </FlowEngineProvider>,
401
+ );
402
+
403
+ await waitFor(() => {
404
+ expect(request).toHaveBeenCalledWith({
405
+ url: '/desktopRoutes:listAccessible',
406
+ params: {
407
+ tree: true,
408
+ sort: 'sort',
409
+ layout: 'mobile-layout-model',
410
+ },
411
+ });
412
+ expect(mobileLayoutModel.registerRoutePage).toHaveBeenCalledWith('mobile-page', expect.any(Object));
413
+ });
414
+ deactivateLayout();
415
+ });
416
+
325
417
  it('should fail fast when admin-layout-model is missing', () => {
326
418
  const engine = new FlowEngine();
327
419
  engine.context.defineProperty('route', {
@@ -421,6 +513,75 @@ describe('FlowRoute', () => {
421
513
  });
422
514
  });
423
515
 
516
+ it('should not rerun accessible guard when layout reference changes but fields stay the same', async () => {
517
+ const engine = new FlowEngine();
518
+ const ensureAccessibleLoaded = vi.fn().mockResolvedValue([]);
519
+ const getRouteBySchemaUid = vi.fn(() => ({ type: 'flowPage', schemaUid: 'test-page' }));
520
+ engine.context.defineProperty('routeRepository', {
521
+ value: {
522
+ refreshAccessible: hookState.refresh,
523
+ isAccessibleLoaded: () => true,
524
+ ensureAccessibleLoaded,
525
+ getRouteBySchemaUid,
526
+ },
527
+ });
528
+ engine.context.defineProperty('app', {
529
+ value: {
530
+ getPublicPath: () => '/v2/',
531
+ router: {
532
+ getBasename: () => '/v2',
533
+ },
534
+ },
535
+ });
536
+
537
+ const layoutModel: MockAdminLayoutModel = Object.assign(
538
+ engine.createModel({ uid: 'legacy-admin-layout-model', use: 'FlowModel' }),
539
+ {
540
+ registerRoutePage: vi.fn(),
541
+ updateRoutePage: vi.fn(),
542
+ unregisterRoutePage: vi.fn(),
543
+ },
544
+ );
545
+ let layoutReadCount = 0;
546
+ const stableLayout = {
547
+ uid: 'admin-layout-model',
548
+ routeName: 'admin',
549
+ routePath: '/admin',
550
+ authCheck: true,
551
+ };
552
+ Object.defineProperty(layoutModel, 'layout', {
553
+ get: () => {
554
+ layoutReadCount += 1;
555
+ return layoutReadCount <= 2 ? { ...stableLayout } : stableLayout;
556
+ },
557
+ });
558
+
559
+ const element = (
560
+ <FlowEngineProvider engine={engine}>
561
+ <MemoryRouter initialEntries={['/flow/test-page']}>
562
+ <Routes>
563
+ <Route path="/flow/:name" element={<FlowRoute getLayoutModel={() => layoutModel as any} />} />
564
+ </Routes>
565
+ </MemoryRouter>
566
+ </FlowEngineProvider>
567
+ );
568
+ const { rerender } = render(element);
569
+
570
+ await waitFor(() => {
571
+ expect(layoutModel.registerRoutePage).toHaveBeenCalledWith('test-page', expect.any(Object));
572
+ });
573
+ expect(ensureAccessibleLoaded).not.toHaveBeenCalled();
574
+ expect(getRouteBySchemaUid).toHaveBeenCalledTimes(1);
575
+
576
+ rerender(element);
577
+
578
+ await waitFor(() => {
579
+ expect(layoutModel.updateRoutePage).toHaveBeenCalled();
580
+ });
581
+ expect(ensureAccessibleLoaded).not.toHaveBeenCalled();
582
+ expect(getRouteBySchemaUid).toHaveBeenCalledTimes(1);
583
+ });
584
+
424
585
  it('should explain how to open a legacy page outside the v2 runtime', async () => {
425
586
  const originalLocation = window.location;
426
587
  const replace = vi.fn();
@@ -689,6 +850,22 @@ describe('FlowRoute', () => {
689
850
  },
690
851
  },
691
852
  });
853
+ const routeModel = engine.createModel({
854
+ uid: 'public-form-route-model',
855
+ use: 'FlowModel',
856
+ });
857
+ routeModel.context.defineProperty('layout', {
858
+ value: {
859
+ routeName: 'public-forms',
860
+ routePath: '/public-forms',
861
+ rootRouteName: 'public-forms',
862
+ uid: 'public-form-layout-model',
863
+ layoutModelClass: 'PublicFormLayoutModel',
864
+ rootPageModelClass: 'PublicFormPageModel',
865
+ childPageModelClass: 'ChildPageModel',
866
+ authCheck: false,
867
+ },
868
+ });
692
869
 
693
870
  const layoutModel: MockAdminLayoutModel = Object.assign(
694
871
  engine.createModel({ uid: 'public-form-layout-model', use: 'FlowModel' }),
@@ -701,14 +878,16 @@ describe('FlowRoute', () => {
701
878
 
702
879
  render(
703
880
  <FlowEngineProvider engine={engine}>
704
- <MemoryRouter initialEntries={['/public-forms/public-form-1']}>
705
- <Routes>
706
- <Route
707
- path="/public-forms/:name"
708
- element={<FlowRoute legacyPageBehavior="notFound" getLayoutModel={() => layoutModel} />}
709
- />
710
- </Routes>
711
- </MemoryRouter>
881
+ <FlowContextProvider context={routeModel.context}>
882
+ <MemoryRouter initialEntries={['/public-forms/public-form-1']}>
883
+ <Routes>
884
+ <Route
885
+ path="/public-forms/:name"
886
+ element={<FlowRoute legacyPageBehavior="notFound" getLayoutModel={() => layoutModel} />}
887
+ />
888
+ </Routes>
889
+ </MemoryRouter>
890
+ </FlowContextProvider>
712
891
  </FlowEngineProvider>,
713
892
  );
714
893
 
@@ -718,20 +897,13 @@ describe('FlowRoute', () => {
718
897
  expect(screen.queryByText('404')).not.toBeInTheDocument();
719
898
  });
720
899
 
721
- it('should check model existence without occupying the route model uid', async () => {
900
+ it('should use getLayoutModel layout authCheck when FlowContext has no layout', async () => {
722
901
  const engine = new FlowEngine();
723
- const findOne = vi.fn().mockResolvedValue({
724
- uid: 'public-form-1',
725
- });
726
- const request = vi.fn().mockResolvedValue({
727
- data: {
728
- data: {
729
- uid: 'public-form-1',
730
- },
731
- },
732
- });
733
902
  engine.setModelRepository({
734
- findOne,
903
+ findOne: vi.fn().mockResolvedValue({
904
+ uid: 'public-form-1',
905
+ use: 'FlowModel',
906
+ }),
735
907
  save: vi.fn(),
736
908
  destroy: vi.fn(),
737
909
  } as any);
@@ -745,20 +917,22 @@ describe('FlowRoute', () => {
745
917
  });
746
918
  engine.context.defineProperty('app', {
747
919
  value: {
748
- apiClient: {
749
- request,
750
- },
751
920
  getPublicPath: () => '/v2/',
752
921
  router: {
753
922
  getBasename: () => '/v2',
754
923
  },
755
924
  },
756
925
  });
757
- const routeModel = engine.createModel({
758
- uid: 'public-form-route-model',
759
- use: 'FlowModel',
760
- });
761
- routeModel.context.defineProperty('layout', {
926
+
927
+ const layoutModel: MockAdminLayoutModel = Object.assign(
928
+ engine.createModel({ uid: 'public-form-layout-model', use: 'FlowModel' }),
929
+ {
930
+ registerRoutePage: vi.fn(),
931
+ updateRoutePage: vi.fn(),
932
+ unregisterRoutePage: vi.fn(),
933
+ },
934
+ );
935
+ Object.defineProperty(layoutModel, 'layout', {
762
936
  value: {
763
937
  routeName: 'public-forms',
764
938
  routePath: '/public-forms',
@@ -771,42 +945,30 @@ describe('FlowRoute', () => {
771
945
  },
772
946
  });
773
947
 
774
- const layoutModel: MockAdminLayoutModel = Object.assign(
775
- engine.createModel({ uid: 'public-form-layout-model', use: 'FlowModel' }),
776
- {
777
- registerRoutePage: vi.fn(),
778
- updateRoutePage: vi.fn(),
779
- unregisterRoutePage: vi.fn(),
780
- },
781
- );
782
-
783
948
  render(
784
949
  <FlowEngineProvider engine={engine}>
785
- <FlowContextProvider context={routeModel.context}>
786
- <MemoryRouter initialEntries={['/public-forms/public-form-1']}>
787
- <Routes>
788
- <Route path="/public-forms/:name" element={<FlowRoute legacyPageBehavior="notFound" />} />
789
- </Routes>
790
- </MemoryRouter>
791
- </FlowContextProvider>
950
+ <MemoryRouter initialEntries={['/public-forms/public-form-1']}>
951
+ <Routes>
952
+ <Route
953
+ path="/public-forms/:name"
954
+ element={<FlowRoute legacyPageBehavior="notFound" getLayoutModel={() => layoutModel as any} />}
955
+ />
956
+ </Routes>
957
+ </MemoryRouter>
792
958
  </FlowEngineProvider>,
793
959
  );
794
960
 
795
961
  await waitFor(() => {
796
962
  expect(layoutModel.registerRoutePage).toHaveBeenCalledWith('public-form-1', expect.any(Object));
797
963
  });
798
- expect(findOne).toHaveBeenCalledWith({ uid: 'public-form-1' });
799
- expect(request).not.toHaveBeenCalled();
800
- expect(engine.getModel('public-form-1')).toBeUndefined();
964
+ expect(screen.queryByText('404')).not.toBeInTheDocument();
801
965
  });
802
966
 
803
- it('should not skip accessible route loading just because layout authCheck is false', async () => {
967
+ it('should render not found when admin route is not accessible', async () => {
804
968
  const engine = new FlowEngine();
805
- const ensureAccessibleLoaded = vi.fn().mockRejectedValue(new Error('cannot load accessible routes'));
806
- const getRouteBySchemaUid = vi.fn();
807
969
  engine.setModelRepository({
808
970
  findOne: vi.fn().mockResolvedValue({
809
- uid: 'public-form-1',
971
+ uid: 'admin-denied-page',
810
972
  use: 'FlowModel',
811
973
  }),
812
974
  save: vi.fn(),
@@ -815,9 +977,9 @@ describe('FlowRoute', () => {
815
977
  engine.context.defineProperty('routeRepository', {
816
978
  value: {
817
979
  refreshAccessible: hookState.refresh,
818
- isAccessibleLoaded: () => false,
819
- ensureAccessibleLoaded,
820
- getRouteBySchemaUid,
980
+ isAccessibleLoaded: () => true,
981
+ ensureAccessibleLoaded: vi.fn().mockResolvedValue([]),
982
+ getRouteBySchemaUid: vi.fn(() => undefined),
821
983
  },
822
984
  });
823
985
  engine.context.defineProperty('app', {
@@ -828,25 +990,9 @@ describe('FlowRoute', () => {
828
990
  },
829
991
  },
830
992
  });
831
- const routeModel = engine.createModel({
832
- uid: 'public-form-route-model',
833
- use: 'FlowModel',
834
- });
835
- routeModel.context.defineProperty('layout', {
836
- value: {
837
- routeName: 'public-forms',
838
- routePath: '/public-forms',
839
- rootRouteName: 'public-forms',
840
- uid: 'public-form-layout-model',
841
- layoutModelClass: 'PublicFormLayoutModel',
842
- rootPageModelClass: 'PublicFormPageModel',
843
- childPageModelClass: 'ChildPageModel',
844
- authCheck: false,
845
- },
846
- });
847
993
 
848
- const layoutModel: MockAdminLayoutModel = Object.assign(
849
- engine.createModel({ uid: 'public-form-layout-model', use: 'FlowModel' }),
994
+ const adminLayoutModel: MockAdminLayoutModel = Object.assign(
995
+ engine.createModel({ uid: 'admin-layout-model', use: 'FlowModel' }),
850
996
  {
851
997
  registerRoutePage: vi.fn(),
852
998
  updateRoutePage: vi.fn(),
@@ -856,40 +1002,878 @@ describe('FlowRoute', () => {
856
1002
 
857
1003
  render(
858
1004
  <FlowEngineProvider engine={engine}>
859
- <FlowContextProvider context={routeModel.context}>
860
- <MemoryRouter initialEntries={['/public-forms/public-form-1']}>
861
- <Routes>
862
- <Route path="/public-forms/:name" element={<FlowRoute legacyPageBehavior="notFound" />} />
863
- </Routes>
864
- </MemoryRouter>
865
- </FlowContextProvider>
1005
+ <MemoryRouter initialEntries={['/admin/admin-denied-page']}>
1006
+ <Routes>
1007
+ <Route path="/admin/:name" element={<FlowRoute />} />
1008
+ </Routes>
1009
+ </MemoryRouter>
866
1010
  </FlowEngineProvider>,
867
1011
  );
868
1012
 
869
- await waitFor(() => {
870
- expect(layoutModel.registerRoutePage).toHaveBeenCalledWith('public-form-1', expect.any(Object));
871
- });
872
- expect(ensureAccessibleLoaded).toHaveBeenCalledTimes(1);
873
- expect(getRouteBySchemaUid).not.toHaveBeenCalled();
1013
+ expect(await screen.findByText('404')).toBeInTheDocument();
1014
+ expect(adminLayoutModel.registerRoutePage).not.toHaveBeenCalled();
874
1015
  });
875
1016
 
876
- it('should bridge legacy page when behavior is bridge', async () => {
877
- const originalLocation = window.location;
878
- const replace = vi.fn();
879
- Object.defineProperty(window, 'location', {
880
- configurable: true,
1017
+ it('should render blank content when current admin route matches an accessible empty group id', async () => {
1018
+ const engine = new FlowEngine();
1019
+ const listAccessible = vi.fn(() => [
1020
+ {
1021
+ id: 371750686228480,
1022
+ schemaUid: 'group-schema',
1023
+ title: '789',
1024
+ type: NocoBaseDesktopRouteType.group,
1025
+ },
1026
+ ]);
1027
+ engine.context.defineProperty('routeRepository', {
881
1028
  value: {
882
- ...originalLocation,
883
- pathname: '/v2/admin/test-page',
884
- replace,
1029
+ refreshAccessible: hookState.refresh,
1030
+ isAccessibleLoaded: () => true,
1031
+ ensureAccessibleLoaded: vi.fn().mockResolvedValue([]),
1032
+ getRouteBySchemaUid: vi.fn(() => undefined),
1033
+ listAccessible,
885
1034
  },
886
1035
  });
887
-
888
- try {
889
- const engine = new FlowEngine();
890
- engine.context.defineProperty('routeRepository', {
891
- value: {
892
- refreshAccessible: hookState.refresh,
1036
+ engine.context.defineProperty('app', {
1037
+ value: {
1038
+ getPublicPath: () => '/v2/',
1039
+ router: {
1040
+ getBasename: () => '/v2',
1041
+ },
1042
+ },
1043
+ });
1044
+
1045
+ const adminLayoutModel: MockAdminLayoutModel = Object.assign(
1046
+ engine.createModel({ uid: 'admin-layout-model', use: 'FlowModel' }),
1047
+ {
1048
+ registerRoutePage: vi.fn(),
1049
+ updateRoutePage: vi.fn(),
1050
+ unregisterRoutePage: vi.fn(),
1051
+ },
1052
+ );
1053
+
1054
+ const { container } = render(
1055
+ <FlowEngineProvider engine={engine}>
1056
+ <MemoryRouter initialEntries={['/admin/371750686228480']}>
1057
+ <Routes>
1058
+ <Route path="/admin/:name" element={<FlowRoute />} />
1059
+ </Routes>
1060
+ </MemoryRouter>
1061
+ </FlowEngineProvider>,
1062
+ );
1063
+
1064
+ await waitFor(() => {
1065
+ expect(listAccessible).toHaveBeenCalled();
1066
+ });
1067
+ expect(container).toBeEmptyDOMElement();
1068
+ expect(screen.queryByText('404')).not.toBeInTheDocument();
1069
+ expect(adminLayoutModel.registerRoutePage).not.toHaveBeenCalled();
1070
+ });
1071
+
1072
+ it('should navigate a current admin group route to its first accessible flow page', async () => {
1073
+ const engine = new FlowEngine();
1074
+ const childRoute = {
1075
+ schemaUid: 'child-flow-page',
1076
+ title: 'Child flow page',
1077
+ type: NocoBaseDesktopRouteType.flowPage,
1078
+ };
1079
+ const groupRoute = {
1080
+ id: 371750686228480,
1081
+ schemaUid: 'group-schema',
1082
+ title: 'Group with child',
1083
+ type: NocoBaseDesktopRouteType.group,
1084
+ children: [childRoute],
1085
+ };
1086
+ const getRouteBySchemaUid = vi.fn((schemaUid: string) =>
1087
+ schemaUid === 'child-flow-page' ? childRoute : undefined,
1088
+ );
1089
+ const listAccessible = vi.fn(() => [groupRoute]);
1090
+ engine.context.defineProperty('routeRepository', {
1091
+ value: {
1092
+ refreshAccessible: hookState.refresh,
1093
+ isAccessibleLoaded: () => true,
1094
+ ensureAccessibleLoaded: vi.fn().mockResolvedValue([]),
1095
+ getRouteBySchemaUid,
1096
+ listAccessible,
1097
+ },
1098
+ });
1099
+ engine.context.defineProperty('app', {
1100
+ value: {
1101
+ getPublicPath: () => '/v2/',
1102
+ router: {
1103
+ getBasename: () => '/v2',
1104
+ },
1105
+ },
1106
+ });
1107
+
1108
+ const adminLayoutModel: MockAdminLayoutModel = Object.assign(
1109
+ engine.createModel({ uid: 'admin-layout-model', use: 'FlowModel' }),
1110
+ {
1111
+ registerRoutePage: vi.fn(),
1112
+ updateRoutePage: vi.fn(),
1113
+ unregisterRoutePage: vi.fn(),
1114
+ },
1115
+ );
1116
+
1117
+ render(
1118
+ <FlowEngineProvider engine={engine}>
1119
+ <MemoryRouter initialEntries={['/admin/371750686228480']}>
1120
+ <Routes>
1121
+ <Route path="/admin/:name" element={<FlowRoute />} />
1122
+ </Routes>
1123
+ </MemoryRouter>
1124
+ </FlowEngineProvider>,
1125
+ );
1126
+
1127
+ await waitFor(() => {
1128
+ expect(adminLayoutModel.registerRoutePage).toHaveBeenCalledWith('child-flow-page', expect.any(Object));
1129
+ });
1130
+ expect(adminLayoutModel.registerRoutePage).not.toHaveBeenCalledWith('371750686228480', expect.any(Object));
1131
+ expect(screen.queryByText('404')).not.toBeInTheDocument();
1132
+ });
1133
+
1134
+ it('should keep rendering 404 when a group id is opened with a nested admin route path', async () => {
1135
+ const engine = new FlowEngine();
1136
+ const childRoute = {
1137
+ schemaUid: 'child-flow-page',
1138
+ title: 'Child flow page',
1139
+ type: NocoBaseDesktopRouteType.flowPage,
1140
+ };
1141
+ const groupRoute = {
1142
+ id: 371750686228480,
1143
+ schemaUid: 'group-schema',
1144
+ title: 'Group with child',
1145
+ type: NocoBaseDesktopRouteType.group,
1146
+ children: [childRoute],
1147
+ };
1148
+ engine.context.defineProperty('routeRepository', {
1149
+ value: {
1150
+ refreshAccessible: hookState.refresh,
1151
+ isAccessibleLoaded: () => true,
1152
+ ensureAccessibleLoaded: vi.fn().mockResolvedValue([]),
1153
+ getRouteBySchemaUid: vi.fn((schemaUid: string) => (schemaUid === 'child-flow-page' ? childRoute : undefined)),
1154
+ listAccessible: vi.fn(() => [groupRoute]),
1155
+ },
1156
+ });
1157
+ engine.context.defineProperty('app', {
1158
+ value: {
1159
+ getPublicPath: () => '/v2/',
1160
+ router: {
1161
+ getBasename: () => '/v2',
1162
+ },
1163
+ },
1164
+ });
1165
+
1166
+ const adminLayoutModel: MockAdminLayoutModel = Object.assign(
1167
+ engine.createModel({ uid: 'admin-layout-model', use: 'FlowModel' }),
1168
+ {
1169
+ registerRoutePage: vi.fn(),
1170
+ updateRoutePage: vi.fn(),
1171
+ unregisterRoutePage: vi.fn(),
1172
+ },
1173
+ );
1174
+
1175
+ render(
1176
+ <FlowEngineProvider engine={engine}>
1177
+ <MemoryRouter initialEntries={['/admin/371750686228480/view/not-exists']}>
1178
+ <Routes>
1179
+ <Route path="/admin/:name/view/*" element={<FlowRoute />} />
1180
+ </Routes>
1181
+ </MemoryRouter>
1182
+ </FlowEngineProvider>,
1183
+ );
1184
+
1185
+ expect(await screen.findByText('404')).toBeInTheDocument();
1186
+ expect(adminLayoutModel.registerRoutePage).not.toHaveBeenCalledWith('child-flow-page', expect.any(Object));
1187
+ expect(adminLayoutModel.registerRoutePage).not.toHaveBeenCalledWith('371750686228480', expect.any(Object));
1188
+ });
1189
+
1190
+ it('should keep rendering 404 when a group id is opened with a tab admin route path', async () => {
1191
+ const engine = new FlowEngine();
1192
+ const childRoute = {
1193
+ schemaUid: 'child-flow-page',
1194
+ title: 'Child flow page',
1195
+ type: NocoBaseDesktopRouteType.flowPage,
1196
+ };
1197
+ const groupRoute = {
1198
+ id: 371750686228480,
1199
+ schemaUid: 'group-schema',
1200
+ title: 'Group with child',
1201
+ type: NocoBaseDesktopRouteType.group,
1202
+ children: [childRoute],
1203
+ };
1204
+ engine.context.defineProperty('routeRepository', {
1205
+ value: {
1206
+ refreshAccessible: hookState.refresh,
1207
+ isAccessibleLoaded: () => true,
1208
+ ensureAccessibleLoaded: vi.fn().mockResolvedValue([]),
1209
+ getRouteBySchemaUid: vi.fn((schemaUid: string) => (schemaUid === 'child-flow-page' ? childRoute : undefined)),
1210
+ listAccessible: vi.fn(() => [groupRoute]),
1211
+ },
1212
+ });
1213
+ engine.context.defineProperty('app', {
1214
+ value: {
1215
+ getPublicPath: () => '/v2/',
1216
+ router: {
1217
+ getBasename: () => '/v2',
1218
+ },
1219
+ },
1220
+ });
1221
+
1222
+ const adminLayoutModel: MockAdminLayoutModel = Object.assign(
1223
+ engine.createModel({ uid: 'admin-layout-model', use: 'FlowModel' }),
1224
+ {
1225
+ registerRoutePage: vi.fn(),
1226
+ updateRoutePage: vi.fn(),
1227
+ unregisterRoutePage: vi.fn(),
1228
+ },
1229
+ );
1230
+
1231
+ render(
1232
+ <FlowEngineProvider engine={engine}>
1233
+ <MemoryRouter initialEntries={['/admin/371750686228480/tab/not-exists']}>
1234
+ <Routes>
1235
+ <Route path="/admin/:name/tab/:tabUid" element={<FlowRoute />} />
1236
+ </Routes>
1237
+ </MemoryRouter>
1238
+ </FlowEngineProvider>,
1239
+ );
1240
+
1241
+ expect(await screen.findByText('404')).toBeInTheDocument();
1242
+ expect(adminLayoutModel.registerRoutePage).not.toHaveBeenCalledWith('child-flow-page', expect.any(Object));
1243
+ expect(adminLayoutModel.registerRoutePage).not.toHaveBeenCalledWith('371750686228480', expect.any(Object));
1244
+ });
1245
+
1246
+ it('should keep blank content while a group child navigation is already pending for the current path', async () => {
1247
+ const engine = new FlowEngine();
1248
+ const childRoute = {
1249
+ schemaUid: 'child-flow-page',
1250
+ title: 'Child flow page',
1251
+ type: NocoBaseDesktopRouteType.flowPage,
1252
+ };
1253
+ const groupRoute = {
1254
+ id: 371750686228480,
1255
+ schemaUid: 'group-schema',
1256
+ title: 'Group with child',
1257
+ type: NocoBaseDesktopRouteType.group,
1258
+ children: [childRoute],
1259
+ };
1260
+ engine.context.defineProperty('routeRepository', {
1261
+ value: {
1262
+ refreshAccessible: hookState.refresh,
1263
+ isAccessibleLoaded: () => true,
1264
+ ensureAccessibleLoaded: vi.fn().mockResolvedValue([]),
1265
+ getRouteBySchemaUid: vi.fn((schemaUid: string) => (schemaUid === 'child-flow-page' ? childRoute : undefined)),
1266
+ listAccessible: vi.fn(() => [groupRoute]),
1267
+ },
1268
+ });
1269
+ engine.context.defineProperty('app', {
1270
+ value: {
1271
+ getPublicPath: () => '/v2/',
1272
+ router: {
1273
+ getBasename: () => '/v2',
1274
+ },
1275
+ },
1276
+ });
1277
+
1278
+ const replace = vi.fn();
1279
+ const navigator: RouterNavigator = {
1280
+ createHref: (to: { pathname?: string; search?: string; hash?: string } | string) =>
1281
+ typeof to === 'string' ? to : `${to.pathname || ''}${to.search || ''}${to.hash || ''}`,
1282
+ go: vi.fn(),
1283
+ push: vi.fn(),
1284
+ replace,
1285
+ };
1286
+
1287
+ const RerenderAfterReplace = () => {
1288
+ const [rerendered, setRerendered] = React.useState(false);
1289
+ React.useEffect(() => {
1290
+ if (!rerendered && replace.mock.calls.length > 0) {
1291
+ setRerendered(true);
1292
+ }
1293
+ }, [rerendered]);
1294
+
1295
+ return <FlowRoute legacyPageBehavior={rerendered ? 'bridge' : undefined} />;
1296
+ };
1297
+
1298
+ render(
1299
+ <FlowEngineProvider engine={engine}>
1300
+ <Router location="/admin/371750686228480" navigator={navigator}>
1301
+ <Routes>
1302
+ <Route path="/admin/:name" element={<RerenderAfterReplace />} />
1303
+ </Routes>
1304
+ </Router>
1305
+ </FlowEngineProvider>,
1306
+ );
1307
+
1308
+ await waitFor(() => {
1309
+ expect(replace).toHaveBeenCalled();
1310
+ });
1311
+ await act(async () => {
1312
+ await new Promise((resolve) => setTimeout(resolve, 0));
1313
+ });
1314
+ expect(screen.queryByText('404')).not.toBeInTheDocument();
1315
+ });
1316
+
1317
+ it('should reset group navigation guard when page uid changes in the same route instance', async () => {
1318
+ const engine = new FlowEngine();
1319
+ const childRoute1 = {
1320
+ schemaUid: 'child-flow-page-1',
1321
+ title: 'Child flow page 1',
1322
+ type: NocoBaseDesktopRouteType.flowPage,
1323
+ };
1324
+ const childRoute2 = {
1325
+ schemaUid: 'child-flow-page-2',
1326
+ title: 'Child flow page 2',
1327
+ type: NocoBaseDesktopRouteType.flowPage,
1328
+ };
1329
+ const groupRoute1 = {
1330
+ id: 371750686228480,
1331
+ schemaUid: 'group-schema-1',
1332
+ title: 'Group with child 1',
1333
+ type: NocoBaseDesktopRouteType.group,
1334
+ children: [childRoute1],
1335
+ };
1336
+ const groupRoute2 = {
1337
+ id: 371750755434496,
1338
+ schemaUid: 'group-schema-2',
1339
+ title: 'Group with child 2',
1340
+ type: NocoBaseDesktopRouteType.group,
1341
+ children: [childRoute2],
1342
+ };
1343
+ const routeMap: Record<string, typeof childRoute1 | typeof childRoute2> = {
1344
+ 'child-flow-page-1': childRoute1,
1345
+ 'child-flow-page-2': childRoute2,
1346
+ };
1347
+ const getRouteBySchemaUid = vi.fn((schemaUid: string) => routeMap[schemaUid]);
1348
+ const listAccessible = vi.fn(() => [groupRoute1, groupRoute2]);
1349
+ engine.context.defineProperty('routeRepository', {
1350
+ value: {
1351
+ refreshAccessible: hookState.refresh,
1352
+ isAccessibleLoaded: () => true,
1353
+ ensureAccessibleLoaded: vi.fn().mockResolvedValue([]),
1354
+ getRouteBySchemaUid,
1355
+ listAccessible,
1356
+ },
1357
+ });
1358
+ engine.context.defineProperty('app', {
1359
+ value: {
1360
+ getPublicPath: () => '/v2/',
1361
+ router: {
1362
+ getBasename: () => '/v2',
1363
+ },
1364
+ },
1365
+ });
1366
+
1367
+ const adminLayoutModel: MockAdminLayoutModel = Object.assign(
1368
+ engine.createModel({ uid: 'admin-layout-model', use: 'FlowModel' }),
1369
+ {
1370
+ registerRoutePage: vi.fn(),
1371
+ updateRoutePage: vi.fn(),
1372
+ unregisterRoutePage: vi.fn(),
1373
+ },
1374
+ );
1375
+ let navigateTo: (path: string) => void = () => {};
1376
+ const CaptureNavigate = () => {
1377
+ const navigate = useNavigate();
1378
+ React.useEffect(() => {
1379
+ navigateTo = navigate;
1380
+ }, [navigate]);
1381
+ return null;
1382
+ };
1383
+
1384
+ render(
1385
+ <FlowEngineProvider engine={engine}>
1386
+ <MemoryRouter initialEntries={['/admin/371750686228480']}>
1387
+ <CaptureNavigate />
1388
+ <Routes>
1389
+ <Route path="/admin/:name" element={<FlowRoute />} />
1390
+ </Routes>
1391
+ </MemoryRouter>
1392
+ </FlowEngineProvider>,
1393
+ );
1394
+
1395
+ await waitFor(() => {
1396
+ expect(adminLayoutModel.registerRoutePage).toHaveBeenCalledWith('child-flow-page-1', expect.any(Object));
1397
+ });
1398
+
1399
+ act(() => {
1400
+ navigateTo('/admin/371750755434496');
1401
+ });
1402
+
1403
+ await waitFor(() => {
1404
+ expect(adminLayoutModel.registerRoutePage).toHaveBeenCalledWith('child-flow-page-2', expect.any(Object));
1405
+ });
1406
+ expect(adminLayoutModel.registerRoutePage).not.toHaveBeenCalledWith('371750755434496', expect.any(Object));
1407
+ expect(screen.queryByText('404')).not.toBeInTheDocument();
1408
+ });
1409
+
1410
+ it('should keep rendering 404 when current admin route matches no accessible schema uid or id', async () => {
1411
+ const engine = new FlowEngine();
1412
+ engine.context.defineProperty('routeRepository', {
1413
+ value: {
1414
+ refreshAccessible: hookState.refresh,
1415
+ isAccessibleLoaded: () => true,
1416
+ ensureAccessibleLoaded: vi.fn().mockResolvedValue([]),
1417
+ getRouteBySchemaUid: vi.fn(() => undefined),
1418
+ listAccessible: vi.fn(() => [
1419
+ {
1420
+ id: 371750686228480,
1421
+ schemaUid: 'group-schema',
1422
+ title: '789',
1423
+ type: NocoBaseDesktopRouteType.group,
1424
+ },
1425
+ ]),
1426
+ },
1427
+ });
1428
+ engine.context.defineProperty('app', {
1429
+ value: {
1430
+ getPublicPath: () => '/v2/',
1431
+ router: {
1432
+ getBasename: () => '/v2',
1433
+ },
1434
+ },
1435
+ });
1436
+
1437
+ const adminLayoutModel: MockAdminLayoutModel = Object.assign(
1438
+ engine.createModel({ uid: 'admin-layout-model', use: 'FlowModel' }),
1439
+ {
1440
+ registerRoutePage: vi.fn(),
1441
+ updateRoutePage: vi.fn(),
1442
+ unregisterRoutePage: vi.fn(),
1443
+ },
1444
+ );
1445
+
1446
+ render(
1447
+ <FlowEngineProvider engine={engine}>
1448
+ <MemoryRouter initialEntries={['/admin/not-exists-4822']}>
1449
+ <Routes>
1450
+ <Route path="/admin/:name" element={<FlowRoute />} />
1451
+ </Routes>
1452
+ </MemoryRouter>
1453
+ </FlowEngineProvider>,
1454
+ );
1455
+
1456
+ expect(await screen.findByText('404')).toBeInTheDocument();
1457
+ expect(adminLayoutModel.registerRoutePage).not.toHaveBeenCalled();
1458
+ });
1459
+
1460
+ it('should keep rendering 404 when current admin route matches only a non-group route id', async () => {
1461
+ const engine = new FlowEngine();
1462
+ engine.context.defineProperty('routeRepository', {
1463
+ value: {
1464
+ refreshAccessible: hookState.refresh,
1465
+ isAccessibleLoaded: () => true,
1466
+ ensureAccessibleLoaded: vi.fn().mockResolvedValue([]),
1467
+ getRouteBySchemaUid: vi.fn(() => undefined),
1468
+ listAccessible: vi.fn(() => [
1469
+ {
1470
+ id: 10001,
1471
+ schemaUid: 'flow-page-schema',
1472
+ title: 'Flow page',
1473
+ type: NocoBaseDesktopRouteType.flowPage,
1474
+ },
1475
+ ]),
1476
+ },
1477
+ });
1478
+ engine.context.defineProperty('app', {
1479
+ value: {
1480
+ getPublicPath: () => '/v2/',
1481
+ router: {
1482
+ getBasename: () => '/v2',
1483
+ },
1484
+ },
1485
+ });
1486
+
1487
+ const adminLayoutModel: MockAdminLayoutModel = Object.assign(
1488
+ engine.createModel({ uid: 'admin-layout-model', use: 'FlowModel' }),
1489
+ {
1490
+ registerRoutePage: vi.fn(),
1491
+ updateRoutePage: vi.fn(),
1492
+ unregisterRoutePage: vi.fn(),
1493
+ },
1494
+ );
1495
+
1496
+ render(
1497
+ <FlowEngineProvider engine={engine}>
1498
+ <MemoryRouter initialEntries={['/admin/10001']}>
1499
+ <Routes>
1500
+ <Route path="/admin/:name" element={<FlowRoute />} />
1501
+ </Routes>
1502
+ </MemoryRouter>
1503
+ </FlowEngineProvider>,
1504
+ );
1505
+
1506
+ expect(await screen.findByText('404')).toBeInTheDocument();
1507
+ expect(adminLayoutModel.registerRoutePage).not.toHaveBeenCalled();
1508
+ });
1509
+
1510
+ it('should keep rendering 404 when current admin route matches only a group schema uid', async () => {
1511
+ const engine = new FlowEngine();
1512
+ const groupRoute = {
1513
+ id: 371750686228480,
1514
+ schemaUid: 'group-schema',
1515
+ title: '789',
1516
+ type: NocoBaseDesktopRouteType.group,
1517
+ };
1518
+ engine.context.defineProperty('routeRepository', {
1519
+ value: {
1520
+ refreshAccessible: hookState.refresh,
1521
+ isAccessibleLoaded: () => true,
1522
+ ensureAccessibleLoaded: vi.fn().mockResolvedValue([]),
1523
+ getRouteBySchemaUid: vi.fn((schemaUid: string) => (schemaUid === 'group-schema' ? groupRoute : undefined)),
1524
+ listAccessible: vi.fn(() => [groupRoute]),
1525
+ },
1526
+ });
1527
+ engine.context.defineProperty('app', {
1528
+ value: {
1529
+ getPublicPath: () => '/v2/',
1530
+ router: {
1531
+ getBasename: () => '/v2',
1532
+ },
1533
+ },
1534
+ });
1535
+
1536
+ const adminLayoutModel: MockAdminLayoutModel = Object.assign(
1537
+ engine.createModel({ uid: 'admin-layout-model', use: 'FlowModel' }),
1538
+ {
1539
+ registerRoutePage: vi.fn(),
1540
+ updateRoutePage: vi.fn(),
1541
+ unregisterRoutePage: vi.fn(),
1542
+ },
1543
+ );
1544
+
1545
+ render(
1546
+ <FlowEngineProvider engine={engine}>
1547
+ <MemoryRouter initialEntries={['/admin/group-schema']}>
1548
+ <Routes>
1549
+ <Route path="/admin/:name" element={<FlowRoute />} />
1550
+ </Routes>
1551
+ </MemoryRouter>
1552
+ </FlowEngineProvider>,
1553
+ );
1554
+
1555
+ expect(await screen.findByText('404')).toBeInTheDocument();
1556
+ expect(adminLayoutModel.registerRoutePage).not.toHaveBeenCalled();
1557
+ });
1558
+
1559
+ it('should not use flowModels:findOne to bridge a missing route for protected layouts', async () => {
1560
+ const engine = new FlowEngine();
1561
+ const findOne = vi.fn().mockResolvedValue({
1562
+ uid: 'mobile-denied-page',
1563
+ use: 'FlowModel',
1564
+ });
1565
+ engine.setModelRepository({
1566
+ findOne,
1567
+ save: vi.fn(),
1568
+ destroy: vi.fn(),
1569
+ } as any);
1570
+ engine.context.defineProperty('routeRepository', {
1571
+ value: {
1572
+ refreshAccessible: hookState.refresh,
1573
+ isAccessibleLoaded: () => true,
1574
+ ensureAccessibleLoaded: vi.fn().mockResolvedValue([]),
1575
+ getRouteBySchemaUid: vi.fn(() => undefined),
1576
+ },
1577
+ });
1578
+ engine.context.defineProperty('app', {
1579
+ value: {
1580
+ getPublicPath: () => '/v2/',
1581
+ router: {
1582
+ getBasename: () => '/v2',
1583
+ },
1584
+ },
1585
+ });
1586
+ const routeModel = engine.createModel({
1587
+ uid: 'mobile-route-model',
1588
+ use: 'FlowModel',
1589
+ });
1590
+ routeModel.context.defineProperty('layout', {
1591
+ value: {
1592
+ routeName: 'mobile',
1593
+ routePath: '/mobile',
1594
+ rootRouteName: 'mobile',
1595
+ uid: 'mobile-layout-model',
1596
+ layoutModelClass: 'MobileLayoutModel',
1597
+ rootPageModelClass: 'MobileRootPageModel',
1598
+ childPageModelClass: 'MobileChildPageModel',
1599
+ authCheck: true,
1600
+ },
1601
+ });
1602
+
1603
+ const layoutModel: MockAdminLayoutModel = Object.assign(
1604
+ engine.createModel({ uid: 'mobile-layout-model', use: 'FlowModel' }),
1605
+ {
1606
+ registerRoutePage: vi.fn(),
1607
+ updateRoutePage: vi.fn(),
1608
+ unregisterRoutePage: vi.fn(),
1609
+ },
1610
+ );
1611
+
1612
+ render(
1613
+ <FlowEngineProvider engine={engine}>
1614
+ <FlowContextProvider context={routeModel.context}>
1615
+ <MemoryRouter initialEntries={['/mobile/mobile-denied-page']}>
1616
+ <Routes>
1617
+ <Route
1618
+ path="/mobile/:name"
1619
+ element={<FlowRoute legacyPageBehavior="notFound" getLayoutModel={() => layoutModel} />}
1620
+ />
1621
+ </Routes>
1622
+ </MemoryRouter>
1623
+ </FlowContextProvider>
1624
+ </FlowEngineProvider>,
1625
+ );
1626
+
1627
+ expect(await screen.findByText('404')).toBeInTheDocument();
1628
+ expect(findOne).not.toHaveBeenCalled();
1629
+ expect(layoutModel.registerRoutePage).not.toHaveBeenCalled();
1630
+ });
1631
+
1632
+ it('should not load a known page uid when a protected custom layout has no accessible route', async () => {
1633
+ const engine = new FlowEngine();
1634
+ const findOne = vi.fn().mockResolvedValue({
1635
+ uid: 'known-denied-custom-page',
1636
+ use: 'FlowModel',
1637
+ });
1638
+ engine.setModelRepository({
1639
+ findOne,
1640
+ save: vi.fn(),
1641
+ destroy: vi.fn(),
1642
+ } as any);
1643
+ engine.context.defineProperty('routeRepository', {
1644
+ value: {
1645
+ refreshAccessible: hookState.refresh,
1646
+ isAccessibleLoaded: () => true,
1647
+ ensureAccessibleLoaded: vi.fn().mockResolvedValue([]),
1648
+ getRouteBySchemaUid: vi.fn(() => undefined),
1649
+ },
1650
+ });
1651
+ engine.context.defineProperty('app', {
1652
+ value: {
1653
+ getPublicPath: () => '/v2/',
1654
+ router: {
1655
+ getBasename: () => '/v2',
1656
+ },
1657
+ },
1658
+ });
1659
+ const routeModel = engine.createModel({
1660
+ uid: 'custom-layout-route-model',
1661
+ use: 'FlowModel',
1662
+ });
1663
+ routeModel.context.defineProperty('layout', {
1664
+ value: {
1665
+ routeName: 'admin2',
1666
+ routePath: '/admin2',
1667
+ rootRouteName: 'admin2',
1668
+ uid: 'custom-protected-layout-model',
1669
+ layoutModelClass: 'AdminLayoutModel',
1670
+ rootPageModelClass: 'RootPageModel',
1671
+ childPageModelClass: 'ChildPageModel',
1672
+ authCheck: true,
1673
+ },
1674
+ });
1675
+
1676
+ const layoutModel: MockAdminLayoutModel = Object.assign(
1677
+ engine.createModel({ uid: 'custom-protected-layout-model', use: 'FlowModel' }),
1678
+ {
1679
+ registerRoutePage: vi.fn(),
1680
+ updateRoutePage: vi.fn(),
1681
+ unregisterRoutePage: vi.fn(),
1682
+ },
1683
+ );
1684
+
1685
+ render(
1686
+ <FlowEngineProvider engine={engine}>
1687
+ <FlowContextProvider context={routeModel.context}>
1688
+ <MemoryRouter initialEntries={['/admin2/known-denied-custom-page']}>
1689
+ <Routes>
1690
+ <Route
1691
+ path="/admin2/:name"
1692
+ element={<FlowRoute legacyPageBehavior="notFound" getLayoutModel={() => layoutModel} />}
1693
+ />
1694
+ </Routes>
1695
+ </MemoryRouter>
1696
+ </FlowContextProvider>
1697
+ </FlowEngineProvider>,
1698
+ );
1699
+
1700
+ expect(await screen.findByText('404')).toBeInTheDocument();
1701
+ expect(findOne).not.toHaveBeenCalled();
1702
+ expect(layoutModel.registerRoutePage).not.toHaveBeenCalled();
1703
+ });
1704
+
1705
+ it('should check model existence without occupying the route model uid', async () => {
1706
+ const engine = new FlowEngine();
1707
+ const findOne = vi.fn().mockResolvedValue({
1708
+ uid: 'public-form-1',
1709
+ });
1710
+ const request = vi.fn().mockResolvedValue({
1711
+ data: {
1712
+ data: {
1713
+ uid: 'public-form-1',
1714
+ },
1715
+ },
1716
+ });
1717
+ engine.setModelRepository({
1718
+ findOne,
1719
+ save: vi.fn(),
1720
+ destroy: vi.fn(),
1721
+ } as any);
1722
+ engine.context.defineProperty('routeRepository', {
1723
+ value: {
1724
+ refreshAccessible: hookState.refresh,
1725
+ isAccessibleLoaded: () => true,
1726
+ ensureAccessibleLoaded: vi.fn().mockResolvedValue([]),
1727
+ getRouteBySchemaUid: vi.fn(() => undefined),
1728
+ },
1729
+ });
1730
+ engine.context.defineProperty('app', {
1731
+ value: {
1732
+ apiClient: {
1733
+ request,
1734
+ },
1735
+ getPublicPath: () => '/v2/',
1736
+ router: {
1737
+ getBasename: () => '/v2',
1738
+ },
1739
+ },
1740
+ });
1741
+ const routeModel = engine.createModel({
1742
+ uid: 'public-form-route-model',
1743
+ use: 'FlowModel',
1744
+ });
1745
+ routeModel.context.defineProperty('layout', {
1746
+ value: {
1747
+ routeName: 'public-forms',
1748
+ routePath: '/public-forms',
1749
+ rootRouteName: 'public-forms',
1750
+ uid: 'public-form-layout-model',
1751
+ layoutModelClass: 'PublicFormLayoutModel',
1752
+ rootPageModelClass: 'PublicFormPageModel',
1753
+ childPageModelClass: 'ChildPageModel',
1754
+ authCheck: false,
1755
+ },
1756
+ });
1757
+
1758
+ const layoutModel: MockAdminLayoutModel = Object.assign(
1759
+ engine.createModel({ uid: 'public-form-layout-model', use: 'FlowModel' }),
1760
+ {
1761
+ registerRoutePage: vi.fn(),
1762
+ updateRoutePage: vi.fn(),
1763
+ unregisterRoutePage: vi.fn(),
1764
+ },
1765
+ );
1766
+
1767
+ render(
1768
+ <FlowEngineProvider engine={engine}>
1769
+ <FlowContextProvider context={routeModel.context}>
1770
+ <MemoryRouter initialEntries={['/public-forms/public-form-1']}>
1771
+ <Routes>
1772
+ <Route path="/public-forms/:name" element={<FlowRoute legacyPageBehavior="notFound" />} />
1773
+ </Routes>
1774
+ </MemoryRouter>
1775
+ </FlowContextProvider>
1776
+ </FlowEngineProvider>,
1777
+ );
1778
+
1779
+ await waitFor(() => {
1780
+ expect(layoutModel.registerRoutePage).toHaveBeenCalledWith('public-form-1', expect.any(Object));
1781
+ });
1782
+ expect(findOne).toHaveBeenCalledWith({ uid: 'public-form-1' });
1783
+ expect(request).not.toHaveBeenCalled();
1784
+ expect(engine.getModel('public-form-1')).toBeUndefined();
1785
+ });
1786
+
1787
+ it('should skip accessible route loading when layout authCheck is false', async () => {
1788
+ const engine = new FlowEngine();
1789
+ const ensureAccessibleLoaded = vi.fn().mockRejectedValue(new Error('cannot load accessible routes'));
1790
+ const getRouteBySchemaUid = vi.fn();
1791
+ engine.setModelRepository({
1792
+ findOne: vi.fn().mockResolvedValue({
1793
+ uid: 'public-form-1',
1794
+ use: 'FlowModel',
1795
+ }),
1796
+ save: vi.fn(),
1797
+ destroy: vi.fn(),
1798
+ } as any);
1799
+ engine.context.defineProperty('routeRepository', {
1800
+ value: {
1801
+ refreshAccessible: hookState.refresh,
1802
+ isAccessibleLoaded: () => false,
1803
+ ensureAccessibleLoaded,
1804
+ getRouteBySchemaUid,
1805
+ },
1806
+ });
1807
+ engine.context.defineProperty('app', {
1808
+ value: {
1809
+ getPublicPath: () => '/v2/',
1810
+ router: {
1811
+ getBasename: () => '/v2',
1812
+ },
1813
+ },
1814
+ });
1815
+ const routeModel = engine.createModel({
1816
+ uid: 'public-form-route-model',
1817
+ use: 'FlowModel',
1818
+ });
1819
+ routeModel.context.defineProperty('layout', {
1820
+ value: {
1821
+ routeName: 'public-forms',
1822
+ routePath: '/public-forms',
1823
+ rootRouteName: 'public-forms',
1824
+ uid: 'public-form-layout-model',
1825
+ layoutModelClass: 'PublicFormLayoutModel',
1826
+ rootPageModelClass: 'PublicFormPageModel',
1827
+ childPageModelClass: 'ChildPageModel',
1828
+ authCheck: false,
1829
+ },
1830
+ });
1831
+
1832
+ const layoutModel: MockAdminLayoutModel = Object.assign(
1833
+ engine.createModel({ uid: 'public-form-layout-model', use: 'FlowModel' }),
1834
+ {
1835
+ registerRoutePage: vi.fn(),
1836
+ updateRoutePage: vi.fn(),
1837
+ unregisterRoutePage: vi.fn(),
1838
+ },
1839
+ );
1840
+
1841
+ render(
1842
+ <FlowEngineProvider engine={engine}>
1843
+ <FlowContextProvider context={routeModel.context}>
1844
+ <MemoryRouter initialEntries={['/public-forms/public-form-1']}>
1845
+ <Routes>
1846
+ <Route path="/public-forms/:name" element={<FlowRoute legacyPageBehavior="notFound" />} />
1847
+ </Routes>
1848
+ </MemoryRouter>
1849
+ </FlowContextProvider>
1850
+ </FlowEngineProvider>,
1851
+ );
1852
+
1853
+ await waitFor(() => {
1854
+ expect(layoutModel.registerRoutePage).toHaveBeenCalledWith('public-form-1', expect.any(Object));
1855
+ });
1856
+ expect(ensureAccessibleLoaded).not.toHaveBeenCalled();
1857
+ expect(getRouteBySchemaUid).not.toHaveBeenCalled();
1858
+ });
1859
+
1860
+ it('should bridge legacy page when behavior is bridge', async () => {
1861
+ const originalLocation = window.location;
1862
+ const replace = vi.fn();
1863
+ Object.defineProperty(window, 'location', {
1864
+ configurable: true,
1865
+ value: {
1866
+ ...originalLocation,
1867
+ pathname: '/v2/admin/test-page',
1868
+ replace,
1869
+ },
1870
+ });
1871
+
1872
+ try {
1873
+ const engine = new FlowEngine();
1874
+ engine.context.defineProperty('routeRepository', {
1875
+ value: {
1876
+ refreshAccessible: hookState.refresh,
893
1877
  isAccessibleLoaded: () => true,
894
1878
  ensureAccessibleLoaded: vi.fn().mockResolvedValue([]),
895
1879
  getRouteBySchemaUid: vi.fn(() => ({ type: 'page', schemaUid: 'test-page' })),
@@ -995,51 +1979,6 @@ describe('FlowRoute', () => {
995
1979
  }
996
1980
  });
997
1981
 
998
- it('should bridge existing FlowModel when route metadata does not exist', async () => {
999
- const engine = new FlowEngine();
1000
- engine.createModel({ uid: 'test-page', use: 'FlowModel' });
1001
- engine.context.defineProperty('routeRepository', {
1002
- value: {
1003
- refreshAccessible: hookState.refresh,
1004
- isAccessibleLoaded: () => true,
1005
- ensureAccessibleLoaded: vi.fn().mockResolvedValue([]),
1006
- getRouteBySchemaUid: vi.fn(() => undefined),
1007
- },
1008
- });
1009
- engine.context.defineProperty('app', {
1010
- value: {
1011
- getPublicPath: () => '/v2/',
1012
- router: {
1013
- getBasename: () => '/v2',
1014
- },
1015
- },
1016
- });
1017
-
1018
- const adminLayoutModel: MockAdminLayoutModel = Object.assign(
1019
- engine.createModel({ uid: 'admin-layout-model', use: 'FlowModel' }),
1020
- {
1021
- registerRoutePage: vi.fn(),
1022
- updateRoutePage: vi.fn(),
1023
- unregisterRoutePage: vi.fn(),
1024
- },
1025
- );
1026
-
1027
- render(
1028
- <FlowEngineProvider engine={engine}>
1029
- <MemoryRouter initialEntries={['/flow/test-page']}>
1030
- <Routes>
1031
- <Route path="/flow/:name" element={<FlowRoute />} />
1032
- </Routes>
1033
- </MemoryRouter>
1034
- </FlowEngineProvider>,
1035
- );
1036
-
1037
- await waitFor(() => {
1038
- expect(adminLayoutModel.registerRoutePage).toHaveBeenCalledWith('test-page', expect.any(Object));
1039
- });
1040
- expect(screen.queryByText('404')).not.toBeInTheDocument();
1041
- });
1042
-
1043
1982
  it('should keep sub app page inside spa when basename does not include /v2/', async () => {
1044
1983
  const originalLocation = window.location;
1045
1984
  const replace = vi.fn();