@nocobase/client-v2 3.0.0-alpha.3 → 3.0.0-alpha.4

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 (32) hide show
  1. package/es/acl/aclCheckReadiness.d.ts +11 -0
  2. package/es/index.d.ts +2 -1
  3. package/es/index.mjs +46 -46
  4. package/es/nocobase-buildin-plugin/index.d.ts +1 -0
  5. package/es/settings-app/SettingsGroupNav.d.ts +3 -2
  6. package/es/settings-app/SettingsSearch.d.ts +1 -1
  7. package/es/ui-operation/index.d.ts +15 -0
  8. package/es/ui-operation/ui-operation-codec.d.ts +10 -0
  9. package/lib/index.js +65 -65
  10. package/package.json +7 -7
  11. package/src/__tests__/browserChecker.test.ts +44 -33
  12. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +37 -0
  13. package/src/__tests__/settings-center.test.tsx +110 -10
  14. package/src/__tests__/settings-layout-root.test.tsx +346 -6
  15. package/src/__tests__/settings-runtime-paths.test.ts +1 -1
  16. package/src/__tests__/settings-search-shortcut.test.tsx +321 -0
  17. package/src/__tests__/settings-shell.test.tsx +35 -0
  18. package/src/acl/ACLProvider.tsx +32 -3
  19. package/src/acl/aclCheckReadiness.ts +44 -0
  20. package/src/index.ts +2 -0
  21. package/src/layout-manager/LayoutContentRoute.tsx +2 -1
  22. package/src/layout-manager/LayoutRoute.tsx +2 -1
  23. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +87 -1
  24. package/src/nocobase-buildin-plugin/index.tsx +14 -1
  25. package/src/settings-app/SettingsGroupNav.tsx +11 -11
  26. package/src/settings-app/SettingsSearch.tsx +115 -10
  27. package/src/settings-app/SettingsShell.tsx +4 -2
  28. package/src/settings-app/runtimePaths.ts +0 -1
  29. package/src/settings-app/settingsTheme.ts +82 -14
  30. package/src/settings-center/AdminSettingsLayout.tsx +96 -106
  31. package/src/ui-operation/index.ts +33 -0
  32. package/src/ui-operation/ui-operation-codec.ts +54 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "3.0.0-alpha.3",
3
+ "version": "3.0.0-alpha.4",
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": "3.0.0-alpha.3",
31
- "@nocobase/flow-engine": "3.0.0-alpha.3",
32
- "@nocobase/sdk": "3.0.0-alpha.3",
33
- "@nocobase/shared": "3.0.0-alpha.3",
34
- "@nocobase/utils": "3.0.0-alpha.3",
30
+ "@nocobase/evaluators": "3.0.0-alpha.4",
31
+ "@nocobase/flow-engine": "3.0.0-alpha.4",
32
+ "@nocobase/sdk": "3.0.0-alpha.4",
33
+ "@nocobase/shared": "3.0.0-alpha.4",
34
+ "@nocobase/utils": "3.0.0-alpha.4",
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": "9899cd799c070c7bd85140148ea5b100b046bdc7"
51
+ "gitHead": "6658768567378382de758c4e814ac510d688400c"
52
52
  }
@@ -136,6 +136,17 @@ describe.each(browserCheckerCases)('$label', ({ scriptPath }) => {
136
136
  expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/v/');
137
137
  });
138
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
+
139
150
  it('does not redirect legacy deep links for modern-default', () => {
140
151
  const replace = executeBrowserChecker(scriptPath, {
141
152
  pathname: '/admin',
@@ -182,32 +193,36 @@ describe.each(browserCheckerCases)('$label', ({ scriptPath }) => {
182
193
  }
183
194
 
184
195
  if (scriptPath.includes('/app/client-v2/public/')) {
185
- it.each(['/v', '/v/'])('redirects the main modern root %s to Settings', (pathname) => {
196
+ it.each([
197
+ ['/v', 'http://c.local.nocobase.com/v/'],
198
+ ['/v/', undefined],
199
+ ])('normalizes the modern client root %s without redirecting to Settings', (pathname, expectedRedirect) => {
186
200
  const replace = executeBrowserChecker(scriptPath, {
187
201
  pathname,
188
202
  publicPath: '/v/',
189
203
  modernClientPrefix: 'v',
190
204
  });
191
205
 
192
- expect(replace).toHaveBeenCalledOnce();
193
- expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/settings');
206
+ if (expectedRedirect) {
207
+ expect(replace).toHaveBeenCalledOnce();
208
+ expect(replace).toHaveBeenCalledWith(expectedRedirect);
209
+ } else {
210
+ expect(replace).not.toHaveBeenCalled();
211
+ }
194
212
  });
195
213
 
196
- it.each([
197
- ['/v/apps/demo', '/settings/apps/demo'],
198
- ['/v/apps/demo/', '/settings/apps/demo'],
199
- ['/v/_app/demo', '/settings/_app/demo'],
200
- ['/v/_app/demo/', '/settings/_app/demo'],
201
- ])('redirects the scoped modern root %s to %s', (pathname, expectedPathname) => {
202
- const replace = executeBrowserChecker(scriptPath, {
203
- pathname,
204
- publicPath: '/v/',
205
- modernClientPrefix: 'v',
206
- });
214
+ it.each(['/v/apps/demo', '/v/apps/demo/', '/v/_app/demo', '/v/_app/demo/'])(
215
+ 'lets the scoped modern client handle its root %s',
216
+ (pathname) => {
217
+ const replace = executeBrowserChecker(scriptPath, {
218
+ pathname,
219
+ publicPath: '/v/',
220
+ modernClientPrefix: 'v',
221
+ });
207
222
 
208
- expect(replace).toHaveBeenCalledOnce();
209
- expect(replace).toHaveBeenCalledWith(`http://c.local.nocobase.com${expectedPathname}`);
210
- });
223
+ expect(replace).not.toHaveBeenCalled();
224
+ },
225
+ );
211
226
 
212
227
  it.each([
213
228
  ['/settings/apps/demo', '/', '/settings/apps/demo/', 'v'],
@@ -233,23 +248,19 @@ describe.each(browserCheckerCases)('$label', ({ scriptPath }) => {
233
248
  });
234
249
 
235
250
  it.each([
236
- ['/tenant/apps/root/modern/apps/demo/', '/tenant/apps/root/modern/', '/tenant/apps/root/settings/apps/demo'],
237
- ['/tenant/_app/root/modern/_app/demo/', '/tenant/_app/root/modern/', '/tenant/_app/root/settings/_app/demo'],
238
- ])(
239
- 'preserves public path, query, and hash for %s without treating public-path segments as app scope',
240
- (pathname, publicPath, expectedPathname) => {
241
- const replace = executeBrowserChecker(scriptPath, {
242
- pathname,
243
- publicPath,
244
- modernClientPrefix: 'modern',
245
- search: '?tab=overview',
246
- hash: '#panel',
247
- });
251
+ ['/tenant/apps/root/modern/apps/demo/', '/tenant/apps/root/modern/'],
252
+ ['/tenant/_app/root/modern/_app/demo/', '/tenant/_app/root/modern/'],
253
+ ])('lets the modern client handle %s under a nested public path', (pathname, publicPath) => {
254
+ const replace = executeBrowserChecker(scriptPath, {
255
+ pathname,
256
+ publicPath,
257
+ modernClientPrefix: 'modern',
258
+ search: '?tab=overview',
259
+ hash: '#panel',
260
+ });
248
261
 
249
- expect(replace).toHaveBeenCalledOnce();
250
- expect(replace).toHaveBeenCalledWith(`http://c.local.nocobase.com${expectedPathname}?tab=overview#panel`);
251
- },
252
- );
262
+ expect(replace).not.toHaveBeenCalled();
263
+ });
253
264
 
254
265
  it.each([
255
266
  ['/v/admin', '/v/'],
@@ -72,6 +72,21 @@ class TestRootPortalPlugin extends Plugin {
72
72
  }
73
73
  }
74
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
+
75
90
  describe('nocobase buildin plugin auth redirect', () => {
76
91
  const originalLocation = globalThis.window.location;
77
92
 
@@ -187,6 +202,28 @@ describe('nocobase buildin plugin auth redirect', () => {
187
202
  });
188
203
  });
189
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
+ );
224
+ });
225
+ });
226
+
190
227
  it('should check current user after navigating from skipped route to v2 admin', async () => {
191
228
  const app = createMockClient({
192
229
  publicPath: '/v2/',
@@ -8,11 +8,12 @@
8
8
  */
9
9
 
10
10
  import { ACLRolesCheckProvider, Plugin } from '@nocobase/client-v2';
11
- import { render, screen, waitFor, fireEvent } from '@testing-library/react';
11
+ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
12
12
  import React from 'react';
13
13
  import { message } from 'antd';
14
14
  import { AdminSettingsLayoutModel as ClientV2AdminSettingsLayoutModel } from '../settings-center';
15
15
  import { AdminSettingsLayoutModel as ClientV1AdminSettingsLayoutModel } from '../../../client/src/pm/AdminSettingsLayoutModel';
16
+ import zhCN from '../../../client/src/locale/zh-CN.json';
16
17
  import { SettingsBuildInPlugin } from '../settings-app/SettingsBuildInPlugin';
17
18
  import { matchSettingsRoute, sortTopLevelSettings } from '../settings-center/utils';
18
19
  import { createMockSettingsClient } from './mockSettingsApplication';
@@ -44,12 +45,20 @@ const waitForGetRequests = async (app: MockClientApplication, urls: string[]) =>
44
45
  const mockAdminRuntime = (
45
46
  app: MockClientApplication,
46
47
  options: {
48
+ lang?: string;
47
49
  snippets?: string[];
48
50
  pmList?: any[];
51
+ resources?: Record<string, string>;
49
52
  systemSettings?: Record<string, any>;
50
53
  } = {},
51
54
  ) => {
52
- const { snippets = ['pm', 'pm.system-settings.system-settings'], pmList = [], systemSettings = {} } = options;
55
+ const {
56
+ lang = 'en-US',
57
+ snippets = ['pm', 'pm.system-settings.system-settings'],
58
+ pmList = [],
59
+ resources = {},
60
+ systemSettings = {},
61
+ } = options;
53
62
 
54
63
  app.dataSourceManager.getCollection = ((name: string, collectionName: string) => {
55
64
  if (name === 'main' && collectionName === 'attachments') {
@@ -74,9 +83,9 @@ const mockAdminRuntime = (
74
83
  });
75
84
  app.apiMock.onGet('app:getLang').reply(200, {
76
85
  data: {
77
- lang: 'en-US',
86
+ lang,
78
87
  resources: {
79
- client: {},
88
+ client: resources,
80
89
  },
81
90
  cron: {},
82
91
  },
@@ -270,7 +279,11 @@ describe('settings center', () => {
270
279
  await renderApp(app);
271
280
  await waitForGetRequests(app, ['/auth:check', 'roles:check']);
272
281
 
273
- expect(await screen.findByText('Current settings page is unavailable')).toBeInTheDocument();
282
+ const result = await screen.findByRole('status');
283
+ expect(within(result).getByRole('heading', { name: 'Settings page not found' })).toBeInTheDocument();
284
+ expect(
285
+ within(result).getByText('The settings page you requested does not exist or has been removed.'),
286
+ ).toBeInTheDocument();
274
287
  });
275
288
 
276
289
  it('should allow direct access to hidden page without showing menu entry', async () => {
@@ -319,16 +332,98 @@ describe('settings center', () => {
319
332
  router: { type: 'memory', initialEntries: ['/settings/secure-demo'] },
320
333
  });
321
334
  mockAdminRuntime(app, {
335
+ lang: 'zh-CN',
336
+ resources: zhCN,
322
337
  snippets: ['pm', 'pm.system-settings.system-settings', '!pm.secure-demo.index'],
323
338
  });
324
339
 
325
340
  await renderApp(app);
326
341
  await waitForGetRequests(app, ['/auth:check', 'roles:check']);
327
342
 
328
- expect(await screen.findByText('Current settings page is unavailable')).toBeInTheDocument();
343
+ const result = await screen.findByRole('status');
344
+ expect(within(result).getByRole('heading', { name: '当前角色无权访问设置中心' })).toBeInTheDocument();
345
+ expect(within(result).getByText('请切换至有权限的角色,或联系管理员获取访问权限。')).toBeInTheDocument();
329
346
  expect(screen.queryByText('Secure settings page')).not.toBeInTheDocument();
330
347
  });
331
348
 
349
+ it('should deny a protected child page when a sibling remains visible', async () => {
350
+ class MixedAccessSettingsPlugin extends Plugin {
351
+ async load() {
352
+ this.pluginSettingsManager.addMenuItem({ key: 'mixed-access', title: 'Mixed access' });
353
+ this.pluginSettingsManager.addPageTabItem({
354
+ menuKey: 'mixed-access',
355
+ key: 'index',
356
+ title: 'Allowed settings',
357
+ Component: () => <div>Allowed settings page</div>,
358
+ });
359
+ this.pluginSettingsManager.addPageTabItem({
360
+ menuKey: 'mixed-access',
361
+ key: 'restricted',
362
+ title: 'Restricted settings',
363
+ aclSnippet: 'pm.mixed-access.restricted',
364
+ Component: () => <div>Restricted settings page</div>,
365
+ });
366
+ }
367
+ }
368
+
369
+ const app = createMockSettingsClient({
370
+ plugins: [SettingsBuildInPlugin, TestAclPlugin, MixedAccessSettingsPlugin],
371
+ router: { type: 'memory', initialEntries: ['/settings/mixed-access/restricted'] },
372
+ });
373
+ mockAdminRuntime(app, {
374
+ snippets: ['pm', 'pm.system-settings.system-settings', '!pm.mixed-access.restricted'],
375
+ });
376
+
377
+ await renderApp(app);
378
+ await waitForGetRequests(app, ['/auth:check', 'roles:check']);
379
+
380
+ expect(
381
+ await screen.findByRole('heading', { name: 'Your current role cannot access Settings' }),
382
+ ).toBeInTheDocument();
383
+ expect(screen.queryByText('Restricted settings page')).not.toBeInTheDocument();
384
+ });
385
+
386
+ it.each(['/settings/fallback-access', '/settings/fallback-access/'])(
387
+ 'should redirect a denied index page to an accessible sibling from %s',
388
+ async (initialEntry) => {
389
+ class FallbackAccessSettingsPlugin extends Plugin {
390
+ async load() {
391
+ this.pluginSettingsManager.addMenuItem({ key: 'fallback-access', title: 'Fallback access' });
392
+ this.pluginSettingsManager.addPageTabItem({
393
+ menuKey: 'fallback-access',
394
+ key: 'index',
395
+ title: 'Restricted index settings',
396
+ aclSnippet: 'pm.fallback-access.index',
397
+ Component: () => <div>Restricted index settings page</div>,
398
+ });
399
+ this.pluginSettingsManager.addPageTabItem({
400
+ menuKey: 'fallback-access',
401
+ key: 'allowed',
402
+ title: 'Allowed fallback settings',
403
+ Component: () => <div>Allowed fallback settings page</div>,
404
+ });
405
+ }
406
+ }
407
+
408
+ const app = createMockSettingsClient({
409
+ plugins: [SettingsBuildInPlugin, TestAclPlugin, FallbackAccessSettingsPlugin],
410
+ router: { type: 'memory', initialEntries: [initialEntry] },
411
+ });
412
+ mockAdminRuntime(app, {
413
+ snippets: ['pm', 'pm.system-settings.system-settings', '!pm.fallback-access.index'],
414
+ });
415
+
416
+ await renderApp(app);
417
+ await waitForGetRequests(app, ['/auth:check', 'roles:check']);
418
+
419
+ expect(await screen.findByText('Allowed fallback settings page')).toBeInTheDocument();
420
+ expect(
421
+ screen.queryByRole('heading', { name: 'Your current role cannot access Settings' }),
422
+ ).not.toBeInTheDocument();
423
+ expect(screen.queryByText('Restricted index settings page')).not.toBeInTheDocument();
424
+ },
425
+ );
426
+
332
427
  it('should keep menu visible when menu acl is denied but child page is visible', async () => {
333
428
  class MenuAclPlugin extends Plugin {
334
429
  async load() {
@@ -358,10 +453,13 @@ describe('settings center', () => {
358
453
  await waitForGetRequests(app, ['/auth:check', 'roles:check']);
359
454
 
360
455
  expect(await screen.findByText('Menu ACL child page')).toBeInTheDocument();
361
- expect(screen.getByRole('menuitem', { name: 'Menu ACL Demo' })).toBeInTheDocument();
456
+ // The sidebar is gone; this group is reached from the "other settings" hover
457
+ // dropdown in the top bar. The dropdown only renders on hover, so this just
458
+ // checks the group entry survived (ACL did not drop the whole menu).
459
+ expect(screen.getByRole('menuitem', { name: 'Other settings' })).toBeInTheDocument();
362
460
  });
363
461
 
364
- it('should allow the settings sidebar menu to scroll independently', async () => {
462
+ it('should keep rendering the active page when a group has many settings', async () => {
365
463
  class ManySettingsPlugin extends Plugin {
366
464
  async load() {
367
465
  for (let index = 0; index < 30; index += 1) {
@@ -390,8 +488,10 @@ describe('settings center', () => {
390
488
 
391
489
  expect(await screen.findByText('Scroll demo page 29')).toBeInTheDocument();
392
490
 
393
- const sidebar = screen.getByRole('menuitem', { name: 'Scroll demo 29' }).closest('.ant-layout-sider');
394
- expect(sidebar).toHaveStyle({ overflowY: 'auto' });
491
+ // Without the sidebar there is no separate scroll area: all 30 settings live in
492
+ // the top bar dropdown and the page renders only the active one, so this just
493
+ // checks it still holds up.
494
+ expect(document.querySelector('.ant-layout-sider')).toBeNull();
395
495
  });
396
496
 
397
497
  it('should save system settings through systemSettings:put', async () => {