@nocobase/client-v2 2.3.0-beta.6 → 2.4.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 (90) hide show
  1. package/es/acl/ACLProvider.d.ts +10 -0
  2. package/es/acl/aclCheckReadiness.d.ts +11 -0
  3. package/es/authRedirect.d.ts +11 -2
  4. package/es/components/AttachmentUpload.d.ts +51 -0
  5. package/es/components/index.d.ts +1 -0
  6. package/es/flow/admin-shell/admin-layout/NocoBaseLogo.d.ts +10 -0
  7. package/es/index.d.ts +4 -1
  8. package/es/index.mjs +129 -118
  9. package/es/nocobase-buildin-plugin/currentUserAuthStatus.d.ts +11 -0
  10. package/es/nocobase-buildin-plugin/index.d.ts +10 -0
  11. package/es/settings-app/SettingsApplication.d.ts +18 -0
  12. package/es/settings-app/SettingsBrand.d.ts +16 -0
  13. package/es/settings-app/SettingsBuildInPlugin.d.ts +9 -0
  14. package/es/settings-app/SettingsDocumentRedirect.d.ts +10 -0
  15. package/es/settings-app/SettingsGroupNav.d.ts +18 -0
  16. package/es/settings-app/SettingsPluginSettingsManager.d.ts +14 -0
  17. package/es/settings-app/SettingsRouterManager.d.ts +15 -0
  18. package/es/settings-app/SettingsSearch.d.ts +17 -0
  19. package/es/settings-app/SettingsShell.d.ts +10 -0
  20. package/es/settings-app/isSettingsApp.d.ts +18 -0
  21. package/es/settings-app/runtimePaths.d.ts +18 -0
  22. package/es/settings-app/settingsDocumentPath.d.ts +14 -0
  23. package/es/settings-app/settingsTheme.d.ts +87 -0
  24. package/es/settings-app/useSettingsThemeConfig.d.ts +22 -0
  25. package/es/settings-center/AdminSettingsLayout.d.ts +2 -2
  26. package/es/settings-center/groups.d.ts +66 -0
  27. package/es/settings-center/useSettingsGroups.d.ts +36 -0
  28. package/es/settings-center/useSettingsSearch.d.ts +35 -0
  29. package/es/settings-center/utils.d.ts +24 -0
  30. package/es/theme/index.d.ts +1 -0
  31. package/es/theme/itemActive.d.ts +24 -0
  32. package/es/theme/type.d.ts +9 -0
  33. package/lib/index.js +157 -146
  34. package/package.json +7 -7
  35. package/src/__tests__/app.test.tsx +122 -3
  36. package/src/__tests__/authRedirect.test.ts +154 -0
  37. package/src/__tests__/browserChecker.test.ts +172 -6
  38. package/src/__tests__/mockSettingsApplication.ts +29 -0
  39. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +81 -8
  40. package/src/__tests__/plugin-manager.test.tsx +7 -6
  41. package/src/__tests__/settings-application.test.ts +164 -0
  42. package/src/__tests__/settings-auth-routes.test.ts +71 -0
  43. package/src/__tests__/settings-center.test.tsx +168 -130
  44. package/src/__tests__/settings-layout-root.test.tsx +600 -0
  45. package/src/__tests__/settings-runtime-paths.test.ts +99 -0
  46. package/src/__tests__/settings-search-shortcut.test.tsx +321 -0
  47. package/src/__tests__/settings-shell.test.tsx +261 -0
  48. package/src/acl/ACLProvider.tsx +53 -3
  49. package/src/acl/aclCheckReadiness.ts +44 -0
  50. package/src/authRedirect.ts +85 -8
  51. package/src/components/AppComponents.tsx +16 -7
  52. package/src/components/AttachmentUpload.tsx +275 -0
  53. package/src/components/index.ts +1 -0
  54. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -56
  55. package/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx +77 -0
  56. package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +37 -14
  57. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +77 -13
  58. package/src/flow/internal/components/Markdown/DisplayMarkdown.tsx +14 -9
  59. package/src/flow/internal/components/Markdown/__tests__/DisplayMarkdown.test.tsx +46 -0
  60. package/src/flow/models/fields/DisplayHtmlFieldModel.tsx +2 -1
  61. package/src/flow/models/fields/RichTextFieldModel/__tests__/RichTextFieldModel.test.tsx +74 -0
  62. package/src/flow/models/fields/RichTextFieldModel/index.tsx +27 -6
  63. package/src/flow/models/topbar/TopbarActionModel.tsx +52 -180
  64. package/src/index.ts +11 -1
  65. package/src/nocobase-buildin-plugin/currentUserAuthStatus.ts +58 -0
  66. package/src/nocobase-buildin-plugin/index.tsx +112 -71
  67. package/src/settings-app/SettingsApplication.ts +31 -0
  68. package/src/settings-app/SettingsBrand.tsx +108 -0
  69. package/src/settings-app/SettingsBuildInPlugin.ts +10 -0
  70. package/src/settings-app/SettingsDocumentRedirect.tsx +26 -0
  71. package/src/settings-app/SettingsGroupNav.tsx +124 -0
  72. package/src/settings-app/SettingsPluginSettingsManager.ts +38 -0
  73. package/src/settings-app/SettingsRouterManager.ts +92 -0
  74. package/src/settings-app/SettingsSearch.tsx +343 -0
  75. package/src/settings-app/SettingsShell.tsx +184 -0
  76. package/src/settings-app/isSettingsApp.ts +21 -0
  77. package/src/settings-app/runtimePaths.ts +103 -0
  78. package/src/settings-app/settingsDocumentPath.ts +66 -0
  79. package/src/settings-app/settingsTheme.ts +369 -0
  80. package/src/settings-app/useSettingsThemeConfig.ts +100 -0
  81. package/src/settings-center/AdminSettingsLayout.tsx +154 -169
  82. package/src/settings-center/SystemSettingsPage.tsx +4 -170
  83. package/src/settings-center/__tests__/groups.test.ts +25 -0
  84. package/src/settings-center/groups.ts +108 -0
  85. package/src/settings-center/useSettingsGroups.ts +105 -0
  86. package/src/settings-center/useSettingsSearch.ts +173 -0
  87. package/src/settings-center/utils.tsx +50 -1
  88. package/src/theme/index.tsx +1 -0
  89. package/src/theme/itemActive.ts +31 -0
  90. package/src/theme/type.ts +10 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "2.3.0-beta.6",
3
+ "version": "2.4.0-alpha.2",
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.3.0-beta.6",
31
- "@nocobase/flow-engine": "2.3.0-beta.6",
32
- "@nocobase/sdk": "2.3.0-beta.6",
33
- "@nocobase/shared": "2.3.0-beta.6",
34
- "@nocobase/utils": "2.3.0-beta.6",
30
+ "@nocobase/evaluators": "2.4.0-alpha.2",
31
+ "@nocobase/flow-engine": "2.4.0-alpha.2",
32
+ "@nocobase/sdk": "2.4.0-alpha.2",
33
+ "@nocobase/shared": "2.4.0-alpha.2",
34
+ "@nocobase/utils": "2.4.0-alpha.2",
35
35
  "ahooks": "^3.7.2",
36
36
  "antd": "5.24.2",
37
37
  "antd-style": "3.7.1",
@@ -48,5 +48,5 @@
48
48
  "react-i18next": "^11.15.1",
49
49
  "react-router-dom": "^6.30.1"
50
50
  },
51
- "gitHead": "45b23791f5245d1417047b122d1a76764aaf5473"
51
+ "gitHead": "706bb81047a05f39f2ddce781ebcafeea4ec9cd5"
52
52
  }
@@ -379,7 +379,11 @@ describe('app', () => {
379
379
  expect(screen.getByText('maintaining dialog message')).toBeInTheDocument();
380
380
  });
381
381
 
382
- it('should keep current content behind maintained dialog state', async () => {
382
+ it.each([
383
+ ['pm.enable', 'Enabling plugin'],
384
+ ['pm.disable', 'Disabling plugin'],
385
+ ['future.command', 'future.command'],
386
+ ])('should keep current content behind the default dialog while app commanding: %s', async (commandName, title) => {
383
387
  const CurrentPage = () => {
384
388
  const [count, setCount] = React.useState(0);
385
389
  return <button onClick={() => setCount((value) => value + 1)}>Current page count: {count}</button>;
@@ -398,12 +402,127 @@ describe('app', () => {
398
402
  act(() => {
399
403
  app.maintained = true;
400
404
  app.maintaining = true;
401
- app.error = { code: 'APP_COMMANDING', command: { name: 'pm.enable' }, message: 'starting sub applications...' };
405
+ app.error = { code: 'APP_COMMANDING', command: { name: commandName }, message: 'starting sub applications...' };
402
406
  });
403
407
 
404
408
  expect(screen.getByRole('button', { name: 'Current page count: 1' })).toBeInTheDocument();
405
409
  expect(screen.getByRole('dialog')).toBeInTheDocument();
406
- expect(screen.getByText('Enabling plugin')).toBeInTheDocument();
410
+ expect(screen.getByText(title)).toBeInTheDocument();
411
+ });
412
+
413
+ it('should keep current content without a dialog while app upgrading', async () => {
414
+ const CurrentPage = () => <div>Current page</div>;
415
+
416
+ class PluginHelloClient extends Plugin {
417
+ async load() {
418
+ this.router.add('root', { path: '/', Component: CurrentPage });
419
+ }
420
+ }
421
+
422
+ const app = createMockClient({ plugins: [PluginHelloClient] });
423
+ await renderApp(app);
424
+
425
+ act(() => {
426
+ app.maintained = true;
427
+ app.maintaining = true;
428
+ app.error = Object.assign(new Error('Loading data sources...'), {
429
+ code: 'APP_COMMANDING',
430
+ command: { name: 'upgrade' },
431
+ });
432
+ });
433
+
434
+ expect(screen.getByText('Current page')).toBeInTheDocument();
435
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
436
+ });
437
+
438
+ it.each([
439
+ ['upgrade', 'App upgrading'],
440
+ ['pm.enable', 'Enabling plugin'],
441
+ ['pm.disable', 'Disabling plugin'],
442
+ ['future.command', 'future.command'],
443
+ ])('should not show Try again for an app commanding error state: %s', async (commandName, title) => {
444
+ class PluginHelloClient extends Plugin {}
445
+ const app = createMockClient({ plugins: [PluginHelloClient] });
446
+ app.error = Object.assign(new Error('Loading data sources...'), {
447
+ code: 'APP_COMMANDING',
448
+ command: { name: commandName },
449
+ });
450
+
451
+ await renderApp(app);
452
+
453
+ expect(screen.getByText(title)).toBeInTheDocument();
454
+ expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument();
455
+ });
456
+
457
+ it('should clear upgrade feedback after an existing page error', async () => {
458
+ const BrokenPage = () => {
459
+ throw new Error('page error');
460
+ };
461
+
462
+ class PluginHelloClient extends Plugin {
463
+ async load() {
464
+ this.router.add('root', { path: '/', Component: BrokenPage });
465
+ }
466
+ }
467
+
468
+ const app = createMockClient({
469
+ plugins: [PluginHelloClient],
470
+ ws: { url: 'ws://localhost:3000/ws' },
471
+ });
472
+ app.maintained = true;
473
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
474
+ try {
475
+ await renderApp(app);
476
+ expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument();
477
+
478
+ act(() => {
479
+ app.ws.emit('message', {
480
+ type: 'maintaining',
481
+ payload: {
482
+ code: 'APP_COMMANDING',
483
+ command: { name: 'upgrade' },
484
+ message: 'Loading data sources...',
485
+ },
486
+ });
487
+ });
488
+
489
+ expect(screen.getByText('App upgrading')).toBeInTheDocument();
490
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
491
+ expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument();
492
+ } finally {
493
+ errorSpy.mockRestore();
494
+ }
495
+ });
496
+
497
+ it('should keep a custom maintaining dialog while app upgrading', async () => {
498
+ class PluginHelloClient extends Plugin {}
499
+ const app = createMockClient({ plugins: [PluginHelloClient] });
500
+ app.addComponents({
501
+ CustomUpgradeStatus: () => <div>Custom upgrade status</div>,
502
+ });
503
+ app.maintained = true;
504
+ app.maintaining = true;
505
+ app.error = Object.assign(new Error('Loading data sources...'), {
506
+ code: 'APP_COMMANDING',
507
+ command: {
508
+ name: 'upgrade',
509
+ components: { maintainingDialog: 'CustomUpgradeStatus' },
510
+ },
511
+ });
512
+
513
+ await renderApp(app);
514
+
515
+ expect(screen.getByText('Custom upgrade status')).toBeInTheDocument();
516
+ });
517
+
518
+ it('should keep Try again for recoverable app errors', async () => {
519
+ class PluginHelloClient extends Plugin {}
520
+ const app = createMockClient({ plugins: [PluginHelloClient] });
521
+ app.error = Object.assign(new Error('load error'), { code: 'LOAD_ERROR' });
522
+
523
+ await renderApp(app);
524
+
525
+ expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument();
407
526
  });
408
527
 
409
528
  it('should handle long loading state gracefully', async () => {
@@ -17,12 +17,18 @@ import {
17
17
 
18
18
  describe('auth redirect helpers', () => {
19
19
  const originalLocation = globalThis.window.location;
20
+ const originalModernClientPrefix = window.__nocobase_modern_client_prefix__;
20
21
 
21
22
  afterEach(() => {
22
23
  Object.defineProperty(globalThis.window, 'location', {
23
24
  configurable: true,
24
25
  value: originalLocation,
25
26
  });
27
+ if (originalModernClientPrefix === undefined) {
28
+ delete window.__nocobase_modern_client_prefix__;
29
+ } else {
30
+ window.__nocobase_modern_client_prefix__ = originalModernClientPrefix;
31
+ }
26
32
  vi.restoreAllMocks();
27
33
  });
28
34
 
@@ -84,6 +90,107 @@ describe('auth redirect helpers', () => {
84
90
  expect(buildV2SigninHref(app, '/v2/admin/7vu4c2sdk6h')).toBe('/v2/signin?redirect=%2Fv2%2Fadmin%2F7vu4c2sdk6h');
85
91
  });
86
92
 
93
+ it('should keep validated standalone settings redirects outside the v2 basename', () => {
94
+ const app = {
95
+ getPublicPath: () => '/nocobase/v2/',
96
+ router: {
97
+ getBasename: () => '/nocobase/v2',
98
+ },
99
+ } as any;
100
+
101
+ expect(normalizeV2RedirectPath(app, '/nocobase/settings/workflow?tab=list#recent')).toBe(
102
+ '/nocobase/settings/workflow?tab=list#recent',
103
+ );
104
+ });
105
+
106
+ it('should build the standalone settings signin URL for a standalone settings app', () => {
107
+ const app = {
108
+ name: 'main',
109
+ getPublicPath: () => '/nocobase/',
110
+ router: {
111
+ getBasename: () => '/nocobase',
112
+ },
113
+ pluginSettingsManager: {
114
+ getRouteName: () => 'settings.',
115
+ getRoutePath: () => '/settings/',
116
+ },
117
+ } satisfies Parameters<typeof buildV2SigninHref>[0];
118
+ expect(buildV2SigninHref(app, '/nocobase/settings/workflow?tab=list#recent')).toBe(
119
+ '/nocobase/settings/signin?redirect=%2Fnocobase%2Fsettings%2Fworkflow%3Ftab%3Dlist%23recent',
120
+ );
121
+ });
122
+
123
+ it('should keep an apps segment inside the main public path out of the Settings application scope', () => {
124
+ const app = {
125
+ name: 'main',
126
+ getPublicPath: () => '/tenant/apps/root/',
127
+ router: {
128
+ getBasename: () => '/tenant/apps/root/',
129
+ },
130
+ pluginSettingsManager: {
131
+ getRouteName: () => 'settings.',
132
+ getRoutePath: () => '/settings/',
133
+ },
134
+ } satisfies Parameters<typeof buildV2SigninHref>[0];
135
+
136
+ expect(buildV2SigninHref(app, '/tenant/apps/root/settings/workflow')).toBe(
137
+ '/tenant/apps/root/settings/signin?redirect=%2Ftenant%2Fapps%2Froot%2Fsettings%2Fworkflow',
138
+ );
139
+ });
140
+
141
+ it.each([
142
+ ['/nocobase/settings/apps/test-app/', '/nocobase/settings/apps/test-app/signin'],
143
+ ['/nocobase/settings/_app/test-app/', '/nocobase/settings/_app/test-app/signin'],
144
+ ])('should keep the standalone settings signin in the current sub-app scope: %s', (basename, signinPath) => {
145
+ const app = {
146
+ name: 'test-app',
147
+ getPublicPath: () => '/nocobase/',
148
+ router: {
149
+ getBasename: () => basename,
150
+ },
151
+ pluginSettingsManager: {
152
+ getRouteName: () => 'settings.',
153
+ getRoutePath: () => '/',
154
+ },
155
+ } as any;
156
+
157
+ expect(buildV2SigninHref(app, `${basename}workflow`)).toBe(
158
+ `${signinPath}?redirect=${encodeURIComponent(`${basename}workflow`)}`,
159
+ );
160
+ });
161
+
162
+ it('should accept the current standalone settings signin URL only', () => {
163
+ Object.defineProperty(globalThis.window, 'location', {
164
+ configurable: true,
165
+ value: {
166
+ ...originalLocation,
167
+ origin: 'http://localhost:20000',
168
+ },
169
+ });
170
+ const app = {
171
+ name: 'test-app',
172
+ getPublicPath: () => '/nocobase/',
173
+ router: {
174
+ getBasename: () => '/nocobase/settings/apps/test-app/',
175
+ },
176
+ pluginSettingsManager: {
177
+ getRouteName: () => 'settings.',
178
+ getRoutePath: () => '/',
179
+ },
180
+ } as any;
181
+
182
+ expect(
183
+ resolveV2SigninRedirect(
184
+ '/nocobase/settings/apps/test-app/signin?redirect=%2Fnocobase%2Fsettings%2Fapps%2Ftest-app',
185
+ app,
186
+ ),
187
+ ).toBe(
188
+ 'http://localhost:20000/nocobase/settings/apps/test-app/signin?redirect=%2Fnocobase%2Fsettings%2Fapps%2Ftest-app',
189
+ );
190
+ expect(resolveV2SigninRedirect('/nocobase/v/apps/test-app/signin', app)).toBeNull();
191
+ expect(resolveV2SigninRedirect('/nocobase/settings/apps/other-app/signin', app)).toBeNull();
192
+ });
193
+
87
194
  it('should redirect with window.location.replace by default', () => {
88
195
  const replace = vi.fn();
89
196
  Object.defineProperty(globalThis.window, 'location', {
@@ -134,6 +241,36 @@ describe('auth redirect helpers', () => {
134
241
  });
135
242
 
136
243
  describe('v2 sub-app context (router basename contains /apps/<id>/)', () => {
244
+ it('should keep only the current sub-app standalone settings redirect', () => {
245
+ const app = {
246
+ getPublicPath: () => '/nocobase/v2/',
247
+ router: {
248
+ getBasename: () => '/nocobase/v2/apps/test-app/',
249
+ },
250
+ } as any;
251
+
252
+ expect(normalizeV2RedirectPath(app, '/nocobase/settings/apps/test-app/workflow')).toBe(
253
+ '/nocobase/settings/apps/test-app/workflow',
254
+ );
255
+ expect(normalizeV2RedirectPath(app, '/nocobase/settings/apps/other-app/workflow')).toBe(
256
+ '/nocobase/v2/apps/test-app/admin/',
257
+ );
258
+ });
259
+
260
+ it.each([
261
+ '/nocobase/settings/apps/test-app/../../other-app/settings',
262
+ '/nocobase/settings/apps/test-app/%2e%2e/%2E%2e/other-app/settings',
263
+ ])('should reject a standalone settings redirect that resolves outside the current sub-app: %s', (target) => {
264
+ const app = {
265
+ getPublicPath: () => '/nocobase/v2/',
266
+ router: {
267
+ getBasename: () => '/nocobase/v2/apps/test-app/',
268
+ },
269
+ } as any;
270
+
271
+ expect(normalizeV2RedirectPath(app, target)).toBe('/nocobase/v2/apps/test-app/admin/');
272
+ });
273
+
137
274
  it('should normalize signin redirect fallback under the current sub-app basename', () => {
138
275
  const app = {
139
276
  getPublicPath: () => '/v/',
@@ -210,6 +347,23 @@ describe('auth redirect helpers', () => {
210
347
  );
211
348
  });
212
349
 
350
+ it('should derive a scoped Portal signin href under the current sub-app basename', () => {
351
+ const app = {
352
+ getPublicPath: () => '/nocobase/v2/',
353
+ router: {
354
+ getBasename: () => '/nocobase/v2/apps/test-app/',
355
+ },
356
+ } satisfies Parameters<typeof buildV2SigninHref>[0];
357
+
358
+ expect(
359
+ buildV2SigninHref(app, '/nocobase/v2/apps/test-app/customer/orders', {
360
+ signInRoutePath: '/customer/signin',
361
+ }),
362
+ ).toBe(
363
+ '/nocobase/v2/apps/test-app/customer/signin?redirect=%2Fnocobase%2Fv2%2Fapps%2Ftest-app%2Fcustomer%2Forders',
364
+ );
365
+ });
366
+
213
367
  it('should redirect to sub-app signin with window.location.replace', () => {
214
368
  const replace = vi.fn();
215
369
  Object.defineProperty(globalThis.window, 'location', {
@@ -15,8 +15,11 @@ import { fileURLToPath } from 'node:url';
15
15
  type BrowserCheckerCase = {
16
16
  pathname: string;
17
17
  publicPath: string;
18
+ search?: string;
19
+ hash?: string;
18
20
  modernClientPrefix?: string;
19
21
  appClientEntryMode?: string;
22
+ scriptSrc?: string;
20
23
  expectedRedirect?: string;
21
24
  };
22
25
 
@@ -47,8 +50,8 @@ function executeBrowserChecker(scriptPath: string, input: BrowserCheckerCase) {
47
50
  location: {
48
51
  origin: 'http://c.local.nocobase.com',
49
52
  pathname: input.pathname,
50
- search: '',
51
- hash: '',
53
+ search: input.search || '',
54
+ hash: input.hash || '',
52
55
  replace,
53
56
  },
54
57
  console: consoleMock,
@@ -58,6 +61,13 @@ function executeBrowserChecker(scriptPath: string, input: BrowserCheckerCase) {
58
61
  onresize: undefined,
59
62
  },
60
63
  document: {
64
+ currentScript: {
65
+ src:
66
+ input.scriptSrc ||
67
+ (scriptPath.includes('/app/client-v2/public/')
68
+ ? 'http://assets.local.nocobase.com/v/browser-checker.js?v=1'
69
+ : 'http://assets.local.nocobase.com/browser-checker.js?v=1'),
70
+ },
61
71
  documentElement: {
62
72
  className: '',
63
73
  clientWidth: 1280,
@@ -80,11 +90,11 @@ function executeBrowserChecker(scriptPath: string, input: BrowserCheckerCase) {
80
90
  describe.each(browserCheckerCases)('$label', ({ scriptPath }) => {
81
91
  it('normalizes a relative public path before redirecting to the trailing-slash entry', () => {
82
92
  const replace = executeBrowserChecker(scriptPath, {
83
- pathname: '/nocobase/v',
84
- publicPath: 'nocobase/v/',
93
+ pathname: '/nocobase/console',
94
+ publicPath: 'nocobase/console/',
85
95
  });
86
96
 
87
- expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/nocobase/v/');
97
+ expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/nocobase/console/');
88
98
  });
89
99
 
90
100
  it('prefixes outside paths with a root-relative basename instead of duplicating a relative segment', () => {
@@ -98,7 +108,7 @@ describe.each(browserCheckerCases)('$label', ({ scriptPath }) => {
98
108
 
99
109
  it('does not redirect when the current path is already under the normalized basename', () => {
100
110
  const replace = executeBrowserChecker(scriptPath, {
101
- pathname: '/nocobase/v/',
111
+ pathname: '/nocobase/v/admin',
102
112
  publicPath: 'nocobase/v/',
103
113
  });
104
114
 
@@ -126,6 +136,30 @@ describe.each(browserCheckerCases)('$label', ({ scriptPath }) => {
126
136
  expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/v/');
127
137
  });
128
138
 
139
+ it('redirects app root to modern entry for modern-only', () => {
140
+ const replace = executeBrowserChecker(scriptPath, {
141
+ pathname: '/',
142
+ publicPath: '/',
143
+ modernClientPrefix: 'v',
144
+ appClientEntryMode: 'modern-only',
145
+ });
146
+
147
+ expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/v/');
148
+ });
149
+
150
+ it.each(['/', '/index.html'])('redirects app root entry %s to Settings for settings-default', (pathname) => {
151
+ const replace = executeBrowserChecker(scriptPath, {
152
+ pathname,
153
+ publicPath: '/',
154
+ modernClientPrefix: 'v',
155
+ appClientEntryMode: 'settings-default',
156
+ search: '?from=entry',
157
+ hash: '#panel',
158
+ });
159
+
160
+ expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/settings/?from=entry#panel');
161
+ });
162
+
129
163
  it('does not redirect legacy deep links for modern-default', () => {
130
164
  const replace = executeBrowserChecker(scriptPath, {
131
165
  pathname: '/admin',
@@ -137,6 +171,17 @@ describe.each(browserCheckerCases)('$label', ({ scriptPath }) => {
137
171
  expect(replace).not.toHaveBeenCalled();
138
172
  });
139
173
 
174
+ it('does not redirect legacy deep links for settings-default', () => {
175
+ const replace = executeBrowserChecker(scriptPath, {
176
+ pathname: '/admin',
177
+ publicPath: '/',
178
+ modernClientPrefix: 'v',
179
+ appClientEntryMode: 'settings-default',
180
+ });
181
+
182
+ expect(replace).not.toHaveBeenCalled();
183
+ });
184
+
140
185
  it('rewrites legacy document paths for modern-only', () => {
141
186
  const replace = executeBrowserChecker(scriptPath, {
142
187
  pathname: '/admin/settings/workflow',
@@ -159,6 +204,19 @@ describe.each(browserCheckerCases)('$label', ({ scriptPath }) => {
159
204
  expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/nocobase/v/');
160
205
  });
161
206
 
207
+ it('redirects a sub-path app root directly to Settings for settings-default', () => {
208
+ const replace = executeBrowserChecker(scriptPath, {
209
+ pathname: '/nocobase/',
210
+ publicPath: '/nocobase/',
211
+ modernClientPrefix: 'v',
212
+ appClientEntryMode: 'settings-default',
213
+ search: '?from=entry',
214
+ hash: '#panel',
215
+ });
216
+
217
+ expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/nocobase/settings/?from=entry#panel');
218
+ });
219
+
162
220
  it('rewrites sub-app legacy deep links for modern-only without collapsing the sub-app segment', () => {
163
221
  const replace = executeBrowserChecker(scriptPath, {
164
222
  pathname: '/nocobase/apps/a_31itq60q4kg/admin/',
@@ -170,4 +228,112 @@ describe.each(browserCheckerCases)('$label', ({ scriptPath }) => {
170
228
  expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/nocobase/v/apps/a_31itq60q4kg/admin');
171
229
  });
172
230
  }
231
+
232
+ if (scriptPath.includes('/app/client-v2/public/')) {
233
+ it.each([
234
+ ['/v', 'http://c.local.nocobase.com/v/'],
235
+ ['/v/', undefined],
236
+ ])('normalizes the modern client root %s without redirecting to Settings', (pathname, expectedRedirect) => {
237
+ const replace = executeBrowserChecker(scriptPath, {
238
+ pathname,
239
+ publicPath: '/v/',
240
+ modernClientPrefix: 'v',
241
+ });
242
+
243
+ if (expectedRedirect) {
244
+ expect(replace).toHaveBeenCalledOnce();
245
+ expect(replace).toHaveBeenCalledWith(expectedRedirect);
246
+ } else {
247
+ expect(replace).not.toHaveBeenCalled();
248
+ }
249
+ });
250
+
251
+ it.each(['/v/apps/demo', '/v/apps/demo/', '/v/_app/demo', '/v/_app/demo/'])(
252
+ 'lets the scoped modern client handle its root %s',
253
+ (pathname) => {
254
+ const replace = executeBrowserChecker(scriptPath, {
255
+ pathname,
256
+ publicPath: '/v/',
257
+ modernClientPrefix: 'v',
258
+ });
259
+
260
+ expect(replace).not.toHaveBeenCalled();
261
+ },
262
+ );
263
+
264
+ it.each([
265
+ ['/settings/apps/demo', '/', '/settings/apps/demo/', 'v'],
266
+ ['/settings/_app/demo', '/', '/settings/_app/demo/', 'v'],
267
+ ['/nocobase/settings/apps/demo', '/nocobase/', '/nocobase/settings/apps/demo/', 'v'],
268
+ ['/nocobase/settings/_app/demo', '/nocobase/', '/nocobase/settings/_app/demo/', 'v'],
269
+ ['/tenant/apps/root/settings/apps/demo', '/tenant/apps/root/', '/tenant/apps/root/settings/apps/demo/', 'v'],
270
+ ['/tenant/_app/root/settings/_app/demo', '/tenant/_app/root/', '/tenant/_app/root/settings/_app/demo/', 'v'],
271
+ ['/tenant/v/settings/apps/demo', '/tenant/v/', '/tenant/v/settings/apps/demo/', 'v'],
272
+ ['/tenant/modern/settings/_app/demo', '/tenant/modern/', '/tenant/modern/settings/_app/demo/', 'modern'],
273
+ ])('normalizes the scoped Settings root %s to %s', (pathname, publicPath, expectedPathname, modernClientPrefix) => {
274
+ const replace = executeBrowserChecker(scriptPath, {
275
+ pathname,
276
+ publicPath,
277
+ modernClientPrefix,
278
+ scriptSrc: 'https://cdn.example.com/ui/settings/browser-checker.js?v=1',
279
+ search: '?tab=overview',
280
+ hash: '#panel',
281
+ });
282
+
283
+ expect(replace).toHaveBeenCalledOnce();
284
+ expect(replace).toHaveBeenCalledWith(`http://c.local.nocobase.com${expectedPathname}?tab=overview#panel`);
285
+ });
286
+
287
+ it.each([
288
+ ['/tenant/apps/root/modern/apps/demo/', '/tenant/apps/root/modern/'],
289
+ ['/tenant/_app/root/modern/_app/demo/', '/tenant/_app/root/modern/'],
290
+ ])('lets the modern client handle %s under a nested public path', (pathname, publicPath) => {
291
+ const replace = executeBrowserChecker(scriptPath, {
292
+ pathname,
293
+ publicPath,
294
+ modernClientPrefix: 'modern',
295
+ search: '?tab=overview',
296
+ hash: '#panel',
297
+ });
298
+
299
+ expect(replace).not.toHaveBeenCalled();
300
+ });
301
+
302
+ it.each([
303
+ ['/v/admin', '/v/'],
304
+ ['/v/apps/demo/admin', '/v/'],
305
+ ['/v/_app/demo/admin', '/v/'],
306
+ ['/v/_apps/demo', '/v/'],
307
+ ['/v/settings/apps/demo', '/v/'],
308
+ ['/nocobase/modern/settings/_app/demo', '/nocobase/modern/', 'modern'],
309
+ ])('does not redirect a non-root modern path %s to Settings', (pathname, publicPath, modernClientPrefix = 'v') => {
310
+ const replace = executeBrowserChecker(scriptPath, {
311
+ pathname,
312
+ publicPath,
313
+ modernClientPrefix,
314
+ });
315
+
316
+ expect(replace).not.toHaveBeenCalled();
317
+ });
318
+
319
+ it.each([
320
+ ['/settings', '/'],
321
+ ['/settings/', '/'],
322
+ ['/settings/apps/demo/', '/'],
323
+ ['/settings/_app/demo/', '/'],
324
+ ['/settings/apps/demo/multi-portal', '/'],
325
+ ['/settings/_app/demo/multi-portal', '/'],
326
+ ['/settings/_apps/demo', '/'],
327
+ ['/nocobase/settings/apps/demo/multi-portal', '/nocobase/'],
328
+ ])('does not redirect a non-target Settings path %s', (pathname, publicPath) => {
329
+ const replace = executeBrowserChecker(scriptPath, {
330
+ pathname,
331
+ publicPath,
332
+ modernClientPrefix: 'v',
333
+ scriptSrc: 'https://cdn.example.com/ui/settings/browser-checker.js?v=1',
334
+ });
335
+
336
+ expect(replace).not.toHaveBeenCalled();
337
+ });
338
+ }
173
339
  });
@@ -0,0 +1,29 @@
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 MockAdapter from 'axios-mock-adapter';
11
+ import type { ApplicationOptions } from '../Application';
12
+ import { SettingsApplication } from '../settings-app/SettingsApplication';
13
+
14
+ class MockSettingsApplication extends SettingsApplication {
15
+ readonly apiMock: MockAdapter;
16
+
17
+ constructor(options: ApplicationOptions = {}) {
18
+ super({
19
+ router: { type: 'memory', initialEntries: ['/settings'] },
20
+ ws: false,
21
+ ...options,
22
+ });
23
+ this.apiMock = new MockAdapter(this.apiClient.axios);
24
+ }
25
+ }
26
+
27
+ export function createMockSettingsClient(options?: ApplicationOptions) {
28
+ return new MockSettingsApplication(options);
29
+ }