@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.
- package/es/acl/aclCheckReadiness.d.ts +11 -0
- package/es/index.d.ts +2 -1
- package/es/index.mjs +46 -46
- package/es/nocobase-buildin-plugin/index.d.ts +1 -0
- package/es/settings-app/SettingsGroupNav.d.ts +3 -2
- package/es/settings-app/SettingsSearch.d.ts +1 -1
- package/es/ui-operation/index.d.ts +15 -0
- package/es/ui-operation/ui-operation-codec.d.ts +10 -0
- package/lib/index.js +65 -65
- package/package.json +7 -7
- package/src/__tests__/browserChecker.test.ts +44 -33
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +37 -0
- package/src/__tests__/settings-center.test.tsx +110 -10
- package/src/__tests__/settings-layout-root.test.tsx +346 -6
- package/src/__tests__/settings-runtime-paths.test.ts +1 -1
- package/src/__tests__/settings-search-shortcut.test.tsx +321 -0
- package/src/__tests__/settings-shell.test.tsx +35 -0
- package/src/acl/ACLProvider.tsx +32 -3
- package/src/acl/aclCheckReadiness.ts +44 -0
- package/src/index.ts +2 -0
- package/src/layout-manager/LayoutContentRoute.tsx +2 -1
- package/src/layout-manager/LayoutRoute.tsx +2 -1
- package/src/layout-manager/__tests__/LayoutRoute.test.tsx +87 -1
- package/src/nocobase-buildin-plugin/index.tsx +14 -1
- package/src/settings-app/SettingsGroupNav.tsx +11 -11
- package/src/settings-app/SettingsSearch.tsx +115 -10
- package/src/settings-app/SettingsShell.tsx +4 -2
- package/src/settings-app/runtimePaths.ts +0 -1
- package/src/settings-app/settingsTheme.ts +82 -14
- package/src/settings-center/AdminSettingsLayout.tsx +96 -106
- package/src/ui-operation/index.ts +33 -0
- 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
|
+
"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.
|
|
31
|
-
"@nocobase/flow-engine": "3.0.0-alpha.
|
|
32
|
-
"@nocobase/sdk": "3.0.0-alpha.
|
|
33
|
-
"@nocobase/shared": "3.0.0-alpha.
|
|
34
|
-
"@nocobase/utils": "3.0.0-alpha.
|
|
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": "
|
|
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([
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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/'
|
|
237
|
-
['/tenant/_app/root/modern/_app/demo/', '/tenant/_app/root/modern/'
|
|
238
|
-
])(
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
250
|
-
|
|
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,
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
394
|
-
|
|
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 () => {
|