@nocobase/client-v2 2.2.0-beta.5 → 2.2.0-beta.6

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 (109) hide show
  1. package/es/RouteRepository.d.ts +21 -6
  2. package/es/components/form/JsonTextArea.d.ts +2 -1
  3. package/es/components/form/VariableJsonTextArea.d.ts +19 -0
  4. package/es/components/form/index.d.ts +1 -0
  5. package/es/components/index.d.ts +1 -0
  6. package/es/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.d.ts +2 -0
  7. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.d.ts +2 -1
  8. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +2 -0
  9. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.d.ts +3 -2
  10. package/es/flow/admin-shell/admin-layout/AdminLayoutSlotModels.d.ts +4 -0
  11. package/es/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.d.ts +6 -0
  12. package/es/flow/components/FieldAssignRulesEditor.d.ts +3 -1
  13. package/es/flow/models/base/GridModel.d.ts +1 -1
  14. package/es/flow/models/blocks/filter-form/FilterFormBlockModel.d.ts +5 -0
  15. package/es/flow/models/blocks/form/FormBlockModel.d.ts +4 -0
  16. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +21 -0
  17. package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +21 -0
  18. package/es/flow/models/blocks/form/value-runtime/types.d.ts +2 -2
  19. package/es/flow-compat/FieldValidation.d.ts +2 -1
  20. package/es/index.d.ts +2 -1
  21. package/es/index.mjs +143 -117
  22. package/lib/index.js +148 -122
  23. package/package.json +7 -7
  24. package/src/RouteRepository.ts +126 -24
  25. package/src/__tests__/RouteRepository.test.ts +216 -0
  26. package/src/__tests__/exports.test.ts +16 -0
  27. package/src/__tests__/settings-center.test.tsx +33 -0
  28. package/src/components/form/JsonTextArea.tsx +21 -11
  29. package/src/components/form/VariableJsonTextArea.tsx +175 -0
  30. package/src/components/form/__tests__/JsonTextArea.test.tsx +43 -0
  31. package/src/components/form/__tests__/VariableJsonTextArea.test.tsx +86 -0
  32. package/src/components/form/index.tsx +1 -0
  33. package/src/components/index.ts +1 -0
  34. package/src/flow/__tests__/FlowRoute.test.tsx +443 -5
  35. package/src/flow/actions/__tests__/fieldLinkageRules.scopeDepth.test.ts +150 -1
  36. package/src/flow/actions/__tests__/linkageAssignField.legacy.test.ts +135 -0
  37. package/src/flow/actions/__tests__/linkageRules.hiddenSync.restore.test.ts +19 -5
  38. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +92 -0
  39. package/src/flow/actions/customVariable.tsx +1 -2
  40. package/src/flow/actions/linkageRules.tsx +122 -34
  41. package/src/flow/actions/runjs.tsx +2 -14
  42. package/src/flow/actions/validation.tsx +62 -30
  43. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +33 -10
  44. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.test.tsx +310 -0
  45. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +44 -8
  46. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.ts +5 -3
  47. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +61 -9
  48. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +71 -8
  49. package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +1 -1
  50. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +21 -8
  51. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +188 -0
  52. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +99 -0
  53. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +166 -2
  54. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +20 -0
  55. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +33 -5
  56. package/src/flow/components/DefaultValue.tsx +1 -2
  57. package/src/flow/components/FieldAssignRulesEditor.tsx +62 -12
  58. package/src/flow/components/FlowRoute.tsx +56 -11
  59. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +81 -4
  60. package/src/flow/components/code-editor/__tests__/useCodeRunner.test.tsx +2 -17
  61. package/src/flow/components/code-editor/hooks/useCodeRunner.ts +2 -14
  62. package/src/flow/components/code-editor/runjsDiagnostics.ts +8 -45
  63. package/src/flow/models/actions/JSActionModel.tsx +2 -7
  64. package/src/flow/models/actions/JSCollectionActionModel.tsx +2 -7
  65. package/src/flow/models/actions/JSItemActionModel.tsx +2 -7
  66. package/src/flow/models/actions/JSRecordActionModel.tsx +2 -7
  67. package/src/flow/models/base/ActionModel.tsx +12 -1
  68. package/src/flow/models/base/GridModel.tsx +38 -7
  69. package/src/flow/models/base/PageModel/PageModel.tsx +4 -1
  70. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +28 -4
  71. package/src/flow/models/base/__tests__/ActionModel.test.ts +83 -0
  72. package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +239 -1
  73. package/src/flow/models/base/__tests__/transformRowsToSingleColumn.test.ts +48 -0
  74. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +31 -3
  75. package/src/flow/models/blocks/filter-form/FilterFormJSActionModel.tsx +2 -7
  76. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +77 -0
  77. package/src/flow/models/blocks/form/EditFormModel.tsx +1 -0
  78. package/src/flow/models/blocks/form/FormBlockModel.tsx +7 -0
  79. package/src/flow/models/blocks/form/JSFormActionModel.tsx +2 -7
  80. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +17 -0
  81. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +880 -102
  82. package/src/flow/models/blocks/form/value-runtime/rules.ts +445 -13
  83. package/src/flow/models/blocks/form/value-runtime/runtime.ts +257 -13
  84. package/src/flow/models/blocks/form/value-runtime/types.ts +2 -2
  85. package/src/flow/models/blocks/js-block/JSBlock.tsx +2 -7
  86. package/src/flow/models/blocks/table/JSColumnModel.tsx +1 -9
  87. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +1 -1
  88. package/src/flow/models/blocks/table/TableBlockModel.tsx +13 -3
  89. package/src/flow/models/blocks/table/__tests__/TableActionsColumnModel.test.tsx +54 -0
  90. package/src/flow/models/blocks/table/__tests__/TableBlockModel.mobileSettingsButtons.test.tsx +78 -0
  91. package/src/flow/models/fields/AssociationFieldModel/AssociationFieldModel.tsx +1 -1
  92. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +29 -6
  93. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +13 -1
  94. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableFieldModel.reset.test.ts +180 -0
  95. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +6 -4
  96. package/src/flow/models/fields/AssociationFieldModel/__tests__/AssociationFieldModel.updateAssociation.test.ts +72 -0
  97. package/src/flow/models/fields/InputFieldModel.tsx +14 -22
  98. package/src/flow/models/fields/JSEditableFieldModel.tsx +2 -11
  99. package/src/flow/models/fields/JSFieldModel.tsx +2 -7
  100. package/src/flow/models/fields/JSItemModel.tsx +2 -14
  101. package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +17 -0
  102. package/src/flow/models/topbar/TopbarActionModel.tsx +93 -10
  103. package/src/flow-compat/FieldValidation.tsx +122 -60
  104. package/src/flow-compat/Popover.tsx +43 -4
  105. package/src/flow-compat/__tests__/Popover.test.tsx +34 -0
  106. package/src/index.ts +8 -1
  107. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +41 -1
  108. package/src/settings-center/AdminSettingsLayout.tsx +3 -0
  109. package/src/settings-center/plugin-manager/index.tsx +3 -0
@@ -42,9 +42,11 @@ import {
42
42
  } from './AdminLayoutMenuFlowUtils';
43
43
  import { ADMIN_LAYOUT_MODEL_UID } from './constants';
44
44
  import {
45
+ type AdminLayoutRoutePathLike,
45
46
  findFirstAccessiblePageRoute,
46
47
  findFirstV2LandingRoute,
47
48
  isV2AdminRuntime,
49
+ joinAdminLayoutRoutePath,
48
50
  resolveAdminRouteRuntimeTarget,
49
51
  toRouterNavigationPath,
50
52
  } from './resolveAdminRouteRuntimeTarget';
@@ -64,6 +66,26 @@ const omitMenuRuntimeProps = (props: Record<string, unknown> = {}) => {
64
66
  return persistableProps;
65
67
  };
66
68
 
69
+ type MenuRouteTreeRefreshableModel = FlowModel & {
70
+ refreshMenuRouteTree?: () => void;
71
+ };
72
+
73
+ function isMenuRouteTreeRefreshableModel(model: FlowModel | undefined): model is MenuRouteTreeRefreshableModel {
74
+ return typeof (model as MenuRouteTreeRefreshableModel | undefined)?.refreshMenuRouteTree === 'function';
75
+ }
76
+
77
+ function getOwnerMenuRouteTreeRefreshModel(model: FlowModel) {
78
+ let current = model.parent as FlowModel | undefined;
79
+ while (current) {
80
+ if (isMenuRouteTreeRefreshableModel(current)) {
81
+ return current;
82
+ }
83
+ current = current.parent as FlowModel | undefined;
84
+ }
85
+
86
+ return undefined;
87
+ }
88
+
67
89
  export class AdminLayoutMenuItemModel extends FlowModel<AdminLayoutMenuItemStructure> {
68
90
  private creationPersisted = false;
69
91
  private persistedStateHydrated = false;
@@ -129,6 +151,24 @@ export class AdminLayoutMenuItemModel extends FlowModel<AdminLayoutMenuItemStruc
129
151
  return this.context.routeRepository;
130
152
  }
131
153
 
154
+ getLayout(): AdminLayoutRoutePathLike | undefined {
155
+ const contextLayout = this.context.layout as AdminLayoutRoutePathLike | undefined;
156
+ if (contextLayout?.routePath) {
157
+ return contextLayout;
158
+ }
159
+
160
+ let current: FlowModel | undefined = this;
161
+ while (current) {
162
+ const propsLayout = current.props?.layout as AdminLayoutRoutePathLike | undefined;
163
+ if (propsLayout?.routePath) {
164
+ return propsLayout;
165
+ }
166
+ current = current.parent as FlowModel | undefined;
167
+ }
168
+
169
+ return undefined;
170
+ }
171
+
132
172
  hasPersistedMenuInstanceFlowFlag(route = this.getRoute()) {
133
173
  return route?.options?.hasPersistedMenuInstanceFlow === true;
134
174
  }
@@ -280,7 +320,12 @@ export class AdminLayoutMenuItemModel extends FlowModel<AdminLayoutMenuItemStruc
280
320
  const previous = this.hidden;
281
321
  super.setHidden(value);
282
322
  if (previous !== this.hidden) {
283
- (this.flowEngine.getModel?.(ADMIN_LAYOUT_MODEL_UID) as any)?.refreshMenuRouteTree?.();
323
+ const defaultLayoutModel = this.flowEngine.getModel?.(ADMIN_LAYOUT_MODEL_UID);
324
+ const refreshableModel =
325
+ getOwnerMenuRouteTreeRefreshModel(this) ||
326
+ (isMenuRouteTreeRefreshableModel(defaultLayoutModel) ? defaultLayoutModel : undefined);
327
+
328
+ refreshableModel?.refreshMenuRouteTree?.();
284
329
  }
285
330
  }
286
331
 
@@ -533,9 +578,10 @@ export class AdminLayoutMenuItemModel extends FlowModel<AdminLayoutMenuItemStruc
533
578
  return false;
534
579
  }
535
580
 
581
+ const layout = this.getLayout();
536
582
  const allAccessRoutes = this.getRouteRepository().listAccessible();
537
583
  const pathname = this.context.location?.pathname || window.location.pathname;
538
- const shouldNavigate = matchesRoutePath(route, pathname, this.context.router?.basename);
584
+ const shouldNavigate = matchesRoutePath(route, pathname, this.context.router?.basename, layout);
539
585
  const prevSibling = findPrevSiblingRoute(allAccessRoutes, route);
540
586
  const nextSibling = findNextSiblingRoute(allAccessRoutes, route);
541
587
 
@@ -554,6 +600,7 @@ export class AdminLayoutMenuItemModel extends FlowModel<AdminLayoutMenuItemStruc
554
600
  const target = resolveAdminRouteRuntimeTarget({
555
601
  app: this.context.app,
556
602
  route: sibling,
603
+ layout,
557
604
  });
558
605
 
559
606
  if (!target.runtimePath) {
@@ -584,6 +631,7 @@ export class AdminLayoutMenuItemModel extends FlowModel<AdminLayoutMenuItemStruc
584
631
 
585
632
  const shouldShowIconInTitle = shouldRenderIconInTitle({ depth, isMobile: options.isMobile });
586
633
  const { name, icon } = buildMenuTitleWithIcon(route, options.t, shouldShowIconInTitle);
634
+ const layout = this.getLayout();
587
635
 
588
636
  if (route.type === NocoBaseDesktopRouteType.link) {
589
637
  const identity = route.id ?? route.schemaUid ?? route.menuSchemaUid ?? route.title ?? this.uid;
@@ -591,7 +639,7 @@ export class AdminLayoutMenuItemModel extends FlowModel<AdminLayoutMenuItemStruc
591
639
  return {
592
640
  name,
593
641
  icon,
594
- path: getAdminLayoutMenuVirtualPath('link', identity),
642
+ path: getAdminLayoutMenuVirtualPath('link', identity, layout),
595
643
  hideInMenu: route.hideInMenu,
596
644
  _route: route,
597
645
  _parentRoute: parentRoute,
@@ -604,6 +652,7 @@ export class AdminLayoutMenuItemModel extends FlowModel<AdminLayoutMenuItemStruc
604
652
  const runtimeTarget = resolveAdminRouteRuntimeTarget({
605
653
  app: this.context.app,
606
654
  route,
655
+ layout,
607
656
  });
608
657
 
609
658
  if (runtimeTarget.reason === 'unsupportedV2Runtime') {
@@ -611,14 +660,14 @@ export class AdminLayoutMenuItemModel extends FlowModel<AdminLayoutMenuItemStruc
611
660
  }
612
661
 
613
662
  const path = route.schemaUid
614
- ? `/admin/${route.schemaUid}`
615
- : getAdminLayoutMenuVirtualPath('link', `${this.uid}-invalid`);
663
+ ? joinAdminLayoutRoutePath(layout, route.schemaUid)
664
+ : getAdminLayoutMenuVirtualPath('link', `${this.uid}-invalid`, layout);
616
665
 
617
666
  return {
618
667
  name,
619
668
  icon,
620
669
  path,
621
- redirect: route.schemaUid ? `/admin/${route.schemaUid}` : undefined,
670
+ redirect: route.schemaUid ? joinAdminLayoutRoutePath(layout, route.schemaUid) : undefined,
622
671
  hideInMenu: route.hideInMenu,
623
672
  disabled: !runtimeTarget.runtimePath,
624
673
  _runtimePath: runtimeTarget.runtimePath,
@@ -644,7 +693,7 @@ export class AdminLayoutMenuItemModel extends FlowModel<AdminLayoutMenuItemStruc
644
693
  .filter(Boolean) || [];
645
694
 
646
695
  if (options.designable && depth === 0) {
647
- children.push(getAdminLayoutMenuInitializerButton('schema-initializer-Menu-side', this, route));
696
+ children.push(getAdminLayoutMenuInitializerButton('schema-initializer-Menu-side', this, route, layout));
648
697
  }
649
698
 
650
699
  const landingRoute = isV2AdminRuntime(this.context.app)
@@ -653,14 +702,17 @@ export class AdminLayoutMenuItemModel extends FlowModel<AdminLayoutMenuItemStruc
653
702
  const runtimeTarget = resolveAdminRouteRuntimeTarget({
654
703
  app: this.context.app,
655
704
  route: isV2AdminRuntime(this.context.app) && landingRoute ? landingRoute : route,
705
+ layout,
656
706
  });
657
707
 
658
708
  const groupRoute: AdminLayoutMenuNode = {
659
709
  name,
660
710
  icon,
661
- path: `/admin/${route.id}`,
711
+ path: joinAdminLayoutRoutePath(layout, route.id),
662
712
  redirect:
663
- children[0]?.key === 'x-designer-button' ? undefined : `/admin/${landingRoute?.schemaUid || route.id}`,
713
+ children[0]?.key === 'x-designer-button'
714
+ ? undefined
715
+ : joinAdminLayoutRoutePath(layout, landingRoute?.schemaUid || route.id),
664
716
  hideInMenu: route.hideInMenu,
665
717
  _runtimePath: runtimeTarget.runtimePath,
666
718
  _navigationMode: runtimeTarget.navigationMode,
@@ -38,7 +38,11 @@ import {
38
38
  VariableScope,
39
39
  withTooltipComponent,
40
40
  } from './AdminLayoutCompat';
41
- import { toRouterNavigationPath } from './resolveAdminRouteRuntimeTarget';
41
+ import {
42
+ type AdminLayoutRoutePathLike,
43
+ joinAdminLayoutRoutePath,
44
+ toRouterNavigationPath,
45
+ } from './resolveAdminRouteRuntimeTarget';
42
46
 
43
47
  export type AdminLayoutMenuRenderType = 'item' | 'group';
44
48
 
@@ -122,8 +126,30 @@ type AdminLayoutMenuItemsParent = FlowModel & {
122
126
  * @param identity 节点身份
123
127
  * @returns 唯一占位路径
124
128
  */
125
- export const getAdminLayoutMenuVirtualPath = (type: 'link' | 'designer', identity: string | number) => {
126
- return `/admin/__admin_layout__/${type}/${encodeURIComponent(String(identity))}`;
129
+ export const getAdminLayoutMenuVirtualPath = (
130
+ type: 'link' | 'designer',
131
+ identity: string | number,
132
+ layout?: AdminLayoutRoutePathLike | null,
133
+ ) => {
134
+ return joinAdminLayoutRoutePath(layout, `__admin_layout__/${type}/${encodeURIComponent(String(identity))}`);
135
+ };
136
+
137
+ const getLayoutRoutePathFromModel = (model: FlowModel): AdminLayoutRoutePathLike | undefined => {
138
+ const contextLayout = model.context.layout as AdminLayoutRoutePathLike | undefined;
139
+ if (contextLayout?.routePath) {
140
+ return contextLayout;
141
+ }
142
+
143
+ let current: FlowModel | undefined = model;
144
+ while (current) {
145
+ const propsLayout = current.props?.layout as AdminLayoutRoutePathLike | undefined;
146
+ if (propsLayout?.routePath) {
147
+ return propsLayout;
148
+ }
149
+ current = current.parent as FlowModel | undefined;
150
+ }
151
+
152
+ return undefined;
127
153
  };
128
154
 
129
155
  const menuItemStyle = { display: 'flex', alignItems: 'center', justifyContent: 'space-between' };
@@ -225,6 +251,41 @@ export const resolveAdminLayoutMenuLink = async (options: {
225
251
  return appendQueryStringToUrl(String(resolvedHref ?? href ?? ''), qs.stringify(query));
226
252
  };
227
253
 
254
+ function normalizeRouterBasename(basename?: string) {
255
+ if (!basename || basename === '/') {
256
+ return '';
257
+ }
258
+
259
+ return `/${basename.replace(/^\/+/, '').replace(/\/+$/, '')}`;
260
+ }
261
+
262
+ function isStandaloneDocumentUrl(url: string) {
263
+ return /^[a-z][a-z\d+\-.]*:/i.test(url) || url.startsWith('//') || url.startsWith('#') || url.startsWith('?');
264
+ }
265
+
266
+ function toDocumentUrlWithRouterBasename(url: string, basename?: string) {
267
+ if (!url || isStandaloneDocumentUrl(url)) {
268
+ return url;
269
+ }
270
+
271
+ const normalizedBasename = normalizeRouterBasename(basename);
272
+ if (!normalizedBasename) {
273
+ return url;
274
+ }
275
+
276
+ const rootRelativeUrl = url.startsWith('/') ? url : `/${url}`;
277
+ if (
278
+ rootRelativeUrl === normalizedBasename ||
279
+ rootRelativeUrl.startsWith(`${normalizedBasename}/`) ||
280
+ rootRelativeUrl.startsWith(`${normalizedBasename}?`) ||
281
+ rootRelativeUrl.startsWith(`${normalizedBasename}#`)
282
+ ) {
283
+ return rootRelativeUrl;
284
+ }
285
+
286
+ return `${normalizedBasename}${rootRelativeUrl}`;
287
+ }
288
+
228
289
  export const openAdminLayoutMenuLink = async (options: {
229
290
  context: FlowModel['context'];
230
291
  href: string;
@@ -249,7 +310,7 @@ export const openAdminLayoutMenuLink = async (options: {
249
310
  if (isMobile) {
250
311
  closeMobileMenu();
251
312
  }
252
- window.open(url, '_blank', 'noopener,noreferrer');
313
+ window.open(toDocumentUrlWithRouterBasename(url, basenameOfCurrentRouter), '_blank', 'noopener,noreferrer');
253
314
  return;
254
315
  }
255
316
 
@@ -265,7 +326,7 @@ export const openAdminLayoutMenuLink = async (options: {
265
326
  if (isMobile) {
266
327
  closeMobileMenu();
267
328
  }
268
- window.open(href, '_blank', 'noopener,noreferrer');
329
+ window.open(toDocumentUrlWithRouterBasename(href, basenameOfCurrentRouter), '_blank', 'noopener,noreferrer');
269
330
  }
270
331
  };
271
332
 
@@ -551,7 +612,7 @@ const GroupItem: FC<{ item: AdminLayoutMenuNode; options?: AdminLayoutMenuRender
551
612
  },
552
613
  });
553
614
  },
554
- [closeMobileMenu, item, item._navigationMode, navigate, props.options?.isMobile, runtimePath, spaRuntimePath],
615
+ [closeMobileMenu, item, navigate, props.options?.isMobile, runtimePath, spaRuntimePath],
555
616
  );
556
617
 
557
618
  const landingEntryAriaLabel = ariaLabel ? `${ariaLabel}-landing-entry` : 'group-landing-entry';
@@ -699,7 +760,7 @@ const MenuItem: FC<{ item: AdminLayoutMenuNode; options?: AdminLayoutMenuRenderO
699
760
  },
700
761
  });
701
762
  },
702
- [props.options?.isMobile, closeMobileMenu, isDocumentNavigation, item, navigate, runtimePath, spaRuntimePath],
763
+ [props.options?.isMobile, closeMobileMenu, isDocumentNavigation, navigate, runtimePath, spaRuntimePath],
703
764
  );
704
765
 
705
766
  if (item._route?.type === NocoBaseDesktopRouteType.link) {
@@ -876,14 +937,16 @@ export function getAdminLayoutMenuInitializerButton(
876
937
  testId: string,
877
938
  launcherModel: FlowModel,
878
939
  parentRoute?: NocoBaseDesktopRoute,
940
+ layout?: AdminLayoutRoutePathLike | null,
879
941
  ): AdminLayoutMenuNode {
880
942
  const identity =
881
943
  parentRoute?.id ?? parentRoute?.schemaUid ?? parentRoute?.menuSchemaUid ?? parentRoute?.title ?? launcherModel.uid;
944
+ const menuLayout = layout || getLayoutRoutePathFromModel(launcherModel);
882
945
 
883
946
  return {
884
947
  key: 'x-designer-button',
885
948
  name: <MenuDesignerButton testId={testId} launcherModel={launcherModel} parentRoute={parentRoute} />,
886
- path: getAdminLayoutMenuVirtualPath('designer', identity),
949
+ path: getAdminLayoutMenuVirtualPath('designer', identity, menuLayout),
887
950
  disabled: true,
888
951
  _route: {},
889
952
  _parentRoute: parentRoute,
@@ -114,7 +114,7 @@ export class AdminLayoutModel extends BaseLayoutModel<AdminLayoutStructure> {
114
114
 
115
115
  render() {
116
116
  return (
117
- <AdminLayoutEntryGuard>
117
+ <AdminLayoutEntryGuard model={this}>
118
118
  <AdminLayoutComponent {...this.props} model={this} />
119
119
  </AdminLayoutEntryGuard>
120
120
  );
@@ -16,10 +16,16 @@ import { useTranslation } from 'react-i18next';
16
16
  import { Outlet, useLocation, useMatches, useParams } from 'react-router-dom';
17
17
  import { KeepAlive } from '../../../components/KeepAlive';
18
18
  import { getLayoutContentRouteNames } from '../../../layout-manager/utils';
19
- import { isV2AdminRuntime, isV2MenuRoute } from './resolveAdminRouteRuntimeTarget';
19
+ import {
20
+ getAdminLayoutRoutePath,
21
+ isV2AdminRuntime,
22
+ isV2MenuRoute,
23
+ type AdminLayoutRoutePathLike,
24
+ } from './resolveAdminRouteRuntimeTarget';
20
25
 
21
26
  type AdminLayoutContentProps = {
22
27
  onContentElementChange?: (element: HTMLDivElement | null) => void;
28
+ layout?: (AdminLayoutRoutePathLike & { routeName?: string }) | null;
23
29
  };
24
30
 
25
31
  const layoutContentClass = css`
@@ -49,8 +55,6 @@ const mobileHeight = {
49
55
  height: `calc(100dvh - var(--nb-header-height))`,
50
56
  };
51
57
 
52
- const adminLayoutContentRouteNames = getLayoutContentRouteNames('admin');
53
-
54
58
  /**
55
59
  * 检测当前浏览器是否支持 dvh,移动端支持时优先使用它计算可视区域高度。
56
60
  *
@@ -66,7 +70,7 @@ function isDvhSupported() {
66
70
  return testEl.style.height === '1dvh';
67
71
  }
68
72
 
69
- const ShowTipWhenNoPages = observer(() => {
73
+ const ShowTipWhenNoPages = observer((props: { layout?: AdminLayoutRoutePathLike | null }) => {
70
74
  const flowEngine = useFlowEngine();
71
75
  const { token } = antdTheme.useToken();
72
76
  const { t } = useTranslation();
@@ -76,8 +80,13 @@ const ShowTipWhenNoPages = observer(() => {
76
80
  ? allAccessRoutes.filter((route) => isV2MenuRoute(route))
77
81
  : allAccessRoutes;
78
82
  const designable = !!flowEngine.context.flowSettingsEnabled;
83
+ const layoutRoutePath = getAdminLayoutRoutePath(props.layout);
79
84
 
80
- if (visibleRoutes.length === 0 && !designable && ['/admin', '/admin/'].includes(location.pathname)) {
85
+ if (
86
+ visibleRoutes.length === 0 &&
87
+ !designable &&
88
+ (location.pathname === layoutRoutePath || location.pathname === `${layoutRoutePath}/`)
89
+ ) {
81
90
  return (
82
91
  <Result
83
92
  icon={<HighlightOutlined style={{ fontSize: '8em', color: token.colorText }} />}
@@ -95,13 +104,17 @@ const ShowTipWhenNoPages = observer(() => {
95
104
  *
96
105
  * 内容区不再依赖独立 FlowModel,而是通过回调把挂载目标同步给 root model。
97
106
  */
98
- export const AdminLayoutContent: FC<AdminLayoutContentProps> = ({ onContentElementChange }) => {
107
+ export const AdminLayoutContent: FC<AdminLayoutContentProps> = ({ onContentElementChange, layout }) => {
99
108
  const style = useMemo(() => (isDvhSupported() ? mobileHeight : undefined), []);
100
109
  const params = useParams();
101
110
  const matches = useMatches();
102
111
  const pageUid = params.name;
103
112
  const currentRouteId = matches.at(-1)?.id;
104
- const shouldKeepAlive = !!pageUid && adminLayoutContentRouteNames.includes(currentRouteId || '');
113
+ const layoutContentRouteNames = useMemo(
114
+ () => getLayoutContentRouteNames(layout?.routeName || 'admin'),
115
+ [layout?.routeName],
116
+ );
117
+ const shouldKeepAlive = !!pageUid && layoutContentRouteNames.includes(currentRouteId || '');
105
118
  const bindLayoutContentRef = useCallback(
106
119
  (node: HTMLDivElement | null) => {
107
120
  // shell 直接渲染内容区时,仍需把挂载目标同步给 root model。
@@ -118,7 +131,7 @@ export const AdminLayoutContent: FC<AdminLayoutContentProps> = ({ onContentEleme
118
131
  >
119
132
  <div style={pageContentStyle}>
120
133
  {shouldKeepAlive && pageUid ? <KeepAlive uid={pageUid}>{() => <Outlet />}</KeepAlive> : <Outlet />}
121
- <ShowTipWhenNoPages />
134
+ <ShowTipWhenNoPages layout={layout} />
122
135
  </div>
123
136
  </div>
124
137
  );
@@ -0,0 +1,188 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { FlowEngine, FlowEngineProvider } from '@nocobase/flow-engine';
11
+ import { act, render, screen, waitFor } from '@testing-library/react';
12
+ import React, { useEffect } from 'react';
13
+ import { createMemoryRouter, RouterProvider, useParams } from 'react-router-dom';
14
+ import { describe, expect, it, vi } from 'vitest';
15
+ import type { LayoutDefinition } from '../../../../layout-manager/types';
16
+ import { AdminLayoutComponent } from '../AdminLayoutComponent';
17
+ import { AdminLayoutModel } from '../AdminLayoutModel';
18
+ import { AdminLayoutContent } from '../AdminLayoutSlotModels';
19
+
20
+ vi.mock('@ant-design/pro-layout', async () => {
21
+ const ReactModule = await import('react');
22
+ const RouteContext = ReactModule.createContext({ isMobile: false });
23
+
24
+ return {
25
+ default: (props: { children?: React.ReactNode }) =>
26
+ ReactModule.createElement(
27
+ RouteContext.Provider,
28
+ { value: { isMobile: false } },
29
+ ReactModule.createElement('div', { 'data-testid': 'pro-layout' }, props.children),
30
+ ),
31
+ RouteContext,
32
+ };
33
+ });
34
+
35
+ vi.mock('../../../system-settings', () => ({
36
+ useSystemSettings: () => ({
37
+ loading: false,
38
+ data: {
39
+ data: {
40
+ title: 'NocoBase',
41
+ },
42
+ },
43
+ }),
44
+ }));
45
+
46
+ vi.mock('../useApplications', () => ({
47
+ useApplications: () => ({
48
+ appList: [],
49
+ }),
50
+ }));
51
+
52
+ vi.mock('../AppListRender', () => ({
53
+ useAppListRender: () => undefined,
54
+ }));
55
+
56
+ describe('AdminLayoutComponent', () => {
57
+ it('keeps custom admin layout pages alive by layout route name', async () => {
58
+ const layout: LayoutDefinition = {
59
+ routeName: 'admin2',
60
+ routePath: '/admin2',
61
+ rootRouteName: 'admin2',
62
+ uid: 'custom-admin-layout-model',
63
+ layoutModelClass: 'AdminLayoutModel',
64
+ rootPageModelClass: 'RootPageModel',
65
+ childPageModelClass: 'ChildPageModel',
66
+ authCheck: true,
67
+ };
68
+ const events: string[] = [];
69
+ const engine = new FlowEngine();
70
+ engine.context.defineProperty('routeRepository', {
71
+ value: {
72
+ listAccessible: () => [],
73
+ },
74
+ });
75
+
76
+ const Page = () => {
77
+ const { name } = useParams();
78
+ useEffect(() => {
79
+ events.push(`mount:${name}`);
80
+ return () => {
81
+ events.push(`unmount:${name}`);
82
+ };
83
+ }, [name]);
84
+
85
+ return <div data-testid={`admin-layout-page-${name}`}>page {name}</div>;
86
+ };
87
+
88
+ const router = createMemoryRouter(
89
+ [
90
+ {
91
+ id: layout.routeName,
92
+ path: layout.routePath,
93
+ element: <AdminLayoutContent layout={layout} />,
94
+ children: [
95
+ {
96
+ id: 'admin2.__page',
97
+ path: ':name',
98
+ element: <Page />,
99
+ },
100
+ ],
101
+ },
102
+ ],
103
+ {
104
+ initialEntries: ['/admin2/page-a'],
105
+ },
106
+ );
107
+
108
+ render(
109
+ <FlowEngineProvider engine={engine}>
110
+ <RouterProvider router={router} />
111
+ </FlowEngineProvider>,
112
+ );
113
+
114
+ expect(await screen.findByTestId('admin-layout-page-page-a')).toBeInTheDocument();
115
+
116
+ await act(async () => {
117
+ await router.navigate('/admin2/page-b');
118
+ });
119
+
120
+ expect(await screen.findByTestId('admin-layout-page-page-b')).toBeInTheDocument();
121
+ expect(screen.getByTestId('admin-layout-page-page-a')).toBeInTheDocument();
122
+ expect(events).toEqual(['mount:page-a', 'mount:page-b']);
123
+ });
124
+
125
+ it('activates the current layout before loading accessible routes', async () => {
126
+ const layout: LayoutDefinition = {
127
+ routeName: 'admin2',
128
+ routePath: '/admin2',
129
+ rootRouteName: 'admin2',
130
+ uid: 'custom-admin-layout-model',
131
+ layoutModelClass: 'AdminLayoutModel',
132
+ rootPageModelClass: 'RootPageModel',
133
+ childPageModelClass: 'ChildPageModel',
134
+ authCheck: true,
135
+ };
136
+ const deactivateLayout = vi.fn();
137
+ const activateLayout = vi.fn(() => deactivateLayout);
138
+ const ensureAccessibleLoaded = vi.fn(async () => []);
139
+ const engine = new FlowEngine();
140
+ engine.context.defineProperty('routeRepository', {
141
+ value: {
142
+ activateLayout,
143
+ ensureAccessibleLoaded,
144
+ listAccessible: () => [],
145
+ subscribe: vi.fn(),
146
+ unsubscribe: vi.fn(),
147
+ },
148
+ });
149
+ engine.context.defineProperty('t', {
150
+ value: (key: string) => key,
151
+ });
152
+ const model = engine.createModel<AdminLayoutModel>({
153
+ uid: layout.uid,
154
+ use: AdminLayoutModel,
155
+ props: {
156
+ layout,
157
+ },
158
+ });
159
+
160
+ const router = createMemoryRouter(
161
+ [
162
+ {
163
+ id: layout.routeName,
164
+ path: layout.routePath,
165
+ element: <AdminLayoutComponent model={model} />,
166
+ },
167
+ ],
168
+ {
169
+ initialEntries: ['/admin2'],
170
+ },
171
+ );
172
+ const { unmount } = render(
173
+ <FlowEngineProvider engine={engine}>
174
+ <RouterProvider router={router} />
175
+ </FlowEngineProvider>,
176
+ );
177
+
178
+ await waitFor(() => {
179
+ expect(ensureAccessibleLoaded).toHaveBeenCalled();
180
+ });
181
+ expect(activateLayout).toHaveBeenCalledWith(layout);
182
+ expect(activateLayout.mock.invocationCallOrder[0]).toBeLessThan(ensureAccessibleLoaded.mock.invocationCallOrder[0]);
183
+
184
+ unmount();
185
+
186
+ expect(deactivateLayout).toHaveBeenCalledTimes(1);
187
+ });
188
+ });