@nocobase/client-v2 2.1.0-beta.35 → 2.1.0-beta.36
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/BaseApplication.d.ts +1 -1
- package/es/components/PoweredBy.d.ts +18 -0
- package/es/components/SwitchLanguage.d.ts +11 -0
- package/es/components/form/DialogFormLayout.d.ts +75 -0
- package/es/components/form/DrawerFormLayout.d.ts +11 -11
- package/es/components/form/PasswordInput.d.ts +40 -0
- package/es/components/form/RemoteSelect.d.ts +79 -0
- package/es/components/form/index.d.ts +3 -0
- package/es/components/form/table/styles.d.ts +10 -0
- package/es/components/index.d.ts +2 -0
- package/es/flow/models/base/ActionModelCore.d.ts +6 -0
- package/es/flow/models/base/GridModel.d.ts +2 -0
- package/es/flow/utils/dataScopeFormValueClear.d.ts +14 -0
- package/es/flow-compat/passwordUtils.d.ts +1 -1
- package/es/hooks/index.d.ts +2 -0
- package/es/hooks/useCurrentAppInfo.d.ts +9 -0
- package/es/index.mjs +102 -90
- package/es/nocobase-buildin-plugin/index.d.ts +25 -0
- package/es/utils/appVersionHTML.d.ts +10 -0
- package/es/utils/index.d.ts +1 -0
- package/es/utils/remotePlugins.d.ts +4 -1
- package/lib/index.js +108 -96
- package/package.json +7 -7
- package/src/BaseApplication.tsx +3 -3
- package/src/PluginSettingsManager.ts +2 -1
- package/src/__tests__/PluginSettingsManager.test.ts +19 -0
- package/src/__tests__/PoweredBy.test.tsx +130 -0
- package/src/__tests__/app.test.tsx +31 -0
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +39 -72
- package/src/__tests__/remotePlugins.test.ts +55 -0
- package/src/__tests__/useCurrentRoles.test.tsx +100 -0
- package/src/components/PoweredBy.tsx +71 -0
- package/src/components/README.md +314 -0
- package/src/components/README.zh-CN.md +312 -0
- package/src/components/SwitchLanguage.tsx +48 -0
- package/src/components/form/DialogFormLayout.tsx +111 -0
- package/src/components/form/DrawerFormLayout.tsx +13 -32
- package/src/components/form/PasswordInput.tsx +211 -0
- package/src/components/form/RemoteSelect.tsx +137 -0
- package/src/components/form/index.tsx +3 -0
- package/src/components/form/table/Table.tsx +2 -1
- package/src/components/form/table/styles.ts +19 -0
- package/src/components/index.ts +2 -0
- package/src/css-variable/CSSVariableProvider.tsx +10 -1
- package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +96 -0
- package/src/flow/actions/dataScope.tsx +3 -0
- package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -4
- package/src/flow/admin-shell/admin-layout/HelpLite.tsx +7 -33
- package/src/flow/components/BlockItemCard.tsx +2 -2
- package/src/flow/models/base/ActionModel.tsx +8 -7
- package/src/flow/models/base/ActionModelCore.tsx +15 -7
- package/src/flow/models/base/GridModel.tsx +93 -36
- package/src/flow/models/base/__tests__/GridModel.visibleLayout.test.ts +83 -11
- package/src/flow/models/blocks/details/DetailsItemModel.tsx +2 -0
- package/src/flow/models/blocks/form/FormItemModel.tsx +5 -3
- package/src/flow/models/blocks/form/__tests__/FormItemModel.defineChildren.test.ts +108 -0
- package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +5 -0
- package/src/flow/models/blocks/table/TableColumnModel.tsx +2 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +2 -0
- package/src/flow/utils/dataScopeFormValueClear.ts +278 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useCurrentAppInfo.ts +36 -0
- package/src/nocobase-buildin-plugin/index.tsx +70 -16
- package/src/utils/appVersionHTML.ts +28 -0
- package/src/utils/globalDeps.ts +2 -2
- package/src/utils/index.tsx +2 -0
- package/src/utils/remotePlugins.ts +12 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/client-v2",
|
|
3
|
-
"version": "2.1.0-beta.
|
|
3
|
+
"version": "2.1.0-beta.36",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"module": "es/index.mjs",
|
|
@@ -26,11 +26,11 @@
|
|
|
26
26
|
"@formily/antd-v5": "1.2.3",
|
|
27
27
|
"@formily/react": "^2.2.27",
|
|
28
28
|
"@formily/shared": "^2.2.27",
|
|
29
|
-
"@nocobase/evaluators": "2.1.0-beta.
|
|
30
|
-
"@nocobase/flow-engine": "2.1.0-beta.
|
|
31
|
-
"@nocobase/sdk": "2.1.0-beta.
|
|
32
|
-
"@nocobase/shared": "2.1.0-beta.
|
|
33
|
-
"@nocobase/utils": "2.1.0-beta.
|
|
29
|
+
"@nocobase/evaluators": "2.1.0-beta.36",
|
|
30
|
+
"@nocobase/flow-engine": "2.1.0-beta.36",
|
|
31
|
+
"@nocobase/sdk": "2.1.0-beta.36",
|
|
32
|
+
"@nocobase/shared": "2.1.0-beta.36",
|
|
33
|
+
"@nocobase/utils": "2.1.0-beta.36",
|
|
34
34
|
"ahooks": "^3.7.2",
|
|
35
35
|
"antd": "5.24.2",
|
|
36
36
|
"antd-style": "3.7.1",
|
|
@@ -44,5 +44,5 @@
|
|
|
44
44
|
"react-i18next": "^11.15.1",
|
|
45
45
|
"react-router-dom": "^6.30.1"
|
|
46
46
|
},
|
|
47
|
-
"gitHead": "
|
|
47
|
+
"gitHead": "397d45c744f6eb48b3a0cd785c87cbf1257c3513"
|
|
48
48
|
}
|
package/src/BaseApplication.tsx
CHANGED
|
@@ -368,11 +368,11 @@ export abstract class BaseApplication<
|
|
|
368
368
|
});
|
|
369
369
|
}
|
|
370
370
|
|
|
371
|
-
updateFavicon(favicon?: string) {
|
|
371
|
+
updateFavicon(favicon?: string | null) {
|
|
372
372
|
let faviconLinkElement = document.querySelector('link[rel="shortcut icon"]') as HTMLLinkElement;
|
|
373
373
|
|
|
374
|
-
if (
|
|
375
|
-
this.favicon = favicon;
|
|
374
|
+
if (arguments.length > 0) {
|
|
375
|
+
this.favicon = favicon || '';
|
|
376
376
|
}
|
|
377
377
|
|
|
378
378
|
const iconHref = this.favicon || '/favicon/favicon.ico';
|
|
@@ -432,7 +432,7 @@ export class PluginSettingsManager<TApp extends BaseApplication<any> = BaseAppli
|
|
|
432
432
|
return null;
|
|
433
433
|
}
|
|
434
434
|
|
|
435
|
-
const { title, aclSnippet, key, menuKey, name, ...others } = page;
|
|
435
|
+
const { title, aclSnippet, key, menuKey, name, icon, ...others } = page;
|
|
436
436
|
|
|
437
437
|
return {
|
|
438
438
|
...others,
|
|
@@ -443,6 +443,7 @@ export class PluginSettingsManager<TApp extends BaseApplication<any> = BaseAppli
|
|
|
443
443
|
name,
|
|
444
444
|
title,
|
|
445
445
|
label: title,
|
|
446
|
+
icon: this.renderIcon(icon),
|
|
446
447
|
path: this.getRoutePath(name),
|
|
447
448
|
sort: page.sort,
|
|
448
449
|
isAllow,
|
|
@@ -76,6 +76,25 @@ describe('PluginSettingsManager v2', () => {
|
|
|
76
76
|
expect(app.router.get('admin.settings.demo.advanced')).toMatchObject({ path: 'advanced' });
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
it('should render string icon on both menu and page tab via renderIcon', () => {
|
|
80
|
+
// Previously `renderPage` spread the raw `icon` string straight to the antd
|
|
81
|
+
// Menu item, which displayed "LockOutlinedTitle" as text. Both `renderMenuItem`
|
|
82
|
+
// and `renderPage` must coerce string icon names to React elements.
|
|
83
|
+
const app = createMockClient();
|
|
84
|
+
|
|
85
|
+
app.pluginSettingsManager.addMenuItem({ key: 'demo', title: 'Demo', icon: 'TeamOutlined' });
|
|
86
|
+
app.pluginSettingsManager.addPageTabItem({
|
|
87
|
+
menuKey: 'demo',
|
|
88
|
+
key: 'index',
|
|
89
|
+
title: 'Overview',
|
|
90
|
+
icon: 'LockOutlined',
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const list = app.pluginSettingsManager.getList();
|
|
94
|
+
expect(React.isValidElement(list[0].icon)).toBe(true);
|
|
95
|
+
expect(React.isValidElement(list[0].children?.[0].icon)).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
79
98
|
it('should support componentLoader on page item', () => {
|
|
80
99
|
const app = createMockClient();
|
|
81
100
|
const componentLoader = async () => ({
|
|
@@ -0,0 +1,130 @@
|
|
|
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 { render, screen, waitFor } from '@testing-library/react';
|
|
11
|
+
import React from 'react';
|
|
12
|
+
import { createMockClient } from '../MockApplication';
|
|
13
|
+
import { Plugin } from '../Plugin';
|
|
14
|
+
import PoweredBy from '../components/PoweredBy';
|
|
15
|
+
|
|
16
|
+
class PoweredByRoutePlugin extends Plugin {
|
|
17
|
+
async load() {
|
|
18
|
+
this.router.add('root', {
|
|
19
|
+
path: '/',
|
|
20
|
+
Component: PoweredBy,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class MockCustomBrandPlugin extends Plugin {}
|
|
26
|
+
|
|
27
|
+
const renderPoweredBy = async (plugins: any[] = [], appInfoData: Record<string, any> = { version: '1.2.3' }) => {
|
|
28
|
+
const app = createMockClient({
|
|
29
|
+
plugins: [PoweredByRoutePlugin as any, ...plugins],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
app.apiMock.onGet('app:getInfo').reply(200, {
|
|
33
|
+
data: appInfoData,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const Root = app.getRootComponent();
|
|
37
|
+
const result = render(<Root />);
|
|
38
|
+
|
|
39
|
+
await waitFor(() => {
|
|
40
|
+
expect(document.querySelector('.ant-spin-spinning')).not.toBeInTheDocument();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return result;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
describe('PoweredBy', () => {
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
vi.restoreAllMocks();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should render the default brand when custom-brand is not installed', async () => {
|
|
52
|
+
const { container } = await renderPoweredBy();
|
|
53
|
+
|
|
54
|
+
expect(screen.getByRole('link', { name: 'NocoBase' })).toHaveAttribute('href', 'https://www.nocobase.com');
|
|
55
|
+
expect(container).toHaveTextContent('Powered by NocoBase');
|
|
56
|
+
// The `.nb-brand` className is reserved for the custom-brand HTML branch
|
|
57
|
+
// so downstream stylesheets can selectively target customised content
|
|
58
|
+
// without leaking onto the default footer.
|
|
59
|
+
expect(container.querySelector('.nb-brand')).not.toBeInTheDocument();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should render custom-brand HTML and replace appVersion', async () => {
|
|
63
|
+
const { container } = await renderPoweredBy([
|
|
64
|
+
[
|
|
65
|
+
MockCustomBrandPlugin,
|
|
66
|
+
{
|
|
67
|
+
packageName: '@nocobase/plugin-custom-brand',
|
|
68
|
+
options: {
|
|
69
|
+
brand: '<span>Custom Brand</span>{{appVersion}}',
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
await waitFor(() => {
|
|
76
|
+
expect(container.querySelector('.nb-brand')).toHaveTextContent('Custom Brandv1.2.3');
|
|
77
|
+
});
|
|
78
|
+
expect(container.querySelector('.nb-app-version')).toHaveTextContent('v1.2.3');
|
|
79
|
+
expect(screen.queryByText('Powered by')).not.toBeInTheDocument();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should not render undefined appVersion when app version is unavailable', async () => {
|
|
83
|
+
const { container } = await renderPoweredBy(
|
|
84
|
+
[
|
|
85
|
+
[
|
|
86
|
+
MockCustomBrandPlugin,
|
|
87
|
+
{
|
|
88
|
+
packageName: '@nocobase/plugin-custom-brand',
|
|
89
|
+
options: {
|
|
90
|
+
brand: '<span>Custom Brand</span>{{appVersion}}',
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
],
|
|
95
|
+
{},
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(container.querySelector('.nb-brand')).toHaveTextContent('Custom Brand');
|
|
99
|
+
expect(container.querySelector('.nb-brand')).not.toHaveTextContent('undefined');
|
|
100
|
+
expect(container.querySelector('.nb-app-version')).not.toBeInTheDocument();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should escape custom-brand appVersion placeholder', async () => {
|
|
104
|
+
// Defence in depth: even if the back-end ever returns a tampered
|
|
105
|
+
// `app:getInfo` payload, the version string must be HTML-escaped
|
|
106
|
+
// before being interpolated into the custom-brand template — never
|
|
107
|
+
// produce a live `<script>` node in the DOM.
|
|
108
|
+
const { container } = await renderPoweredBy(
|
|
109
|
+
[
|
|
110
|
+
[
|
|
111
|
+
MockCustomBrandPlugin,
|
|
112
|
+
{
|
|
113
|
+
packageName: '@nocobase/plugin-custom-brand',
|
|
114
|
+
options: {
|
|
115
|
+
brand: '<span>Custom Brand</span>{{appVersion}}',
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
],
|
|
120
|
+
{
|
|
121
|
+
version: '<script>alert(1)</script>&"',
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
await waitFor(() => {
|
|
126
|
+
expect(container.querySelector('.nb-app-version')).toHaveTextContent('v<script>alert(1)</script>&"');
|
|
127
|
+
});
|
|
128
|
+
expect(container.querySelector('.nb-brand script')).not.toBeInTheDocument();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -12,6 +12,7 @@ import { useFlowEngineContext } from '@nocobase/flow-engine';
|
|
|
12
12
|
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
13
13
|
import React from 'react';
|
|
14
14
|
import { Outlet } from 'react-router-dom';
|
|
15
|
+
import { escapeHTML, getAppVersionHTML } from '../utils';
|
|
15
16
|
|
|
16
17
|
const waitForAppReady = async () => {
|
|
17
18
|
await waitFor(() => {
|
|
@@ -66,6 +67,36 @@ describe('app', () => {
|
|
|
66
67
|
expect(favicon.getAttribute('href')).toBe('/custom-favicon.ico');
|
|
67
68
|
});
|
|
68
69
|
|
|
70
|
+
it('should reset favicon to default when favicon is cleared', () => {
|
|
71
|
+
const app = new Application({ router });
|
|
72
|
+
|
|
73
|
+
app.updateFavicon('/custom-favicon.ico');
|
|
74
|
+
app.updateFavicon(null);
|
|
75
|
+
|
|
76
|
+
const favicon = document.querySelector('link[rel="shortcut icon"]') as HTMLLinkElement;
|
|
77
|
+
expect(favicon).toBeInTheDocument();
|
|
78
|
+
expect(favicon.getAttribute('href')).toBe('/favicon/favicon.ico');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should reset favicon to default when favicon is explicitly undefined', () => {
|
|
82
|
+
const app = new Application({ router });
|
|
83
|
+
|
|
84
|
+
app.updateFavicon('/custom-favicon.ico');
|
|
85
|
+
app.updateFavicon(undefined);
|
|
86
|
+
|
|
87
|
+
const favicon = document.querySelector('link[rel="shortcut icon"]') as HTMLLinkElement;
|
|
88
|
+
expect(favicon).toBeInTheDocument();
|
|
89
|
+
expect(favicon.getAttribute('href')).toBe('/favicon/favicon.ico');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should escape app version html placeholder content', () => {
|
|
93
|
+
expect(getAppVersionHTML('<script>alert(1)</script>&"')).toBe(
|
|
94
|
+
'<span class="nb-app-version">v<script>alert(1)</script>&"</span>',
|
|
95
|
+
);
|
|
96
|
+
expect(getAppVersionHTML(undefined)).toBe('');
|
|
97
|
+
expect(escapeHTML("NocoBase <v2> & 'beta'")).toBe('NocoBase <v2> & 'beta'');
|
|
98
|
+
});
|
|
99
|
+
|
|
69
100
|
it('should reject invalid component objects but keep valid exotic components', () => {
|
|
70
101
|
const app = new Application({ router });
|
|
71
102
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
@@ -39,30 +39,18 @@ describe('nocobase buildin plugin auth redirect', () => {
|
|
|
39
39
|
vi.restoreAllMocks();
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
-
it('should
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
...originalLocation,
|
|
48
|
-
pathname: '/v2/admin/7vu4c2sdk6h',
|
|
49
|
-
search: '',
|
|
50
|
-
hash: '',
|
|
51
|
-
replace,
|
|
52
|
-
},
|
|
53
|
-
});
|
|
54
|
-
|
|
42
|
+
it('should navigate to v2 signin when /auth:check returns no user', async () => {
|
|
43
|
+
// Aligns with v1: use react-router navigate (virtual) rather than
|
|
44
|
+
// `window.location.replace`, so a `window.location.href` queued elsewhere
|
|
45
|
+
// (e.g. 2FA's `code:302` response interceptor) can commit instead of being
|
|
46
|
+
// overridden.
|
|
55
47
|
const app = createMockClient({
|
|
56
48
|
publicPath: '/v2/',
|
|
57
49
|
plugins: [NocoBaseBuildInPlugin as any],
|
|
58
50
|
router: { type: 'memory', initialEntries: ['/v2/admin/7vu4c2sdk6h'] },
|
|
59
51
|
});
|
|
60
52
|
app.apiMock.onGet('app:getLang').reply(200, {
|
|
61
|
-
data: {
|
|
62
|
-
lang: 'en-US',
|
|
63
|
-
resources: { client: {} },
|
|
64
|
-
cron: {},
|
|
65
|
-
},
|
|
53
|
+
data: { lang: 'en-US', resources: { client: {} }, cron: {} },
|
|
66
54
|
});
|
|
67
55
|
app.apiMock.onGet('/auth:check').reply(200, { data: {} });
|
|
68
56
|
|
|
@@ -70,68 +58,63 @@ describe('nocobase buildin plugin auth redirect', () => {
|
|
|
70
58
|
render(<Root />);
|
|
71
59
|
|
|
72
60
|
await waitFor(() => {
|
|
73
|
-
expect(
|
|
61
|
+
expect(app.router.router.state.location.pathname).toBe('/v2/signin');
|
|
62
|
+
expect(app.router.router.state.location.search).toBe('?redirect=%2Fv2%2Fadmin%2F7vu4c2sdk6h');
|
|
74
63
|
});
|
|
75
64
|
});
|
|
76
65
|
|
|
77
|
-
it('should
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
},
|
|
66
|
+
it('should short-circuit /auth:check when server returns code:302 instead of redirecting to signin', async () => {
|
|
67
|
+
// When the server signals an intermediate redirect (typically 2FA verify),
|
|
68
|
+
// CurrentUserProvider must NOT treat the missing `user.id` as "logged out"
|
|
69
|
+
// and race the 2FA response interceptor with its own signin redirect.
|
|
70
|
+
const app = createMockClient({
|
|
71
|
+
publicPath: '/v2/',
|
|
72
|
+
plugins: [NocoBaseBuildInPlugin as any],
|
|
73
|
+
router: { type: 'memory', initialEntries: ['/v2/admin'] },
|
|
74
|
+
});
|
|
75
|
+
app.apiMock.onGet('app:getLang').reply(200, {
|
|
76
|
+
data: { lang: 'en-US', resources: { client: {} }, cron: {} },
|
|
77
|
+
});
|
|
78
|
+
app.apiMock.onGet('/auth:check').reply(200, {
|
|
79
|
+
data: { code: 302, redirect: '/2fa?redirect=/admin' },
|
|
88
80
|
});
|
|
89
81
|
|
|
82
|
+
const Root = app.getRootComponent();
|
|
83
|
+
render(<Root />);
|
|
84
|
+
|
|
85
|
+
// Give CurrentUserProvider time to process the response.
|
|
86
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
87
|
+
expect(app.router.router.state.location.pathname).toBe('/v2/admin');
|
|
88
|
+
expect(app.router.router.state.location.search).toBe('');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should redirect unauthenticated v2 root access to v2 signin via <Navigate />', async () => {
|
|
90
92
|
const app = createMockClient({
|
|
91
93
|
publicPath: '/nocobase/v2/',
|
|
92
94
|
plugins: [NocoBaseBuildInPlugin as any],
|
|
93
95
|
router: { type: 'memory', initialEntries: ['/nocobase/v2/'] },
|
|
94
96
|
});
|
|
95
97
|
app.apiMock.onGet('app:getLang').reply(200, {
|
|
96
|
-
data: {
|
|
97
|
-
lang: 'en-US',
|
|
98
|
-
resources: { client: {} },
|
|
99
|
-
cron: {},
|
|
100
|
-
},
|
|
98
|
+
data: { lang: 'en-US', resources: { client: {} }, cron: {} },
|
|
101
99
|
});
|
|
102
100
|
|
|
103
101
|
const Root = app.getRootComponent();
|
|
104
102
|
render(<Root />);
|
|
105
103
|
|
|
106
104
|
await waitFor(() => {
|
|
107
|
-
expect(
|
|
105
|
+
expect(app.router.router.state.location.pathname).toBe('/nocobase/v2/signin');
|
|
106
|
+
expect(app.router.router.state.location.search).toBe('?redirect=%2Fnocobase%2Fv2%2Fadmin');
|
|
108
107
|
});
|
|
109
108
|
});
|
|
110
109
|
|
|
111
110
|
it('should render v2 admin root without redirecting away', async () => {
|
|
112
|
-
const replace = vi.fn();
|
|
113
|
-
Object.defineProperty(globalThis.window, 'location', {
|
|
114
|
-
configurable: true,
|
|
115
|
-
value: {
|
|
116
|
-
...originalLocation,
|
|
117
|
-
pathname: '/v2/admin',
|
|
118
|
-
search: '',
|
|
119
|
-
hash: '',
|
|
120
|
-
replace,
|
|
121
|
-
},
|
|
122
|
-
});
|
|
123
|
-
|
|
124
111
|
const app = createMockClient({
|
|
125
112
|
publicPath: '/v2/',
|
|
126
113
|
plugins: [NocoBaseBuildInPlugin as any],
|
|
127
114
|
router: { type: 'memory', initialEntries: ['/v2/admin'] },
|
|
128
115
|
});
|
|
129
116
|
app.apiMock.onGet('app:getLang').reply(200, {
|
|
130
|
-
data: {
|
|
131
|
-
lang: 'en-US',
|
|
132
|
-
resources: { client: {} },
|
|
133
|
-
cron: {},
|
|
134
|
-
},
|
|
117
|
+
data: { lang: 'en-US', resources: { client: {} }, cron: {} },
|
|
135
118
|
});
|
|
136
119
|
app.apiMock.onGet('/auth:check').reply(200, { data: { id: 1 } });
|
|
137
120
|
app.apiMock.onGet('systemSettings:get').reply(200, { data: {} });
|
|
@@ -152,36 +135,20 @@ describe('nocobase buildin plugin auth redirect', () => {
|
|
|
152
135
|
await waitFor(() => {
|
|
153
136
|
expect(container.innerHTML).toContain('No pages yet, please configure first');
|
|
154
137
|
});
|
|
155
|
-
expect(
|
|
138
|
+
expect(app.router.router.state.location.pathname).toBe('/v2/admin');
|
|
156
139
|
expect(container.innerHTML).not.toContain('Legacy page');
|
|
157
140
|
});
|
|
158
141
|
|
|
159
142
|
it.each(['/v2/admin/legacy-page/tab/tab-1', '/v2/admin/legacy-page/view/detail'])(
|
|
160
143
|
'should show 404 for authenticated direct v1-style v2 page access: %s',
|
|
161
144
|
async (pathname) => {
|
|
162
|
-
const replace = vi.fn();
|
|
163
|
-
Object.defineProperty(globalThis.window, 'location', {
|
|
164
|
-
configurable: true,
|
|
165
|
-
value: {
|
|
166
|
-
...originalLocation,
|
|
167
|
-
pathname,
|
|
168
|
-
search: '?from=direct',
|
|
169
|
-
hash: '#dialog',
|
|
170
|
-
replace,
|
|
171
|
-
},
|
|
172
|
-
});
|
|
173
|
-
|
|
174
145
|
const app = createMockClient({
|
|
175
146
|
publicPath: '/v2/',
|
|
176
147
|
plugins: [NocoBaseBuildInPlugin as any],
|
|
177
148
|
router: { type: 'memory', initialEntries: [pathname] },
|
|
178
149
|
});
|
|
179
150
|
app.apiMock.onGet('app:getLang').reply(200, {
|
|
180
|
-
data: {
|
|
181
|
-
lang: 'en-US',
|
|
182
|
-
resources: { client: {} },
|
|
183
|
-
cron: {},
|
|
184
|
-
},
|
|
151
|
+
data: { lang: 'en-US', resources: { client: {} }, cron: {} },
|
|
185
152
|
});
|
|
186
153
|
app.apiMock.onGet('/auth:check').reply(200, { data: { id: 1 } });
|
|
187
154
|
app.apiMock.onGet('systemSettings:get').reply(200, { data: {} });
|
|
@@ -200,7 +167,7 @@ describe('nocobase buildin plugin auth redirect', () => {
|
|
|
200
167
|
render(<Root />);
|
|
201
168
|
|
|
202
169
|
expect(await screen.findByText('404')).toBeInTheDocument();
|
|
203
|
-
expect(
|
|
170
|
+
expect(app.router.router.state.location.pathname).toBe(pathname);
|
|
204
171
|
},
|
|
205
172
|
);
|
|
206
173
|
});
|
|
@@ -32,6 +32,61 @@ describe('client-v2 remotePlugins', () => {
|
|
|
32
32
|
expect(mockDefine).toHaveBeenCalledWith('@nocobase/demo/client-v2', expect.any(Function));
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
it('should preserve named exports from dev plugin modules', () => {
|
|
36
|
+
class DemoPlugin extends Plugin {}
|
|
37
|
+
class DemoActionModel {}
|
|
38
|
+
|
|
39
|
+
const mockDefine: any = vi.fn();
|
|
40
|
+
window.define = mockDefine;
|
|
41
|
+
|
|
42
|
+
const pluginModule = {
|
|
43
|
+
default: DemoPlugin,
|
|
44
|
+
DemoActionModel,
|
|
45
|
+
};
|
|
46
|
+
defineDevPlugins({
|
|
47
|
+
'@nocobase/demo': pluginModule,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const moduleFactory = mockDefine.mock.calls[0]?.[1];
|
|
51
|
+
expect(mockDefine).toHaveBeenCalledWith('@nocobase/demo/client-v2', expect.any(Function));
|
|
52
|
+
expect(moduleFactory()).toBe(pluginModule);
|
|
53
|
+
expect(window.__nocobase_app_dev_plugins__['@nocobase/demo/client-v2']).toBe(pluginModule);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should expose named exports from devDynamicImport modules to RequireJS consumers', async () => {
|
|
57
|
+
class DemoPlugin extends Plugin {}
|
|
58
|
+
class DemoActionModel {}
|
|
59
|
+
|
|
60
|
+
const requirejs: any = {
|
|
61
|
+
requirejs: vi.fn(),
|
|
62
|
+
};
|
|
63
|
+
requirejs.requirejs.config = vi.fn();
|
|
64
|
+
|
|
65
|
+
const mockDefine: any = vi.fn();
|
|
66
|
+
window.define = mockDefine;
|
|
67
|
+
|
|
68
|
+
const plugins = await getPlugins({
|
|
69
|
+
requirejs,
|
|
70
|
+
pluginData: [
|
|
71
|
+
{
|
|
72
|
+
name: '@nocobase/demo',
|
|
73
|
+
packageName: '@nocobase/demo',
|
|
74
|
+
url: 'https://demo.com/dist/client-v2/index.js',
|
|
75
|
+
},
|
|
76
|
+
] as any,
|
|
77
|
+
devDynamicImport: vi.fn().mockResolvedValue({ default: DemoPlugin, DemoActionModel }) as any,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const moduleFactory = mockDefine.mock.calls.find((call) => call[0] === '@nocobase/demo/client-v2')?.[1];
|
|
81
|
+
|
|
82
|
+
expect(plugins).toEqual([['@nocobase/demo', DemoPlugin]]);
|
|
83
|
+
expect(moduleFactory()).toEqual({ default: DemoPlugin, DemoActionModel });
|
|
84
|
+
expect(window.__nocobase_app_dev_plugins__['@nocobase/demo/client-v2']).toEqual({
|
|
85
|
+
default: DemoPlugin,
|
|
86
|
+
DemoActionModel,
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
35
90
|
it('should not define /client aliases when loading v2 plugins', async () => {
|
|
36
91
|
class DemoPlugin extends Plugin {}
|
|
37
92
|
|
|
@@ -0,0 +1,100 @@
|
|
|
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 { FlowEngine, FlowEngineProvider } from '@nocobase/flow-engine';
|
|
11
|
+
import { renderHook } from '@testing-library/react';
|
|
12
|
+
import React from 'react';
|
|
13
|
+
import { describe, expect, it } from 'vitest';
|
|
14
|
+
import { ACLContext } from '../acl';
|
|
15
|
+
import { useCurrentRoles } from '../nocobase-buildin-plugin';
|
|
16
|
+
|
|
17
|
+
type AclContextValue = React.ContextType<typeof ACLContext>;
|
|
18
|
+
|
|
19
|
+
function makeAclValue(allowAnonymous: boolean): AclContextValue {
|
|
20
|
+
return {
|
|
21
|
+
loading: false,
|
|
22
|
+
data: {
|
|
23
|
+
data: { allowAnonymous },
|
|
24
|
+
meta: {},
|
|
25
|
+
},
|
|
26
|
+
refresh: async () => undefined,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeEngineWithUser(user: { roles?: Array<{ name: string; title?: string }> } | null): FlowEngine {
|
|
31
|
+
const engine = new FlowEngine();
|
|
32
|
+
if (user != null) {
|
|
33
|
+
engine.context.defineProperty('user', { value: user });
|
|
34
|
+
}
|
|
35
|
+
return engine;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function makeWrapper(opts: { engine: FlowEngine; acl: AclContextValue }) {
|
|
39
|
+
const Wrapper: React.FC = ({ children }) => (
|
|
40
|
+
<FlowEngineProvider engine={opts.engine}>
|
|
41
|
+
<ACLContext.Provider value={opts.acl}>{children}</ACLContext.Provider>
|
|
42
|
+
</FlowEngineProvider>
|
|
43
|
+
);
|
|
44
|
+
return Wrapper;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('useCurrentRoles', () => {
|
|
48
|
+
it('returns roles from flowEngine.context.user, dropping the synthetic __union__ entry', () => {
|
|
49
|
+
// `__union__` is a server-side marker for the merged-roles pseudo role and
|
|
50
|
+
// must never appear in user-facing role pickers — guards against a regression
|
|
51
|
+
// that broke role assignment in API Keys / SwitchRole pages.
|
|
52
|
+
const wrapper = makeWrapper({
|
|
53
|
+
engine: makeEngineWithUser({
|
|
54
|
+
roles: [
|
|
55
|
+
{ name: '__union__', title: 'Union' },
|
|
56
|
+
{ name: 'root', title: 'Root' },
|
|
57
|
+
{ name: 'member', title: 'Member' },
|
|
58
|
+
],
|
|
59
|
+
}),
|
|
60
|
+
acl: makeAclValue(false),
|
|
61
|
+
});
|
|
62
|
+
const { result } = renderHook(() => useCurrentRoles(), { wrapper });
|
|
63
|
+
expect(result.current).toEqual([
|
|
64
|
+
{ name: 'root', title: 'Root' },
|
|
65
|
+
{ name: 'member', title: 'Member' },
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('appends an anonymous role when ACL allowAnonymous is true', () => {
|
|
70
|
+
const wrapper = makeWrapper({
|
|
71
|
+
engine: makeEngineWithUser({ roles: [{ name: 'root', title: 'Root' }] }),
|
|
72
|
+
acl: makeAclValue(true),
|
|
73
|
+
});
|
|
74
|
+
const { result } = renderHook(() => useCurrentRoles(), { wrapper });
|
|
75
|
+
expect(result.current).toEqual([
|
|
76
|
+
{ name: 'root', title: 'Root' },
|
|
77
|
+
{ name: 'anonymous', title: 'Anonymous' },
|
|
78
|
+
]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('compiles {{t(...)}} templates in role.title via flowEngine.context.t', () => {
|
|
82
|
+
const engine = makeEngineWithUser({ roles: [{ name: 'admin', title: '{{t("Admin")}}' }] });
|
|
83
|
+
engine.context.defineProperty('t', { value: () => 'Compiled Title' });
|
|
84
|
+
const wrapper = makeWrapper({ engine, acl: makeAclValue(false) });
|
|
85
|
+
const { result } = renderHook(() => useCurrentRoles(), { wrapper });
|
|
86
|
+
expect(result.current).toEqual([{ name: 'admin', title: 'Compiled Title' }]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('returns an empty array when no user has been written to engine.context', () => {
|
|
90
|
+
const wrapper = makeWrapper({ engine: makeEngineWithUser(null), acl: makeAclValue(false) });
|
|
91
|
+
const { result } = renderHook(() => useCurrentRoles(), { wrapper });
|
|
92
|
+
expect(result.current).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('returns just anonymous when no user is set but allowAnonymous is true', () => {
|
|
96
|
+
const wrapper = makeWrapper({ engine: makeEngineWithUser(null), acl: makeAclValue(true) });
|
|
97
|
+
const { result } = renderHook(() => useCurrentRoles(), { wrapper });
|
|
98
|
+
expect(result.current).toEqual([{ name: 'anonymous', title: 'Anonymous' }]);
|
|
99
|
+
});
|
|
100
|
+
});
|