@nocobase/client-v2 2.3.0-beta.9 → 2.4.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/es/RouteRepository.d.ts +6 -0
  2. package/es/acl/ACLProvider.d.ts +10 -0
  3. package/es/acl/aclCheckReadiness.d.ts +11 -0
  4. package/es/authRedirect.d.ts +11 -2
  5. package/es/components/AttachmentUpload.d.ts +51 -0
  6. package/es/components/index.d.ts +1 -0
  7. package/es/flow/admin-shell/admin-layout/NocoBaseLogo.d.ts +10 -0
  8. package/es/flow/components/filter/VariableFilterItem.d.ts +10 -1
  9. package/es/flow/internal/utils/quickEditDataScope.d.ts +48 -0
  10. package/es/flow/internal/utils/quickEditViewContainer.d.ts +27 -0
  11. package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +1 -0
  12. package/es/index.d.ts +4 -1
  13. package/es/index.mjs +129 -118
  14. package/es/nocobase-buildin-plugin/currentUserAuthStatus.d.ts +11 -0
  15. package/es/nocobase-buildin-plugin/index.d.ts +10 -0
  16. package/es/settings-app/SettingsApplication.d.ts +18 -0
  17. package/es/settings-app/SettingsBrand.d.ts +16 -0
  18. package/es/settings-app/SettingsBuildInPlugin.d.ts +9 -0
  19. package/es/settings-app/SettingsDocumentRedirect.d.ts +10 -0
  20. package/es/settings-app/SettingsGroupNav.d.ts +18 -0
  21. package/es/settings-app/SettingsPluginSettingsManager.d.ts +14 -0
  22. package/es/settings-app/SettingsRouterManager.d.ts +15 -0
  23. package/es/settings-app/SettingsSearch.d.ts +17 -0
  24. package/es/settings-app/SettingsShell.d.ts +10 -0
  25. package/es/settings-app/isSettingsApp.d.ts +18 -0
  26. package/es/settings-app/runtimePaths.d.ts +18 -0
  27. package/es/settings-app/settingsDocumentPath.d.ts +14 -0
  28. package/es/settings-app/settingsTheme.d.ts +87 -0
  29. package/es/settings-app/useSettingsThemeConfig.d.ts +22 -0
  30. package/es/settings-center/AdminSettingsLayout.d.ts +2 -2
  31. package/es/settings-center/groups.d.ts +66 -0
  32. package/es/settings-center/useSettingsGroups.d.ts +36 -0
  33. package/es/settings-center/useSettingsSearch.d.ts +35 -0
  34. package/es/settings-center/utils.d.ts +24 -0
  35. package/es/theme/index.d.ts +1 -0
  36. package/es/theme/itemActive.d.ts +24 -0
  37. package/es/theme/type.d.ts +9 -0
  38. package/lib/index.js +133 -122
  39. package/package.json +7 -7
  40. package/src/BaseApplication.tsx +2 -0
  41. package/src/RouteRepository.ts +14 -0
  42. package/src/__tests__/RouteRepository.test.ts +51 -0
  43. package/src/__tests__/app.test.tsx +135 -3
  44. package/src/__tests__/authRedirect.test.ts +154 -0
  45. package/src/__tests__/browserChecker.test.ts +172 -6
  46. package/src/__tests__/mockSettingsApplication.ts +29 -0
  47. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +81 -8
  48. package/src/__tests__/plugin-manager.test.tsx +7 -6
  49. package/src/__tests__/settings-application.test.ts +164 -0
  50. package/src/__tests__/settings-auth-routes.test.ts +71 -0
  51. package/src/__tests__/settings-center.test.tsx +168 -130
  52. package/src/__tests__/settings-layout-root.test.tsx +600 -0
  53. package/src/__tests__/settings-runtime-paths.test.ts +99 -0
  54. package/src/__tests__/settings-search-shortcut.test.tsx +321 -0
  55. package/src/__tests__/settings-shell.test.tsx +261 -0
  56. package/src/acl/ACLProvider.tsx +53 -3
  57. package/src/acl/aclCheckReadiness.ts +44 -0
  58. package/src/authRedirect.ts +85 -8
  59. package/src/components/AppComponents.tsx +16 -7
  60. package/src/components/AttachmentUpload.tsx +275 -0
  61. package/src/components/index.ts +1 -0
  62. package/src/flow/actions/__tests__/validation.test.ts +48 -0
  63. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -56
  64. package/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx +77 -0
  65. package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +37 -14
  66. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +104 -20
  67. package/src/flow/components/filter/VariableFilterItem.tsx +14 -4
  68. package/src/flow/components/filter/__tests__/VariableFilterItem.rightMetaTree.test.tsx +158 -0
  69. package/src/flow/internal/utils/__tests__/quickEditDataScope.test.ts +156 -0
  70. package/src/flow/internal/utils/__tests__/quickEditViewContainer.test.ts +52 -0
  71. package/src/flow/internal/utils/quickEditDataScope.tsx +177 -0
  72. package/src/flow/internal/utils/quickEditViewContainer.ts +46 -0
  73. package/src/flow/models/actions/JSItemActionModel.tsx +19 -3
  74. package/src/flow/models/actions/__tests__/JSItemActionModel.test.tsx +47 -1
  75. package/src/flow/models/blocks/form/QuickEditFormModel.tsx +45 -11
  76. package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +120 -0
  77. package/src/flow/models/blocks/js-block/JSBlock.tsx +5 -1
  78. package/src/flow/models/blocks/js-block/__tests__/JSBlockModel.test.tsx +23 -1
  79. package/src/flow/models/blocks/table/TableColumnModel.tsx +31 -0
  80. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +64 -0
  81. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +7 -1
  82. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +4 -3
  83. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -0
  84. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +17 -10
  85. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +6 -1
  86. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.disabled.test.tsx +90 -0
  87. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordSelectFieldModel.quickCreatePopup.test.tsx +183 -0
  88. package/src/flow/models/fields/SelectFieldModel.tsx +5 -0
  89. package/src/flow/models/fields/__tests__/SelectFieldModel.test.tsx +29 -1
  90. package/src/flow/models/topbar/TopbarActionModel.tsx +52 -180
  91. package/src/index.ts +11 -1
  92. package/src/nocobase-buildin-plugin/currentUserAuthStatus.ts +58 -0
  93. package/src/nocobase-buildin-plugin/index.tsx +112 -71
  94. package/src/settings-app/SettingsApplication.ts +31 -0
  95. package/src/settings-app/SettingsBrand.tsx +108 -0
  96. package/src/settings-app/SettingsBuildInPlugin.ts +10 -0
  97. package/src/settings-app/SettingsDocumentRedirect.tsx +26 -0
  98. package/src/settings-app/SettingsGroupNav.tsx +124 -0
  99. package/src/settings-app/SettingsPluginSettingsManager.ts +38 -0
  100. package/src/settings-app/SettingsRouterManager.ts +92 -0
  101. package/src/settings-app/SettingsSearch.tsx +343 -0
  102. package/src/settings-app/SettingsShell.tsx +184 -0
  103. package/src/settings-app/isSettingsApp.ts +21 -0
  104. package/src/settings-app/runtimePaths.ts +103 -0
  105. package/src/settings-app/settingsDocumentPath.ts +66 -0
  106. package/src/settings-app/settingsTheme.ts +369 -0
  107. package/src/settings-app/useSettingsThemeConfig.ts +100 -0
  108. package/src/settings-center/AdminSettingsLayout.tsx +154 -169
  109. package/src/settings-center/SystemSettingsPage.tsx +4 -170
  110. package/src/settings-center/__tests__/groups.test.ts +25 -0
  111. package/src/settings-center/groups.ts +108 -0
  112. package/src/settings-center/useSettingsGroups.ts +105 -0
  113. package/src/settings-center/useSettingsSearch.ts +173 -0
  114. package/src/settings-center/utils.tsx +50 -1
  115. package/src/theme/index.tsx +1 -0
  116. package/src/theme/itemActive.ts +31 -0
  117. package/src/theme/type.ts +10 -0
@@ -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
+ }
@@ -51,6 +51,42 @@ class AuthBootstrapRoutePlugin extends Plugin {
51
51
  }
52
52
  }
53
53
 
54
+ class TestAdminPortalPlugin extends Plugin {
55
+ async load() {
56
+ this.app.layoutManager.registerLayout({
57
+ routeName: 'admin',
58
+ routePath: '/admin',
59
+ uid: 'admin-layout-model',
60
+ layoutModelClass: 'AdminLayoutModel',
61
+ });
62
+ }
63
+ }
64
+
65
+ class TestRootPortalPlugin extends Plugin {
66
+ async load() {
67
+ this.router.add('root', {
68
+ path: '/',
69
+ authCheck: true,
70
+ Component: () => <div>portal landing</div>,
71
+ });
72
+ }
73
+ }
74
+
75
+ class TestScopedPortalPlugin extends Plugin {
76
+ async load() {
77
+ this.router.add('customer-portal', {
78
+ path: '/customer/*',
79
+ authCheck: true,
80
+ Component: () => <div>customer portal</div>,
81
+ });
82
+ this.router.add('customer-portal-signin', {
83
+ path: '/customer/signin',
84
+ skipAuthCheck: true,
85
+ Component: () => <div>customer portal signin</div>,
86
+ });
87
+ }
88
+ }
89
+
54
90
  describe('nocobase buildin plugin auth redirect', () => {
55
91
  const originalLocation = globalThis.window.location;
56
92
 
@@ -85,6 +121,16 @@ describe('nocobase buildin plugin auth redirect', () => {
85
121
  vi.restoreAllMocks();
86
122
  });
87
123
 
124
+ it('should leave Admin portal registration to runtime plugins', async () => {
125
+ const app = createMockClient();
126
+ const plugin = new NocoBaseBuildInPlugin({}, app);
127
+
128
+ await plugin.load();
129
+
130
+ expect(app.layoutManager.hasLayout('admin')).toBe(false);
131
+ expect(app.router.has('root')).toBe(false);
132
+ });
133
+
88
134
  it('should navigate to v2 signin when /auth:check returns no user', async () => {
89
135
  // Aligns with v1: use react-router navigate (virtual) rather than
90
136
  // `window.location.replace`, so a `window.location.href` queued elsewhere
@@ -92,7 +138,7 @@ describe('nocobase buildin plugin auth redirect', () => {
92
138
  // overridden.
93
139
  const app = createMockClient({
94
140
  publicPath: '/v2/',
95
- plugins: [NocoBaseBuildInPlugin as any],
141
+ plugins: [NocoBaseBuildInPlugin as any, TestAdminPortalPlugin as typeof Plugin],
96
142
  router: { type: 'memory', initialEntries: ['/v2/admin/7vu4c2sdk6h'] },
97
143
  });
98
144
  app.apiMock.onGet('app:getLang').reply(200, {
@@ -115,7 +161,7 @@ describe('nocobase buildin plugin auth redirect', () => {
115
161
  // and race the 2FA response interceptor with its own signin redirect.
116
162
  const app = createMockClient({
117
163
  publicPath: '/v2/',
118
- plugins: [NocoBaseBuildInPlugin as any],
164
+ plugins: [NocoBaseBuildInPlugin as any, TestAdminPortalPlugin as typeof Plugin],
119
165
  router: { type: 'memory', initialEntries: ['/v2/admin'] },
120
166
  });
121
167
  app.apiClient.auth.setToken('test-token');
@@ -136,29 +182,56 @@ describe('nocobase buildin plugin auth redirect', () => {
136
182
  expect(app.apiMock.history.post.filter((request) => request.url === 'auth:syncCookies')).toHaveLength(0);
137
183
  });
138
184
 
139
- it('should redirect unauthenticated v2 root access to v2 signin via <Navigate />', async () => {
185
+ it('should redirect an unauthenticated Portal root to v2 signin', async () => {
140
186
  const app = createMockClient({
141
187
  publicPath: '/nocobase/v2/',
142
- plugins: [NocoBaseBuildInPlugin as any],
188
+ plugins: [NocoBaseBuildInPlugin as any, TestRootPortalPlugin as typeof Plugin],
143
189
  router: { type: 'memory', initialEntries: ['/nocobase/v2/'] },
144
190
  });
145
191
  app.apiMock.onGet('app:getLang').reply(200, {
146
192
  data: { lang: 'en-US', resources: { client: {} }, cron: {} },
147
193
  });
194
+ app.apiMock.onGet('/auth:check').reply(200, { data: {} });
148
195
 
149
196
  const Root = app.getRootComponent();
150
197
  render(<Root />);
151
198
 
152
199
  await waitFor(() => {
153
200
  expect(app.router.router.state.location.pathname).toBe('/nocobase/v2/signin');
154
- expect(app.router.router.state.location.search).toBe('?redirect=%2Fnocobase%2Fv2%2Fadmin');
201
+ expect(app.router.router.state.location.search).toBe('?redirect=%2Fnocobase%2Fv2');
202
+ });
203
+ });
204
+
205
+ it('should use an explicitly registered Portal signin route when unauthenticated', async () => {
206
+ const app = createMockClient({
207
+ publicPath: '/v2/',
208
+ plugins: [NocoBaseBuildInPlugin as unknown as typeof Plugin, TestScopedPortalPlugin as typeof Plugin],
209
+ router: { type: 'memory', initialEntries: ['/v2/customer/dashboard?tab=overview#panel'] },
210
+ });
211
+ app.apiMock.onGet('app:getLang').reply(200, {
212
+ data: { lang: 'en-US', resources: { client: {} }, cron: {} },
213
+ });
214
+ app.apiMock.onGet('/auth:check').reply(200, { data: {} });
215
+
216
+ const Root = app.getRootComponent();
217
+ render(<Root />);
218
+
219
+ await waitFor(() => {
220
+ expect(app.router.router.state.location.pathname).toBe('/v2/customer/signin');
221
+ expect(app.router.router.state.location.search).toBe(
222
+ '?redirect=%2Fv2%2Fcustomer%2Fdashboard%3Ftab%3Doverview%23panel',
223
+ );
155
224
  });
156
225
  });
157
226
 
158
227
  it('should check current user after navigating from skipped route to v2 admin', async () => {
159
228
  const app = createMockClient({
160
229
  publicPath: '/v2/',
161
- plugins: [NocoBaseBuildInPlugin as any, SkippedPublicRoutePlugin as any],
230
+ plugins: [
231
+ NocoBaseBuildInPlugin as any,
232
+ TestAdminPortalPlugin as typeof Plugin,
233
+ SkippedPublicRoutePlugin as typeof Plugin,
234
+ ],
162
235
  router: { type: 'memory', initialEntries: ['/v2/public'] },
163
236
  });
164
237
  app.apiMock.onGet('app:getLang').reply(200, {
@@ -385,7 +458,7 @@ describe('nocobase buildin plugin auth redirect', () => {
385
458
  it('should render v2 admin root without redirecting away', async () => {
386
459
  const app = createMockClient({
387
460
  publicPath: '/v2/',
388
- plugins: [NocoBaseBuildInPlugin as any],
461
+ plugins: [NocoBaseBuildInPlugin as any, TestAdminPortalPlugin as typeof Plugin],
389
462
  router: { type: 'memory', initialEntries: ['/v2/admin'] },
390
463
  });
391
464
  app.apiMock.onGet('app:getLang').reply(200, {
@@ -419,7 +492,7 @@ describe('nocobase buildin plugin auth redirect', () => {
419
492
  async (pathname) => {
420
493
  const app = createMockClient({
421
494
  publicPath: '/v2/',
422
- plugins: [NocoBaseBuildInPlugin as any],
495
+ plugins: [NocoBaseBuildInPlugin as any, TestAdminPortalPlugin as typeof Plugin],
423
496
  router: { type: 'memory', initialEntries: [pathname] },
424
497
  });
425
498
  app.apiMock.onGet('app:getLang').reply(200, {
@@ -7,10 +7,11 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { ACLRolesCheckProvider, createMockClient, Plugin } from '@nocobase/client-v2';
10
+ import { ACLRolesCheckProvider, Plugin } from '@nocobase/client-v2';
11
11
  import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
12
12
  import React from 'react';
13
- import { NocoBaseBuildInPlugin } from '../nocobase-buildin-plugin';
13
+ import { SettingsBuildInPlugin } from '../settings-app/SettingsBuildInPlugin';
14
+ import { createMockSettingsClient } from './mockSettingsApplication';
14
15
 
15
16
  class TestAclPlugin extends Plugin {
16
17
  async load() {
@@ -31,7 +32,7 @@ class TestSettingsLinkPlugin extends Plugin {
31
32
  }
32
33
  }
33
34
 
34
- type MockClientApplication = ReturnType<typeof createMockClient>;
35
+ type MockClientApplication = ReturnType<typeof createMockSettingsClient>;
35
36
 
36
37
  const renderApp = (app: MockClientApplication) => {
37
38
  const Root = app.getRootComponent();
@@ -49,9 +50,9 @@ const waitForGetRequests = async (app: MockClientApplication, urls: string[]) =>
49
50
  };
50
51
 
51
52
  const setupApp = (pmList: any[], plugins: Array<typeof Plugin> = []) => {
52
- const app = createMockClient({
53
- plugins: [NocoBaseBuildInPlugin, TestAclPlugin, ...plugins],
54
- router: { type: 'memory', initialEntries: ['/admin/settings/plugin-manager'] },
53
+ const app = createMockSettingsClient({
54
+ plugins: [SettingsBuildInPlugin, TestAclPlugin, ...plugins],
55
+ router: { type: 'memory', initialEntries: ['/settings/plugin-manager'] },
55
56
  });
56
57
 
57
58
  app.apiMock.onGet('/auth:check').reply(200, {
@@ -0,0 +1,164 @@
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 { describe, expect, it } from 'vitest';
12
+ import { Application } from '../index';
13
+ import { SettingsApplication } from '../settings-app/SettingsApplication';
14
+ import { SettingsBuildInPlugin } from '../settings-app/SettingsBuildInPlugin';
15
+
16
+ describe('SettingsApplication', () => {
17
+ it('uses the standalone settings route namespace', () => {
18
+ const adminApp = new Application({ router: { type: 'memory' }, ws: false });
19
+ const settingsApp = new SettingsApplication({ router: { type: 'memory' }, ws: false });
20
+
21
+ expect(adminApp.pluginSettingsManager.getRouteName('demo.index')).toBe('admin.settings.demo.index');
22
+ expect(adminApp.pluginSettingsManager.getRoutePath('demo.index')).toBe('/admin/settings/demo');
23
+
24
+ expect(settingsApp.pluginSettingsManager.getRouteName('demo.index')).toBe('settings.demo.index');
25
+ expect(settingsApp.pluginSettingsManager.getRoutePath('demo.index')).toBe('/settings/demo');
26
+ expect(settingsApp.pluginSettingsManager.getRoutePath('')).toBe('/settings/');
27
+ });
28
+
29
+ it.each(['apps', '_app'])('uses basename-relative paths for the %s sub-application Settings runtime', (scope) => {
30
+ const app = new SettingsApplication({
31
+ publicPath: '/nocobase/',
32
+ router: { type: 'memory', basename: `/nocobase/settings/${scope}/demo/` },
33
+ ws: false,
34
+ });
35
+
36
+ expect(app.pluginSettingsManager.getRouteName('demo.index')).toBe('settings.demo.index');
37
+ expect(app.pluginSettingsManager.getRoutePath('')).toBe('/');
38
+ expect(app.pluginSettingsManager.getRoutePath('demo.index')).toBe('/demo');
39
+
40
+ app.router.add('settings', { path: '/settings' });
41
+ app.router.add('settingsDetails.workflow.canvas', { path: '/settings/workflow/workflows/:id' });
42
+ app.router.add('settingsDetails.preview', { path: '/settings-preview/:id' });
43
+ app.router.add('auth.signin', { path: '/signin' });
44
+
45
+ expect(app.router.get('settings')).toMatchObject({ path: '/' });
46
+ expect(app.router.get('settingsDetails.workflow.canvas')).toMatchObject({
47
+ path: '/workflow/workflows/:id',
48
+ authCheck: true,
49
+ });
50
+ expect(
51
+ app.router
52
+ .matchRoutes(`/nocobase/settings/${scope}/demo/workflow/workflows/1`)
53
+ ?.some((match) => match.route.id === 'settingsDetails.workflow.canvas'),
54
+ ).toBe(true);
55
+ expect(
56
+ app.router
57
+ .matchRoutes(`/nocobase/settings/${scope}/demo/settings/workflow/workflows/1`)
58
+ ?.some((match) => match.route.id === 'settingsDetails.workflow.canvas'),
59
+ ).toBe(false);
60
+ expect(app.router.get('settingsDetails.preview')).toMatchObject({ path: '/settings-preview/:id' });
61
+ expect(app.router.get('auth.signin')).toMatchObject({ path: '/signin' });
62
+ });
63
+
64
+ it('does not treat an apps segment inside the main application public path as a sub-application scope', () => {
65
+ const app = new SettingsApplication({
66
+ router: { type: 'memory', basename: '/tenant/apps/root/' },
67
+ ws: false,
68
+ });
69
+
70
+ expect(app.pluginSettingsManager.getRoutePath('')).toBe('/settings/');
71
+ expect(app.pluginSettingsManager.getRoutePath('demo.index')).toBe('/settings/demo');
72
+
73
+ app.router.add('settingsDetails.workflow.canvas', { path: '/settings/workflow/workflows/:id' });
74
+ expect(app.router.get('settingsDetails.workflow.canvas')).toMatchObject({
75
+ path: '/settings/workflow/workflows/:id',
76
+ });
77
+ });
78
+
79
+ it.each(['apps', '_app'])(
80
+ 'does not treat a scoped-looking %s suffix inside the main public path as a sub-application scope',
81
+ (scope) => {
82
+ const publicPath = `/tenant/settings/${scope}/root/`;
83
+ const app = new SettingsApplication({
84
+ publicPath,
85
+ router: { type: 'memory', basename: publicPath },
86
+ ws: false,
87
+ });
88
+
89
+ expect(app.pluginSettingsManager.getRoutePath('')).toBe('/settings/');
90
+ expect(app.pluginSettingsManager.getRoutePath('demo.index')).toBe('/settings/demo');
91
+
92
+ app.router.add('settingsDetails.workflow.canvas', { path: '/settings/workflow/workflows/:id' });
93
+ app.router.add('auth.signin', { path: '/signin' });
94
+
95
+ expect(app.router.get('settingsDetails.workflow.canvas')).toMatchObject({
96
+ path: '/settings/workflow/workflows/:id',
97
+ });
98
+ expect(app.router.get('auth.signin')).toMatchObject({ path: '/settings/signin' });
99
+ },
100
+ );
101
+
102
+ it('keeps only routes owned by the settings runtime', () => {
103
+ const app = new SettingsApplication({ router: { type: 'memory' }, ws: false });
104
+
105
+ app.router.add('settings', { path: '/settings' });
106
+ app.router.add('settings.demo', { path: 'demo' });
107
+ app.router.add('settingsDetails.workflow.canvas', { path: '/settings/workflow/workflows/:id' });
108
+ app.router.add('admin.demo', { path: '/admin/demo' });
109
+ app.router.add('public-forms', { path: '/public-forms/:key' });
110
+ app.router.add('mobile', { path: '/mobile' });
111
+
112
+ expect(app.router.has('settings')).toBe(true);
113
+ expect(app.router.has('settings.demo')).toBe(true);
114
+ expect(app.router.get('settingsDetails.workflow.canvas')).toMatchObject({
115
+ path: '/settings/workflow/workflows/:id',
116
+ authCheck: true,
117
+ });
118
+ expect(app.router.has('admin.demo')).toBe(false);
119
+ expect(app.router.has('public-forms')).toBe(false);
120
+ expect(app.router.has('mobile')).toBe(false);
121
+ expect(app.router.has('not-found')).toBe(true);
122
+ });
123
+
124
+ it('keeps hidden plugin detail pages in the normal Settings route tree', () => {
125
+ const app = new SettingsApplication({ router: { type: 'memory' }, ws: false });
126
+
127
+ app.pluginSettingsManager.addMenuItem({ key: 'public-forms', title: 'Public forms' });
128
+ app.pluginSettingsManager.addPageTabItem({
129
+ menuKey: 'public-forms',
130
+ key: ':name',
131
+ title: false,
132
+ hidden: true,
133
+ Component: () => null,
134
+ });
135
+
136
+ expect(app.pluginSettingsManager.get('public-forms.:name')).toMatchObject({
137
+ hidden: true,
138
+ path: '/settings/public-forms/:name',
139
+ });
140
+ expect(app.router.get('settings.public-forms.:name')).toMatchObject({ path: ':name' });
141
+ });
142
+
143
+ it('registers the authenticated settings shell without the admin layout', async () => {
144
+ const app = new SettingsApplication({ router: { type: 'memory' }, ws: false });
145
+ const apiMock = new MockAdapter(app.apiClient.axios);
146
+ apiMock.onGet('app:getLang').reply(200, { data: { lang: 'en-US', resources: { client: {} }, cron: {} } });
147
+ const plugin = new SettingsBuildInPlugin({ name: 'settings-buildin' }, app);
148
+
149
+ await plugin.afterAdd();
150
+ await plugin.load();
151
+ app.router.add('settingsDetails.workflow.canvas', { path: '/settings/workflow/workflows/:id' });
152
+
153
+ expect(app.router.get('settings')).toMatchObject({ path: '/settings', authCheck: true });
154
+ expect(app.router.get('settingsDetails')).toMatchObject({ path: '/settings', authCheck: true });
155
+ expect(
156
+ app.router.matchRoutes('/settings/workflow/workflows/1')?.some((match) => match.route.authCheck === true),
157
+ ).toBe(true);
158
+ expect(app.router.has('admin')).toBe(false);
159
+ expect(app.layoutManager.hasLayout('admin')).toBe(false);
160
+ expect(app.pluginSettingsManager.has('plugin-manager')).toBe(true);
161
+ expect(app.pluginSettingsManager.has('system-settings')).toBe(true);
162
+ expect(app.pluginSettingsManager.has('security')).toBe(true);
163
+ });
164
+ });