@nocobase/client-v2 2.1.11 → 2.2.0-alpha.2

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 (137) hide show
  1. package/es/PluginSettingsManager.d.ts +33 -0
  2. package/es/RouteRepository.d.ts +21 -6
  3. package/es/components/category-tabs/SortableCategoryTabs.d.ts +55 -0
  4. package/es/components/category-tabs/index.d.ts +9 -0
  5. package/es/components/form/TypedVariableInput.d.ts +22 -4
  6. package/es/components/form/filter/CollectionFilterItem.d.ts +11 -1
  7. package/es/components/form/filter/index.d.ts +2 -0
  8. package/es/components/index.d.ts +2 -0
  9. package/es/flow/actions/afterSuccess.d.ts +8 -1
  10. package/es/flow/actions/index.d.ts +1 -1
  11. package/es/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.d.ts +2 -0
  12. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.d.ts +2 -1
  13. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +2 -0
  14. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.d.ts +3 -2
  15. package/es/flow/admin-shell/admin-layout/AdminLayoutSlotModels.d.ts +5 -0
  16. package/es/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.d.ts +6 -0
  17. package/es/flow/components/FieldAssignValueInput.d.ts +2 -0
  18. package/es/flow/index.d.ts +1 -0
  19. package/es/flow/models/base/GridModel.d.ts +1 -1
  20. package/es/flow/models/blocks/form/submitHandler.d.ts +1 -1
  21. package/es/flow/models/blocks/table/TableActionsColumnModel.d.ts +1 -0
  22. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  23. package/es/flow/models/blocks/table/dragSort/dragSortHooks.d.ts +1 -1
  24. package/es/flow/models/blocks/table/dragSort/dragSortSettings.d.ts +2 -1
  25. package/es/flow/models/blocks/table/dragSort/dragSortUtils.d.ts +6 -4
  26. package/es/flow/resolveViewParamsToViewList.d.ts +1 -1
  27. package/es/index.d.ts +2 -1
  28. package/es/index.mjs +181 -131
  29. package/lib/index.js +186 -136
  30. package/package.json +7 -7
  31. package/src/PluginSettingsManager.ts +53 -0
  32. package/src/RouteRepository.ts +126 -24
  33. package/src/__tests__/PluginSettingsManager.test.ts +13 -0
  34. package/src/__tests__/RouteRepository.test.ts +216 -0
  35. package/src/__tests__/browserChecker.test.ts +61 -0
  36. package/src/__tests__/exports.test.ts +16 -0
  37. package/src/__tests__/plugin-manager.test.tsx +44 -2
  38. package/src/__tests__/settings-center.test.tsx +40 -1
  39. package/src/collection-manager/field-configure.ts +1 -1
  40. package/src/collection-manager/interfaces/id.ts +1 -1
  41. package/src/collection-manager/interfaces/m2m.tsx +2 -2
  42. package/src/collection-manager/interfaces/m2o.tsx +2 -2
  43. package/src/collection-manager/interfaces/nanoid.ts +1 -1
  44. package/src/collection-manager/interfaces/o2m.tsx +2 -2
  45. package/src/collection-manager/interfaces/obo.tsx +2 -2
  46. package/src/collection-manager/interfaces/oho.tsx +2 -2
  47. package/src/collection-manager/interfaces/properties/index.ts +2 -2
  48. package/src/collection-manager/interfaces/uuid.ts +1 -1
  49. package/src/components/README.md +7 -1
  50. package/src/components/README.zh-CN.md +6 -1
  51. package/src/components/category-tabs/SortableCategoryTabs.tsx +210 -0
  52. package/src/components/category-tabs/index.ts +10 -0
  53. package/src/components/form/TypedVariableInput.tsx +416 -93
  54. package/src/components/form/__tests__/TypedVariableInput.test.tsx +266 -9
  55. package/src/components/form/filter/CollectionFilterItem.tsx +32 -7
  56. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +38 -0
  57. package/src/components/form/filter/index.ts +2 -0
  58. package/src/components/index.ts +2 -0
  59. package/src/flow/__tests__/FlowRoute.test.tsx +443 -5
  60. package/src/flow/__tests__/getKey.test.ts +7 -0
  61. package/src/flow/__tests__/resolveViewParamsToViewList.test.ts +34 -0
  62. package/src/flow/actions/__tests__/afterSuccess.test.ts +73 -0
  63. package/src/flow/actions/__tests__/openView.defineProps.route.test.tsx +104 -0
  64. package/src/flow/actions/afterSuccess.tsx +142 -3
  65. package/src/flow/actions/customVariable.tsx +1 -2
  66. package/src/flow/actions/index.ts +1 -1
  67. package/src/flow/actions/linkageRules.tsx +2 -7
  68. package/src/flow/actions/openView.tsx +38 -4
  69. package/src/flow/actions/runjs.tsx +2 -14
  70. package/src/flow/admin-shell/BaseLayoutModel.tsx +25 -3
  71. package/src/flow/admin-shell/BaseLayoutRouteCoordinator.ts +5 -2
  72. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +13 -0
  73. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +34 -10
  74. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.test.tsx +310 -0
  75. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +44 -8
  76. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.ts +5 -3
  77. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +61 -9
  78. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +71 -8
  79. package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +1 -1
  80. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +23 -9
  81. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +188 -0
  82. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +99 -0
  83. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +82 -0
  84. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +166 -2
  85. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +20 -0
  86. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +33 -5
  87. package/src/flow/components/DefaultValue.tsx +1 -2
  88. package/src/flow/components/FieldAssignValueInput.tsx +10 -5
  89. package/src/flow/components/FlowRoute.tsx +56 -11
  90. package/src/flow/components/code-editor/__tests__/useCodeRunner.test.tsx +2 -17
  91. package/src/flow/components/code-editor/hooks/useCodeRunner.ts +2 -14
  92. package/src/flow/components/code-editor/runjsDiagnostics.ts +8 -45
  93. package/src/flow/getViewDiffAndUpdateHidden.tsx +1 -0
  94. package/src/flow/index.ts +1 -0
  95. package/src/flow/models/actions/JSActionModel.tsx +2 -7
  96. package/src/flow/models/actions/JSCollectionActionModel.tsx +2 -7
  97. package/src/flow/models/actions/JSItemActionModel.tsx +2 -7
  98. package/src/flow/models/actions/JSRecordActionModel.tsx +2 -7
  99. package/src/flow/models/base/ActionModel.tsx +12 -1
  100. package/src/flow/models/base/GridModel.tsx +38 -7
  101. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +0 -4
  102. package/src/flow/models/base/__tests__/ActionModel.test.ts +83 -0
  103. package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +239 -1
  104. package/src/flow/models/base/__tests__/transformRowsToSingleColumn.test.ts +48 -0
  105. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +1 -2
  106. package/src/flow/models/blocks/filter-form/FilterFormJSActionModel.tsx +2 -7
  107. package/src/flow/models/blocks/filter-form/fields/FilterFormCustomFieldModel.tsx +1 -1
  108. package/src/flow/models/blocks/form/FormActionModel.tsx +1 -1
  109. package/src/flow/models/blocks/form/JSFormActionModel.tsx +2 -7
  110. package/src/flow/models/blocks/form/__tests__/submitHandler.test.ts +54 -1
  111. package/src/flow/models/blocks/form/submitHandler.ts +17 -3
  112. package/src/flow/models/blocks/form/value-runtime/rules.ts +2 -2
  113. package/src/flow/models/blocks/js-block/JSBlock.tsx +2 -7
  114. package/src/flow/models/blocks/table/JSColumnModel.tsx +1 -9
  115. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +36 -13
  116. package/src/flow/models/blocks/table/TableBlockModel.tsx +38 -17
  117. package/src/flow/models/blocks/table/__tests__/TableActionsColumnModel.test.tsx +111 -1
  118. package/src/flow/models/blocks/table/__tests__/TableBlockModel.dragSort.test.ts +127 -0
  119. package/src/flow/models/blocks/table/__tests__/TableBlockModel.mobileSettingsButtons.test.tsx +78 -0
  120. package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowSelection.test.tsx +1 -0
  121. package/src/flow/models/blocks/table/dragSort/dragSortHooks.tsx +28 -17
  122. package/src/flow/models/blocks/table/dragSort/dragSortSettings.ts +13 -6
  123. package/src/flow/models/blocks/table/dragSort/dragSortUtils.ts +15 -2
  124. package/src/flow/models/fields/JSEditableFieldModel.tsx +2 -11
  125. package/src/flow/models/fields/JSFieldModel.tsx +2 -7
  126. package/src/flow/models/fields/JSItemModel.tsx +2 -14
  127. package/src/flow/models/topbar/TopbarActionModel.tsx +93 -10
  128. package/src/flow/resolveViewParamsToViewList.tsx +11 -3
  129. package/src/flow-compat/Popover.tsx +43 -4
  130. package/src/flow-compat/__tests__/Popover.test.tsx +34 -0
  131. package/src/index.ts +8 -1
  132. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +41 -1
  133. package/src/nocobase-buildin-plugin/index.tsx +0 -2
  134. package/src/settings-center/AdminSettingsLayout.tsx +10 -2
  135. package/src/settings-center/SystemSettingsPage.tsx +1 -2
  136. package/src/settings-center/plugin-manager/PluginCard.tsx +2 -2
  137. package/src/settings-center/utils.tsx +0 -6
@@ -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,17 @@ 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;
29
+ designable?: boolean;
23
30
  };
24
31
 
25
32
  const layoutContentClass = css`
@@ -49,8 +56,6 @@ const mobileHeight = {
49
56
  height: `calc(100dvh - var(--nb-header-height))`,
50
57
  };
51
58
 
52
- const adminLayoutContentRouteNames = getLayoutContentRouteNames('admin');
53
-
54
59
  /**
55
60
  * 检测当前浏览器是否支持 dvh,移动端支持时优先使用它计算可视区域高度。
56
61
  *
@@ -66,7 +71,7 @@ function isDvhSupported() {
66
71
  return testEl.style.height === '1dvh';
67
72
  }
68
73
 
69
- const ShowTipWhenNoPages = observer(() => {
74
+ const ShowTipWhenNoPages = observer((props: { designable?: boolean; layout?: AdminLayoutRoutePathLike | null }) => {
70
75
  const flowEngine = useFlowEngine();
71
76
  const { token } = antdTheme.useToken();
72
77
  const { t } = useTranslation();
@@ -75,9 +80,14 @@ const ShowTipWhenNoPages = observer(() => {
75
80
  const visibleRoutes = isV2AdminRuntime(flowEngine.context.app)
76
81
  ? allAccessRoutes.filter((route) => isV2MenuRoute(route))
77
82
  : allAccessRoutes;
78
- const designable = !!flowEngine.context.flowSettingsEnabled;
83
+ const designable = !!props.designable || !!flowEngine.context.flowSettingsEnabled;
84
+ const layoutRoutePath = getAdminLayoutRoutePath(props.layout);
79
85
 
80
- if (visibleRoutes.length === 0 && !designable && ['/admin', '/admin/'].includes(location.pathname)) {
86
+ if (
87
+ visibleRoutes.length === 0 &&
88
+ !designable &&
89
+ (location.pathname === layoutRoutePath || location.pathname === `${layoutRoutePath}/`)
90
+ ) {
81
91
  return (
82
92
  <Result
83
93
  icon={<HighlightOutlined style={{ fontSize: '8em', color: token.colorText }} />}
@@ -95,13 +105,17 @@ const ShowTipWhenNoPages = observer(() => {
95
105
  *
96
106
  * 内容区不再依赖独立 FlowModel,而是通过回调把挂载目标同步给 root model。
97
107
  */
98
- export const AdminLayoutContent: FC<AdminLayoutContentProps> = ({ onContentElementChange }) => {
108
+ export const AdminLayoutContent: FC<AdminLayoutContentProps> = ({ designable, onContentElementChange, layout }) => {
99
109
  const style = useMemo(() => (isDvhSupported() ? mobileHeight : undefined), []);
100
110
  const params = useParams();
101
111
  const matches = useMatches();
102
112
  const pageUid = params.name;
103
113
  const currentRouteId = matches.at(-1)?.id;
104
- const shouldKeepAlive = !!pageUid && adminLayoutContentRouteNames.includes(currentRouteId || '');
114
+ const layoutContentRouteNames = useMemo(
115
+ () => getLayoutContentRouteNames(layout?.routeName || 'admin'),
116
+ [layout?.routeName],
117
+ );
118
+ const shouldKeepAlive = !!pageUid && layoutContentRouteNames.includes(currentRouteId || '');
105
119
  const bindLayoutContentRef = useCallback(
106
120
  (node: HTMLDivElement | null) => {
107
121
  // shell 直接渲染内容区时,仍需把挂载目标同步给 root model。
@@ -118,7 +132,7 @@ export const AdminLayoutContent: FC<AdminLayoutContentProps> = ({ onContentEleme
118
132
  >
119
133
  <div style={pageContentStyle}>
120
134
  {shouldKeepAlive && pageUid ? <KeepAlive uid={pageUid}>{() => <Outlet />}</KeepAlive> : <Outlet />}
121
- <ShowTipWhenNoPages />
135
+ <ShowTipWhenNoPages designable={designable} layout={layout} />
122
136
  </div>
123
137
  </div>
124
138
  );
@@ -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
+ });
@@ -318,6 +318,50 @@ describe('AdminLayoutModel menu items', () => {
318
318
  expect(route.children[1]._model).toBe(adminLayoutModel.subModels.menuItems?.[1]);
319
319
  });
320
320
 
321
+ it('should generate ProLayout route tree under a custom admin layout route path', () => {
322
+ const adminLayoutModel = engine.createModel<AdminLayoutModel>({
323
+ uid: 'admin2-layout-model',
324
+ use: AdminLayoutModel,
325
+ props: {
326
+ layout: {
327
+ routeName: 'admin2',
328
+ routePath: '/admin2',
329
+ uid: 'admin2-layout-model',
330
+ layoutModelClass: 'AdminLayoutModel',
331
+ },
332
+ },
333
+ });
334
+
335
+ adminLayoutModel.syncMenuRoutes([
336
+ {
337
+ id: 1,
338
+ title: 'Group',
339
+ type: NocoBaseDesktopRouteType.group,
340
+ children: [
341
+ {
342
+ id: 11,
343
+ title: 'Page 1',
344
+ schemaUid: 'page-1',
345
+ type: NocoBaseDesktopRouteType.flowPage,
346
+ },
347
+ ],
348
+ },
349
+ ]);
350
+
351
+ const route = adminLayoutModel.toProLayoutRoute({
352
+ designable: false,
353
+ isMobile: false,
354
+ t: (title) => title,
355
+ });
356
+
357
+ expect(route.children[0].path).toBe('/admin2/1');
358
+ expect(route.children[0].redirect).toBe('/admin2/page-1');
359
+ expect(route.children[0]._runtimePath).toBe('/apps/demo/v2/admin2/page-1');
360
+ expect(route.children[0].routes?.[0].path).toBe('/admin2/page-1');
361
+ expect(route.children[0].routes?.[0].redirect).toBe('/admin2/page-1');
362
+ expect(route.children[0].routes?.[0]._runtimePath).toBe('/apps/demo/v2/admin2/page-1');
363
+ });
364
+
321
365
  it('should filter legacy page menu routes but keep empty groups in v2 admin layout', () => {
322
366
  const adminLayoutModel = engine.createModel<AdminLayoutModel>({
323
367
  uid: 'admin-layout-model',
@@ -1128,6 +1172,46 @@ describe('AdminLayoutModel menu items', () => {
1128
1172
  expect(adminLayoutModel.menuRouteRefreshVersion).toBe(refreshBefore + 1);
1129
1173
  });
1130
1174
 
1175
+ it('should refresh the owning admin layout when hiding custom layout menu routes dynamically', () => {
1176
+ const defaultAdminLayoutModel = engine.createModel<AdminLayoutModel>({
1177
+ uid: ADMIN_LAYOUT_MODEL_UID,
1178
+ use: AdminLayoutModel,
1179
+ });
1180
+ const customAdminLayoutModel = engine.createModel<AdminLayoutModel>({
1181
+ uid: 'custom-admin-layout-model',
1182
+ use: AdminLayoutModel,
1183
+ props: {
1184
+ layout: {
1185
+ routeName: 'admin2',
1186
+ routePath: '/admin2',
1187
+ rootRouteName: 'admin2',
1188
+ uid: 'custom-admin-layout-model',
1189
+ layoutModelClass: 'AdminLayoutModel',
1190
+ rootPageModelClass: 'RootPageModel',
1191
+ childPageModelClass: 'ChildPageModel',
1192
+ authCheck: true,
1193
+ },
1194
+ },
1195
+ });
1196
+ const model = engine.createModel<AdminLayoutMenuItemModel>({
1197
+ uid: 'menu-item-custom-layout-dynamic-hidden',
1198
+ use: AdminLayoutMenuItemModel,
1199
+ props: {
1200
+ route: createRoute(),
1201
+ },
1202
+ });
1203
+
1204
+ model.setParent(customAdminLayoutModel);
1205
+
1206
+ const defaultRefreshBefore = defaultAdminLayoutModel.menuRouteRefreshVersion;
1207
+ const customRefreshBefore = customAdminLayoutModel.menuRouteRefreshVersion;
1208
+
1209
+ model.setHidden(true);
1210
+
1211
+ expect(customAdminLayoutModel.menuRouteRefreshVersion).toBe(customRefreshBefore + 1);
1212
+ expect(defaultAdminLayoutModel.menuRouteRefreshVersion).toBe(defaultRefreshBefore);
1213
+ });
1214
+
1131
1215
  it('should render hidden menu item with opacity and keep original title in config mode', () => {
1132
1216
  const model = engine.createModel<AdminLayoutMenuItemModel>({
1133
1217
  uid: 'menu-item-hidden-in-config',
@@ -2222,6 +2306,21 @@ describe('AdminLayoutModel menu items', () => {
2222
2306
  );
2223
2307
  });
2224
2308
 
2309
+ it('should include router basename when opening same-origin links in a new window', async () => {
2310
+ await openAdminLayoutMenuLink({
2311
+ context: engine.context as any,
2312
+ href: '/admin2/page',
2313
+ params: [{ name: 'from', value: 'admin' }],
2314
+ openInNewWindow: true,
2315
+ isMobile: false,
2316
+ closeMobileMenu: vi.fn(),
2317
+ navigate: navigateMock,
2318
+ basenameOfCurrentRouter: '/v',
2319
+ });
2320
+
2321
+ expect(window.open).toHaveBeenCalledWith('/v/admin2/page?from=admin', '_blank', 'noopener,noreferrer');
2322
+ });
2323
+
2225
2324
  it('should resolve sibling move options for non-group drag target', () => {
2226
2325
  const activeModel = engine.createModel<AdminLayoutMenuItemModel>({
2227
2326
  uid: 'drag-source-page',
@@ -39,6 +39,7 @@ import {
39
39
  FlowEngine,
40
40
  FlowEngineProvider,
41
41
  FlowModelRenderer,
42
+ encodeOpenViewRouteState,
42
43
  useFlowEngine,
43
44
  type FlowModel,
44
45
  } from '@nocobase/flow-engine';
@@ -213,6 +214,87 @@ describe('AdminLayoutModel runtime', () => {
213
214
  });
214
215
  });
215
216
 
217
+ it('should parse RunJS openView route params into layout route view stack state', async () => {
218
+ const token = encodeOpenViewRouteState('popup', { mode: 'dialog', size: 'large' });
219
+ if (!token) {
220
+ throw new Error('Expected openView route state token.');
221
+ }
222
+ const engine = new FlowEngine();
223
+ engine.context.defineProperty('routeRepository', {
224
+ value: {
225
+ getRouteBySchemaUid: (pageUid: string) => ({ title: pageUid }),
226
+ },
227
+ });
228
+
229
+ render(
230
+ <FlowEngineProvider engine={engine}>
231
+ <TestAdminLayoutHost />
232
+ </FlowEngineProvider>,
233
+ );
234
+ const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
235
+ expect(model).toBeTruthy();
236
+
237
+ act(() => {
238
+ model.syncLayoutRoute({
239
+ name: getLayoutPageViewRouteName('admin'),
240
+ pathname: `/admin/page-1/view/popup/opts/${token}/filterbytk/1`,
241
+ layoutBasePathname: '/admin',
242
+ });
243
+ });
244
+
245
+ await waitFor(() => {
246
+ expect(model.context.layoutRoute).toMatchObject({
247
+ type: 'page',
248
+ pageUid: 'page-1',
249
+ viewStack: [
250
+ { viewUid: 'page-1' },
251
+ {
252
+ viewUid: 'popup',
253
+ openViewRouteState: { mode: 'dialog', size: 'large' },
254
+ filterByTk: '1',
255
+ },
256
+ ],
257
+ });
258
+ });
259
+ });
260
+
261
+ it('should reject malformed RunJS openView route params', async () => {
262
+ const wrongViewToken = encodeOpenViewRouteState('other-popup', { mode: 'dialog', size: 'large' });
263
+ if (!wrongViewToken) {
264
+ throw new Error('Expected openView route state token.');
265
+ }
266
+ const engine = new FlowEngine();
267
+
268
+ render(
269
+ <FlowEngineProvider engine={engine}>
270
+ <TestAdminLayoutHost />
271
+ </FlowEngineProvider>,
272
+ );
273
+ const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
274
+ expect(model).toBeTruthy();
275
+
276
+ [
277
+ '/admin/page-1/sourceid',
278
+ '/admin/page-1/AbCdEfGh/filterbytk/1',
279
+ '/admin/page-1/view/popup/AbCdEfGh/filterbytk/1',
280
+ '/admin/page-1/view/popup/opts/AbCdEfGh/filterbytk/1',
281
+ '/admin/page-1/view/popup/openviewmode/dialog',
282
+ '/admin/page-1/view/popup/openviewsize/large',
283
+ `/admin/page-1/view/popup/opts/${wrongViewToken}/filterbytk/1`,
284
+ ].forEach((pathname) => {
285
+ expect(
286
+ model.resolveLayoutRoute({
287
+ name: getLayoutPageViewRouteName('admin'),
288
+ pathname,
289
+ layoutBasePathname: '/admin',
290
+ }),
291
+ ).toMatchObject({
292
+ type: 'notFound',
293
+ pathname,
294
+ });
295
+ });
296
+ });
297
+
216
298
  it('should not consume global routes that belong to nested layouts', async () => {
217
299
  const engine = new FlowEngine();
218
300
  const routeRef = observable.ref({