@nocobase/client-v2 2.2.0-beta.7 → 2.2.0-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/es/BaseApplication.d.ts +2 -0
  2. package/es/RouteRepository.d.ts +8 -0
  3. package/es/RouterManager.d.ts +15 -0
  4. package/es/authRedirect.d.ts +1 -0
  5. package/es/entry-actions/EntryActionManager.d.ts +24 -0
  6. package/es/entry-actions/index.d.ts +9 -0
  7. package/es/flow/actions/afterSuccess.d.ts +8 -1
  8. package/es/flow/actions/index.d.ts +1 -1
  9. package/es/flow/admin-shell/BaseLayoutModel.d.ts +6 -0
  10. package/es/flow/admin-shell/BaseLayoutRouteCoordinator.d.ts +7 -0
  11. package/es/flow/admin-shell/admin-layout/AdminLayoutSlotModels.d.ts +1 -0
  12. package/es/flow/admin-shell/admin-layout/AppListRender.d.ts +2 -1
  13. package/es/flow/admin-shell/admin-layout/AppSwitcherActionPanelModel.d.ts +24 -0
  14. package/es/flow/admin-shell/admin-layout/index.d.ts +1 -0
  15. package/es/flow/admin-shell/admin-layout/useApplications.d.ts +7 -2
  16. package/es/flow/models/base/ActionModelCore.d.ts +2 -0
  17. package/es/flow/models/blocks/form/submitHandler.d.ts +1 -1
  18. package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +9 -0
  19. package/es/flow/models/blocks/js-block/JSBlock.d.ts +1 -0
  20. package/es/flow/models/blocks/table/TableActionsColumnModel.d.ts +1 -0
  21. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  22. package/es/flow/models/blocks/table/dragSort/dragSortHooks.d.ts +1 -1
  23. package/es/flow/models/blocks/table/dragSort/dragSortSettings.d.ts +2 -1
  24. package/es/flow/models/blocks/table/dragSort/dragSortUtils.d.ts +6 -4
  25. package/es/flow/resolveViewParamsToViewList.d.ts +1 -1
  26. package/es/flow/routeTransientInputArgs.d.ts +14 -0
  27. package/es/index.d.ts +2 -0
  28. package/es/index.mjs +243 -141
  29. package/es/utils/markdownSanitize.d.ts +11 -0
  30. package/lib/index.js +232 -130
  31. package/package.json +7 -7
  32. package/src/BaseApplication.tsx +2 -0
  33. package/src/RouteRepository.ts +25 -0
  34. package/src/RouterManager.tsx +142 -1
  35. package/src/__tests__/RouteRepository.test.ts +23 -0
  36. package/src/__tests__/RouterManager.test.ts +125 -0
  37. package/src/__tests__/authRedirect.test.ts +17 -0
  38. package/src/authRedirect.ts +38 -0
  39. package/src/collection-manager/field-configure.ts +1 -1
  40. package/src/collection-manager/interfaces/id.ts +1 -1
  41. package/src/collection-manager/interfaces/m2m.tsx +2 -2
  42. package/src/collection-manager/interfaces/m2o.tsx +2 -2
  43. package/src/collection-manager/interfaces/nanoid.ts +1 -1
  44. package/src/collection-manager/interfaces/o2m.tsx +2 -2
  45. package/src/collection-manager/interfaces/obo.tsx +2 -2
  46. package/src/collection-manager/interfaces/oho.tsx +2 -2
  47. package/src/collection-manager/interfaces/properties/index.ts +2 -2
  48. package/src/collection-manager/interfaces/uuid.ts +1 -1
  49. package/src/entry-actions/EntryActionManager.ts +76 -0
  50. package/src/entry-actions/index.ts +10 -0
  51. package/src/flow/FlowPage.tsx +29 -2
  52. package/src/flow/__tests__/FlowPage.test.tsx +152 -25
  53. package/src/flow/__tests__/FlowRoute.test.tsx +547 -2
  54. package/src/flow/__tests__/getKey.test.ts +7 -0
  55. package/src/flow/__tests__/resolveViewParamsToViewList.test.ts +34 -0
  56. package/src/flow/actions/__tests__/afterSuccess.test.ts +73 -0
  57. package/src/flow/actions/__tests__/dataScopeFilter.test.ts +62 -0
  58. package/src/flow/actions/__tests__/openView.defineProps.route.test.tsx +164 -0
  59. package/src/flow/actions/afterSuccess.tsx +142 -3
  60. package/src/flow/actions/dataScopeFilter.ts +10 -1
  61. package/src/flow/actions/dateTimeFormat.tsx +2 -2
  62. package/src/flow/actions/index.ts +1 -1
  63. package/src/flow/actions/openView.tsx +59 -5
  64. package/src/flow/admin-shell/BaseLayoutModel.tsx +149 -3
  65. package/src/flow/admin-shell/BaseLayoutRouteCoordinator.ts +187 -42
  66. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +1020 -110
  67. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +42 -4
  68. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.test.tsx +177 -1
  69. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +20 -0
  70. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +5 -4
  71. package/src/flow/admin-shell/admin-layout/AppListRender.tsx +13 -2
  72. package/src/flow/admin-shell/admin-layout/AppSwitcherActionPanelModel.tsx +289 -0
  73. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +518 -2
  74. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +48 -0
  75. package/src/flow/admin-shell/admin-layout/index.ts +1 -0
  76. package/src/flow/admin-shell/admin-layout/useApplications.tsx +81 -12
  77. package/src/flow/common/Markdown/Display.tsx +14 -3
  78. package/src/flow/common/Markdown/Edit.tsx +19 -7
  79. package/src/flow/components/FlowRoute.tsx +128 -16
  80. package/src/flow/getViewDiffAndUpdateHidden.tsx +1 -0
  81. package/src/flow/internal/components/Markdown/util.ts +2 -1
  82. package/src/flow/models/base/ActionModel.tsx +10 -8
  83. package/src/flow/models/base/ActionModelCore.tsx +11 -4
  84. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +37 -0
  85. package/src/flow/models/blocks/filter-form/fields/FilterFormCustomFieldModel.tsx +1 -1
  86. package/src/flow/models/blocks/form/FormActionModel.tsx +1 -1
  87. package/src/flow/models/blocks/form/__tests__/submitHandler.test.ts +54 -1
  88. package/src/flow/models/blocks/form/submitHandler.ts +17 -3
  89. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +87 -0
  90. package/src/flow/models/blocks/form/value-runtime/runtime.ts +91 -0
  91. package/src/flow/models/blocks/js-block/JSBlock.tsx +223 -2
  92. package/src/flow/models/blocks/js-block/__tests__/JSBlockModel.test.tsx +150 -0
  93. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +35 -12
  94. package/src/flow/models/blocks/table/TableBlockModel.tsx +25 -14
  95. package/src/flow/models/blocks/table/TableColumnModel.tsx +6 -2
  96. package/src/flow/models/blocks/table/__tests__/TableActionsColumnModel.test.tsx +57 -1
  97. package/src/flow/models/blocks/table/__tests__/TableBlockModel.dragSort.test.ts +127 -0
  98. package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowSelection.test.tsx +1 -0
  99. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +51 -0
  100. package/src/flow/models/blocks/table/dragSort/dragSortHooks.tsx +28 -17
  101. package/src/flow/models/blocks/table/dragSort/dragSortSettings.ts +13 -6
  102. package/src/flow/models/blocks/table/dragSort/dragSortUtils.ts +15 -2
  103. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +45 -13
  104. package/src/flow/models/fields/TextareaFieldModel.tsx +42 -19
  105. package/src/flow/models/fields/__tests__/TextareaFieldModel.test.tsx +32 -1
  106. package/src/flow/models/topbar/TopbarActionModel.tsx +78 -3
  107. package/src/flow/resolveViewParamsToViewList.tsx +11 -3
  108. package/src/flow/routeTransientInputArgs.ts +27 -0
  109. package/src/flow/utils/__tests__/dateTimeFormat.test.ts +42 -0
  110. package/src/index.ts +2 -0
  111. package/src/nocobase-buildin-plugin/index.tsx +7 -1
  112. package/src/settings-center/SystemSettingsPage.tsx +1 -1
  113. package/src/utils/markdownSanitize.ts +88 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "2.2.0-beta.7",
3
+ "version": "2.2.0-beta.9",
4
4
  "license": "Apache-2.0",
5
5
  "main": "lib/index.js",
6
6
  "module": "es/index.mjs",
@@ -27,11 +27,11 @@
27
27
  "@formily/antd-v5": "1.2.3",
28
28
  "@formily/react": "^2.2.27",
29
29
  "@formily/shared": "^2.2.27",
30
- "@nocobase/evaluators": "2.2.0-beta.7",
31
- "@nocobase/flow-engine": "2.2.0-beta.7",
32
- "@nocobase/sdk": "2.2.0-beta.7",
33
- "@nocobase/shared": "2.2.0-beta.7",
34
- "@nocobase/utils": "2.2.0-beta.7",
30
+ "@nocobase/evaluators": "2.2.0-beta.9",
31
+ "@nocobase/flow-engine": "2.2.0-beta.9",
32
+ "@nocobase/sdk": "2.2.0-beta.9",
33
+ "@nocobase/shared": "2.2.0-beta.9",
34
+ "@nocobase/utils": "2.2.0-beta.9",
35
35
  "ahooks": "^3.7.2",
36
36
  "antd": "5.24.2",
37
37
  "antd-style": "3.7.1",
@@ -46,5 +46,5 @@
46
46
  "react-i18next": "^11.15.1",
47
47
  "react-router-dom": "^6.30.1"
48
48
  },
49
- "gitHead": "e6a3fa8963a73cd9ddfc1273d71b0012483e1ad8"
49
+ "gitHead": "60e3d7abbaa0c7cead76f71a4f3d5eedb6b8acdb"
50
50
  }
@@ -30,6 +30,7 @@ import AntdAppProvider from './theme/AntdAppProvider';
30
30
  import { GlobalThemeProvider } from './theme';
31
31
  import { AIManager } from './ai';
32
32
  import { AppError, AppMaintaining, AppMaintainingDialog, AppNotFound, AppSpin, BlankComponent } from './components';
33
+ import { EntryActionManager } from './entry-actions';
33
34
  import { SystemSettingsSource } from './flow/system-settings';
34
35
  import { LayoutManager } from './layout-manager/LayoutManager';
35
36
  import type { PluginClass, PluginManager, PluginType } from './PluginManager';
@@ -126,6 +127,7 @@ export abstract class BaseApplication<
126
127
  public pluginManager: TPluginManager;
127
128
  public pluginSettingsManager: TPluginSettingsManager;
128
129
  public layoutManager: LayoutManager<this>;
130
+ public entryActionManager = new EntryActionManager();
129
131
  public aiManager!: AIManager;
130
132
  public devDynamicImport?: DevDynamicImport;
131
133
  public requirejs!: RequireJS;
@@ -268,6 +268,16 @@ export class RouteRepository {
268
268
  return this.findRoute(this.listAccessible(), schemaUid);
269
269
  }
270
270
 
271
+ /**
272
+ * 通过路由 id 反查对应路由节点。
273
+ *
274
+ * @param routeId 桌面路由主键
275
+ * @returns 匹配到的路由节点
276
+ */
277
+ getRouteById(routeId: string | number): NocoBaseDesktopRoute | undefined {
278
+ return this.findRouteById(this.listAccessible(), routeId);
279
+ }
280
+
271
281
  protected getAPIClient(): APIClient {
272
282
  if (!this.ctx?.api) {
273
283
  throw new Error('[NocoBase] RouteRepository requires context.api.');
@@ -346,4 +356,19 @@ export class RouteRepository {
346
356
  }
347
357
  return undefined;
348
358
  }
359
+
360
+ private findRouteById(routes: NocoBaseDesktopRoute[], routeId: string | number): NocoBaseDesktopRoute | undefined {
361
+ for (const route of routes) {
362
+ if (route.id != null && String(route.id) === String(routeId)) {
363
+ return route;
364
+ }
365
+ if (route.children) {
366
+ const found = this.findRouteById(route.children, routeId);
367
+ if (found) {
368
+ return found;
369
+ }
370
+ }
371
+ }
372
+ return undefined;
373
+ }
349
374
  }
@@ -26,6 +26,7 @@ import type { BaseApplication } from './BaseApplication';
26
26
  import { BlankComponent, RouterContextCleaner } from './components';
27
27
  import { RouterBridge } from './components/RouterBridge';
28
28
  import { Router } from '@remix-run/router';
29
+ import { getV2EffectiveBasePath } from './authRedirect';
29
30
 
30
31
  export interface BrowserRouterOptions extends Omit<BrowserRouterProps, 'children'> {
31
32
  type?: 'browser';
@@ -55,6 +56,87 @@ export interface RouteType extends Omit<RouteObject, 'children' | 'Component'> {
55
56
  export type RenderComponentType = (Component: ComponentTypeAndString, props?: any) => React.ReactNode;
56
57
  export type RouterComponentType = React.FC<{ BaseLayout?: ComponentType }>;
57
58
 
59
+ const DEFAULT_ADMIN_ROUTE_PATH = '/admin';
60
+
61
+ type AdminRouteNavigationTarget = {
62
+ currentPathname?: string;
63
+ targetPathname: unknown;
64
+ basePath?: string;
65
+ adminRoutePath?: string;
66
+ replace?: boolean;
67
+ };
68
+
69
+ function trimPathSearchAndHash(pathname: string) {
70
+ return pathname.split(/[?#]/)[0];
71
+ }
72
+
73
+ function splitPathSearchAndHash(pathname: string) {
74
+ const match = pathname.match(/^([^?#]*)(.*)$/);
75
+ return {
76
+ pathname: match?.[1] || '',
77
+ suffix: match?.[2] || '',
78
+ };
79
+ }
80
+
81
+ function normalizeRootPath(pathname?: string) {
82
+ const trimmed = trimPathSearchAndHash(pathname || '').trim();
83
+ if (!trimmed || trimmed === '/') {
84
+ return '/';
85
+ }
86
+ return `/${trimmed.replace(/^\/+|\/+$/g, '')}`;
87
+ }
88
+
89
+ function isRootRelativePath(pathname: string) {
90
+ return pathname.startsWith('/') && !pathname.startsWith('//') && !pathname.startsWith('/\\');
91
+ }
92
+
93
+ function removeBasePath(pathname: string, basePath?: string) {
94
+ const normalizedPathname = normalizeRootPath(pathname);
95
+ const normalizedBasePath = normalizeRootPath(basePath);
96
+
97
+ if (normalizedBasePath === '/') {
98
+ return normalizedPathname;
99
+ }
100
+ if (normalizedPathname === normalizedBasePath) {
101
+ return '/';
102
+ }
103
+ if (normalizedPathname.startsWith(`${normalizedBasePath}/`)) {
104
+ return normalizeRootPath(normalizedPathname.slice(normalizedBasePath.length));
105
+ }
106
+ return normalizedPathname;
107
+ }
108
+
109
+ function removeAppSegment(pathname: string) {
110
+ return normalizeRootPath(pathname).replace(/^\/(?:apps|_app)\/[^/]+(?=\/|$)/, '') || '/';
111
+ }
112
+
113
+ function normalizePortalRoutePath(pathname: string, basePath?: string) {
114
+ return removeAppSegment(removeBasePath(pathname, basePath));
115
+ }
116
+
117
+ function isSameOrChildPath(pathname: string, basePath: string) {
118
+ const normalizedPathname = normalizeRootPath(pathname);
119
+ const normalizedBasePath = normalizeRootPath(basePath);
120
+ return normalizedPathname === normalizedBasePath || normalizedPathname.startsWith(`${normalizedBasePath}/`);
121
+ }
122
+
123
+ export function shouldOpenAdminRouteInNewWindow(options: AdminRouteNavigationTarget) {
124
+ if (options.replace || typeof options.targetPathname !== 'string') {
125
+ return false;
126
+ }
127
+
128
+ const targetPathname = options.targetPathname.trim();
129
+ if (!isRootRelativePath(targetPathname)) {
130
+ return false;
131
+ }
132
+
133
+ const adminRoutePath = normalizeRootPath(options.adminRoutePath || DEFAULT_ADMIN_ROUTE_PATH);
134
+ const currentRoutePath = normalizePortalRoutePath(options.currentPathname || '/', options.basePath);
135
+ const targetRoutePath = normalizePortalRoutePath(targetPathname, options.basePath);
136
+
137
+ return !isSameOrChildPath(currentRoutePath, adminRoutePath) && isSameOrChildPath(targetRoutePath, adminRoutePath);
138
+ }
139
+
58
140
  function removeBasename(pathname: string, basename?: string) {
59
141
  if (!basename || basename === '/') {
60
142
  return pathname;
@@ -72,6 +154,7 @@ function removeBasename(pathname: string, basename?: string) {
72
154
  export class RouterManager<TApp extends BaseApplication<any> = BaseApplication<any>> {
73
155
  protected routes: Record<string, RouteType> = {};
74
156
  protected options: RouterOptions;
157
+ private routerNavigate?: Router['navigate'];
75
158
  public app: TApp;
76
159
  public router!: Router;
77
160
  get basename() {
@@ -81,7 +164,7 @@ export class RouterManager<TApp extends BaseApplication<any> = BaseApplication<a
81
164
  return this.router.state;
82
165
  }
83
166
  get navigate() {
84
- return this.router.navigate;
167
+ return this.navigateWithPortalPolicy;
85
168
  }
86
169
 
87
170
  constructor(options: RouterOptions = {}, app: TApp) {
@@ -90,6 +173,62 @@ export class RouterManager<TApp extends BaseApplication<any> = BaseApplication<a
90
173
  this.routes = options.routes || {};
91
174
  }
92
175
 
176
+ private navigateWithPortalPolicy: Router['navigate'] = ((to, opts) => {
177
+ if (this.shouldOpenAdminRouteInNewWindow(to, opts)) {
178
+ window.open(this.getAdminRouteNavigationHref(to as string), '_blank', 'noopener,noreferrer');
179
+ return Promise.resolve();
180
+ }
181
+
182
+ const navigate = this.routerNavigate || this.router.navigate.bind(this.router);
183
+ return navigate(to, opts);
184
+ }) as Router['navigate'];
185
+
186
+ private shouldOpenAdminRouteInNewWindow(
187
+ to: Parameters<Router['navigate']>[0],
188
+ opts?: Parameters<Router['navigate']>[1],
189
+ ) {
190
+ if (this.options.type && this.options.type !== 'browser') {
191
+ return false;
192
+ }
193
+
194
+ return shouldOpenAdminRouteInNewWindow({
195
+ currentPathname:
196
+ typeof window !== 'undefined' ? window.location.pathname : this.router?.state?.location?.pathname,
197
+ targetPathname: to,
198
+ basePath: this.getRuntimeBasePath(),
199
+ adminRoutePath: this.getAdminRoutePath(),
200
+ replace: opts?.replace,
201
+ });
202
+ }
203
+
204
+ private getRuntimeBasePath() {
205
+ return getV2EffectiveBasePath(this.app);
206
+ }
207
+
208
+ private getAdminRoutePath() {
209
+ try {
210
+ return this.app.layoutManager?.getLayout?.('admin')?.routePath || DEFAULT_ADMIN_ROUTE_PATH;
211
+ } catch {
212
+ return DEFAULT_ADMIN_ROUTE_PATH;
213
+ }
214
+ }
215
+
216
+ private getAdminRouteNavigationHref(pathname: string) {
217
+ const { pathname: rawPathname, suffix } = splitPathSearchAndHash(pathname.trim());
218
+ const normalizedPathname = normalizeRootPath(rawPathname);
219
+ const runtimeBasePath = this.getRuntimeBasePath();
220
+ const normalizedBasePath = normalizeRootPath(runtimeBasePath);
221
+
222
+ if (
223
+ normalizedBasePath !== '/' &&
224
+ (normalizedPathname === normalizedBasePath || normalizedPathname.startsWith(`${normalizedBasePath}/`))
225
+ ) {
226
+ return `${normalizedPathname}${suffix}`;
227
+ }
228
+
229
+ return `${this.app.getHref(normalizePortalRoutePath(normalizedPathname, runtimeBasePath))}${suffix}`;
230
+ }
231
+
93
232
  protected resolveLoadedComponent(moduleOrComponent: ComponentLoaderResult): ComponentTypeAndString | undefined {
94
233
  if (!moduleOrComponent) {
95
234
  return undefined;
@@ -257,6 +396,8 @@ export class RouterManager<TApp extends BaseApplication<any> = BaseApplication<a
257
396
  ],
258
397
  opts,
259
398
  );
399
+ this.routerNavigate = this.router.navigate.bind(this.router) as Router['navigate'];
400
+ this.router.navigate = this.navigateWithPortalPolicy;
260
401
 
261
402
  const RenderRouter: RouterComponentType = ({ BaseLayout = BlankComponent }) => {
262
403
  return (
@@ -140,6 +140,29 @@ describe('RouteRepository', () => {
140
140
  expect(repository.getRouteBySchemaUid('custom-page')).toBeUndefined();
141
141
  });
142
142
 
143
+ it('should find nested routes by id', () => {
144
+ const { repository } = createRouteRepository();
145
+
146
+ repository.setRoutes([
147
+ {
148
+ id: 1,
149
+ schemaUid: 'group-1',
150
+ children: [
151
+ {
152
+ id: 11,
153
+ schemaUid: 'page-1',
154
+ },
155
+ ],
156
+ },
157
+ ] as NocoBaseDesktopRoute[]);
158
+
159
+ expect(repository.getRouteById('11')).toMatchObject({
160
+ id: 11,
161
+ schemaUid: 'page-1',
162
+ });
163
+ expect(repository.getRouteById('missing')).toBeUndefined();
164
+ });
165
+
143
166
  it('should notify only subscribers for the changed layout cache', () => {
144
167
  const { repository } = createRouteRepository();
145
168
  const adminEvents: string[][] = [];
@@ -0,0 +1,125 @@
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 type { BaseApplication } from '../BaseApplication';
11
+ import { RouterManager, shouldOpenAdminRouteInNewWindow } from '../RouterManager';
12
+
13
+ describe('RouterManager', () => {
14
+ describe('shouldOpenAdminRouteInNewWindow', () => {
15
+ it('opens admin route in a new window from a non-admin portal', () => {
16
+ expect(
17
+ shouldOpenAdminRouteInNewWindow({
18
+ currentPathname: '/v/test',
19
+ targetPathname: '/admin/settings',
20
+ basePath: '/v',
21
+ adminRoutePath: '/admin',
22
+ }),
23
+ ).toBe(true);
24
+ });
25
+
26
+ it('supports target pathname with the v2 base path', () => {
27
+ expect(
28
+ shouldOpenAdminRouteInNewWindow({
29
+ currentPathname: '/nocobase/v/test',
30
+ targetPathname: '/nocobase/v/admin/settings',
31
+ basePath: '/nocobase/v',
32
+ adminRoutePath: '/admin',
33
+ }),
34
+ ).toBe(true);
35
+ });
36
+
37
+ it('supports custom portal routes in sub-apps', () => {
38
+ expect(
39
+ shouldOpenAdminRouteInNewWindow({
40
+ currentPathname: '/v/apps/a_9xlild35jir/crm-amd/ekeisumx1zu',
41
+ targetPathname: '/v/apps/a_9xlild35jir/admin/settings',
42
+ basePath: '/v',
43
+ adminRoutePath: '/admin',
44
+ }),
45
+ ).toBe(true);
46
+ });
47
+
48
+ it('keeps admin route navigation in the current window when already in sub-app admin', () => {
49
+ expect(
50
+ shouldOpenAdminRouteInNewWindow({
51
+ currentPathname: '/v/apps/a_9xlild35jir/admin',
52
+ targetPathname: '/v/apps/a_9xlild35jir/admin/settings',
53
+ basePath: '/v',
54
+ adminRoutePath: '/admin',
55
+ }),
56
+ ).toBe(false);
57
+ });
58
+
59
+ it('keeps admin route navigation in the current window when already in admin', () => {
60
+ expect(
61
+ shouldOpenAdminRouteInNewWindow({
62
+ currentPathname: '/v/admin',
63
+ targetPathname: '/admin/settings',
64
+ basePath: '/v',
65
+ adminRoutePath: '/admin',
66
+ }),
67
+ ).toBe(false);
68
+ });
69
+
70
+ it('keeps non-admin portal navigation in the current window', () => {
71
+ expect(
72
+ shouldOpenAdminRouteInNewWindow({
73
+ currentPathname: '/v/test',
74
+ targetPathname: '/test/page',
75
+ basePath: '/v',
76
+ adminRoutePath: '/admin',
77
+ }),
78
+ ).toBe(false);
79
+ });
80
+
81
+ it('does not intercept replace navigation or relative paths', () => {
82
+ expect(
83
+ shouldOpenAdminRouteInNewWindow({
84
+ currentPathname: '/v/test',
85
+ targetPathname: '/admin/settings',
86
+ basePath: '/v',
87
+ adminRoutePath: '/admin',
88
+ replace: true,
89
+ }),
90
+ ).toBe(false);
91
+ expect(
92
+ shouldOpenAdminRouteInNewWindow({
93
+ currentPathname: '/v/test',
94
+ targetPathname: 'admin/settings',
95
+ basePath: '/v',
96
+ adminRoutePath: '/admin',
97
+ }),
98
+ ).toBe(false);
99
+ });
100
+ });
101
+
102
+ it('opens admin route in a new window through the underlying router navigate from a sub-app portal', async () => {
103
+ const app = {
104
+ name: 'a_9xlild35jir',
105
+ getPublicPath: () => '/v/',
106
+ getHref: (pathname: string) => `/v/apps/a_9xlild35jir/${pathname.replace(/^\/+/, '')}`,
107
+ layoutManager: {
108
+ getLayout: () => ({ routePath: '/admin' }),
109
+ },
110
+ renderComponent: () => null,
111
+ } as unknown as BaseApplication<unknown>;
112
+ const manager = new RouterManager({ type: 'browser' }, app);
113
+ const open = vi.spyOn(window, 'open').mockImplementation(() => null);
114
+
115
+ window.history.pushState({}, '', '/v/apps/a_9xlild35jir/crm-amd/ekeisumx1zu');
116
+ manager.getRouterComponent();
117
+ await manager.router.navigate('/admin/settings/version-control/list');
118
+
119
+ expect(open).toHaveBeenCalledWith(
120
+ '/v/apps/a_9xlild35jir/admin/settings/version-control/list',
121
+ '_blank',
122
+ 'noopener,noreferrer',
123
+ );
124
+ });
125
+ });
@@ -10,6 +10,7 @@
10
10
  import {
11
11
  buildV2SigninHref,
12
12
  getCurrentV2RedirectPath,
13
+ normalizeV2RedirectPath,
13
14
  redirectToV2Signin,
14
15
  resolveV2SigninRedirect,
15
16
  } from '../authRedirect';
@@ -133,6 +134,22 @@ describe('auth redirect helpers', () => {
133
134
  });
134
135
 
135
136
  describe('v2 sub-app context (router basename contains /apps/<id>/)', () => {
137
+ it('should normalize signin redirect fallback under the current sub-app basename', () => {
138
+ const app = {
139
+ getPublicPath: () => '/v/',
140
+ router: {
141
+ getBasename: () => '/v/apps/test-app/',
142
+ },
143
+ } as any;
144
+
145
+ expect(normalizeV2RedirectPath(app, '')).toBe('/v/apps/test-app/admin/');
146
+ expect(normalizeV2RedirectPath(app, '/admin/?tab=overview#panel')).toBe(
147
+ '/v/apps/test-app/admin/?tab=overview#panel',
148
+ );
149
+ expect(normalizeV2RedirectPath(app, '/v/apps/test-app/admin/')).toBe('/v/apps/test-app/admin/');
150
+ expect(normalizeV2RedirectPath(app, '/v/admin/')).toBe('/v/apps/test-app/admin/');
151
+ });
152
+
136
153
  it('should preserve sub-app segment when building current redirect path under simple public path', () => {
137
154
  const app = {
138
155
  getPublicPath: () => '/v2/',
@@ -204,6 +204,44 @@ function getDefaultV2AdminRedirectPath(app: AppLike) {
204
204
  return joinRootRelativePath(getV2EffectiveBasePath(app), '/admin');
205
205
  }
206
206
 
207
+ function isSafeRootRelativePath(value?: string | null) {
208
+ return !!value && value.startsWith('/') && !value.startsWith('//') && !value.startsWith('/\\');
209
+ }
210
+
211
+ function preserveTrailingSlash(originalPathname: string, value: string) {
212
+ if (originalPathname !== '/' && originalPathname.endsWith('/') && !value.endsWith('/')) {
213
+ return `${value}/`;
214
+ }
215
+ return value;
216
+ }
217
+
218
+ export function normalizeV2RedirectPath(app: AppLike, target?: string | null, fallbackPath = '/admin/') {
219
+ // In a v2 sub-app, publicPath can be `/v/` while the active router
220
+ // basename is `/v/apps/a/`. Redirects must resolve under the basename.
221
+ const basePath = trimTrailingSlashes(getV2EffectiveBasePath(app)) || '/';
222
+ const fallbackTarget = isSafeRootRelativePath(fallbackPath) ? fallbackPath : '/admin/';
223
+ const rawTarget = isSafeRootRelativePath(target) ? target : fallbackTarget;
224
+ let { pathname, search, hash } = splitPathLike(rawTarget);
225
+ let normalizedPathname = normalizePathname(pathname);
226
+
227
+ // Already under the current v2 runtime, e.g. `/v/apps/a/admin/`.
228
+ if (basePath === '/' || normalizedPathname === basePath || normalizedPathname.startsWith(`${basePath}/`)) {
229
+ return `${preserveTrailingSlash(pathname, normalizedPathname)}${normalizeSearch(search)}${normalizeHash(hash)}`;
230
+ }
231
+
232
+ const publicPath = trimTrailingSlashes(getV2PublicPath(app)) || '/';
233
+ // Under v2 publicPath but outside the current basename, e.g. `/v/admin/`
234
+ // inside `/v/apps/a/`; use fallback so it lands on `/v/apps/a/admin/`.
235
+ if (publicPath !== '/' && (normalizedPathname === publicPath || normalizedPathname.startsWith(`${publicPath}/`))) {
236
+ ({ pathname, search, hash } = splitPathLike(fallbackTarget));
237
+ normalizedPathname = normalizePathname(pathname);
238
+ }
239
+
240
+ // Basename-relative target, e.g. `/admin/`, becomes `/v/apps/a/admin/`.
241
+ const joinedPathname = preserveTrailingSlash(pathname, joinRootRelativePath(basePath, normalizedPathname));
242
+ return `${joinedPathname}${normalizeSearch(search)}${normalizeHash(hash)}`;
243
+ }
244
+
207
245
  /**
208
246
  * 将当前 v2 页面地址转换为根相对 redirect 路径。
209
247
  *
@@ -530,7 +530,7 @@ export function reverseFieldConfigureItems(): FieldConfigureItem[] {
530
530
  component: 'Input',
531
531
  required: true,
532
532
  description:
533
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
533
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
534
534
  hidden: ({ context, values }) => !context.showReverseFieldConfig && !get(values, 'autoCreateReverseField'),
535
535
  disabled: ({ context }) => !context.showReverseFieldConfig,
536
536
  },
@@ -48,7 +48,7 @@ export class IdFieldInterface extends CollectionFieldInterface {
48
48
  'x-decorator': 'FormItem',
49
49
  'x-component': 'Input',
50
50
  description:
51
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
51
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
52
52
  },
53
53
  };
54
54
  filterable = {
@@ -158,7 +158,7 @@ export class M2MFieldInterface extends CollectionFieldInterface {
158
158
  required: true,
159
159
  default: '{{ useNewId("f_") }}',
160
160
  description:
161
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
161
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
162
162
  'x-decorator': 'FormItem',
163
163
  'x-component': 'ForeignKey',
164
164
  'x-validator': 'uid',
@@ -192,7 +192,7 @@ export class M2MFieldInterface extends CollectionFieldInterface {
192
192
  required: true,
193
193
  default: '{{ useNewId("f_") }}',
194
194
  description:
195
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
195
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
196
196
  'x-decorator': 'FormItem',
197
197
  'x-component': 'ForeignKey',
198
198
  'x-validator': 'uid',
@@ -69,7 +69,7 @@ export class M2OFieldInterface extends CollectionFieldInterface {
69
69
  'x-decorator': 'FormItem',
70
70
  'x-component': 'Input',
71
71
  description:
72
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
72
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
73
73
  },
74
74
  type: relationshipType,
75
75
  grid: {
@@ -123,7 +123,7 @@ export class M2OFieldInterface extends CollectionFieldInterface {
123
123
  required: true,
124
124
  default: '{{ useNewId("f_") }}',
125
125
  description:
126
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
126
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
127
127
  'x-decorator': 'FormItem',
128
128
  'x-component': 'ForeignKey',
129
129
  'x-validator': 'uid',
@@ -42,7 +42,7 @@ export class NanoidFieldInterface extends CollectionFieldInterface {
42
42
  'x-component': 'Input',
43
43
  'x-disabled': '{{ !createOnly }}',
44
44
  description:
45
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
45
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
46
46
  },
47
47
  customAlphabet: {
48
48
  type: 'string',
@@ -68,7 +68,7 @@ export class O2MFieldInterface extends CollectionFieldInterface {
68
68
  'x-decorator': 'FormItem',
69
69
  'x-component': 'Input',
70
70
  description:
71
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
71
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
72
72
  },
73
73
  type: relationshipType,
74
74
  grid: {
@@ -135,7 +135,7 @@ export class O2MFieldInterface extends CollectionFieldInterface {
135
135
  required: true,
136
136
  default: '{{ useNewId("f_") }}',
137
137
  description:
138
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
138
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
139
139
  'x-decorator': 'FormItem',
140
140
  'x-component': 'ForeignKey',
141
141
  'x-validator': 'uid',
@@ -55,7 +55,7 @@ export class OBOFieldInterface extends CollectionFieldInterface {
55
55
  'x-decorator': 'FormItem',
56
56
  'x-component': 'Input',
57
57
  description:
58
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
58
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
59
59
  },
60
60
  type: relationshipType,
61
61
  grid: {
@@ -109,7 +109,7 @@ export class OBOFieldInterface extends CollectionFieldInterface {
109
109
  required: true,
110
110
  default: '{{ useNewId("f_") }}',
111
111
  description:
112
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
112
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
113
113
  'x-decorator': 'FormItem',
114
114
  'x-component': 'ForeignKey',
115
115
  'x-validator': 'uid',
@@ -55,7 +55,7 @@ export class OHOFieldInterface extends CollectionFieldInterface {
55
55
  'x-decorator': 'FormItem',
56
56
  'x-component': 'Input',
57
57
  description:
58
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
58
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
59
59
  },
60
60
  type: relationshipType,
61
61
  grid: {
@@ -123,7 +123,7 @@ export class OHOFieldInterface extends CollectionFieldInterface {
123
123
  required: true,
124
124
  default: '{{ useNewId("f_") }}',
125
125
  description:
126
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
126
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
127
127
  'x-decorator': 'FormItem',
128
128
  'x-component': 'ForeignKey',
129
129
  'x-validator': 'uid',
@@ -141,7 +141,7 @@ export const reverseFieldProperties: Record<string, ISchema> = {
141
141
  'x-validator': 'uid',
142
142
  'x-disabled': '{{ !showReverseFieldConfig }}',
143
143
  description:
144
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
144
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
145
145
  },
146
146
  },
147
147
  },
@@ -399,7 +399,7 @@ export const defaultProps = {
399
399
  'x-component': 'Input',
400
400
  'x-validator': 'uid',
401
401
  description:
402
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
402
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
403
403
  },
404
404
  };
405
405
 
@@ -46,7 +46,7 @@ export class UUIDFieldInterface extends CollectionFieldInterface {
46
46
  'x-component': 'Input',
47
47
  'x-disabled': '{{ !createOnly }}',
48
48
  description:
49
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
49
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
50
50
  },
51
51
  autoFill,
52
52
  layout: {