@nocobase/client-v2 2.1.0-alpha.39 → 2.1.0-alpha.40

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 (67) hide show
  1. package/es/BaseApplication.d.ts +1 -1
  2. package/es/components/PoweredBy.d.ts +18 -0
  3. package/es/components/SwitchLanguage.d.ts +11 -0
  4. package/es/components/form/DialogFormLayout.d.ts +75 -0
  5. package/es/components/form/DrawerFormLayout.d.ts +11 -11
  6. package/es/components/form/PasswordInput.d.ts +40 -0
  7. package/es/components/form/RemoteSelect.d.ts +79 -0
  8. package/es/components/form/index.d.ts +3 -0
  9. package/es/components/form/table/styles.d.ts +10 -0
  10. package/es/components/index.d.ts +2 -0
  11. package/es/flow/models/base/ActionModelCore.d.ts +6 -0
  12. package/es/flow/models/base/GridModel.d.ts +2 -0
  13. package/es/flow/utils/dataScopeFormValueClear.d.ts +14 -0
  14. package/es/flow-compat/passwordUtils.d.ts +1 -1
  15. package/es/hooks/index.d.ts +2 -0
  16. package/es/hooks/useCurrentAppInfo.d.ts +9 -0
  17. package/es/index.mjs +102 -90
  18. package/es/nocobase-buildin-plugin/index.d.ts +25 -0
  19. package/es/utils/appVersionHTML.d.ts +10 -0
  20. package/es/utils/index.d.ts +1 -0
  21. package/es/utils/remotePlugins.d.ts +4 -1
  22. package/lib/index.js +108 -96
  23. package/package.json +7 -7
  24. package/src/BaseApplication.tsx +3 -3
  25. package/src/PluginSettingsManager.ts +2 -1
  26. package/src/__tests__/PluginSettingsManager.test.ts +19 -0
  27. package/src/__tests__/PoweredBy.test.tsx +130 -0
  28. package/src/__tests__/app.test.tsx +31 -0
  29. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +39 -72
  30. package/src/__tests__/remotePlugins.test.ts +55 -0
  31. package/src/__tests__/useCurrentRoles.test.tsx +100 -0
  32. package/src/components/PoweredBy.tsx +71 -0
  33. package/src/components/README.md +314 -0
  34. package/src/components/README.zh-CN.md +312 -0
  35. package/src/components/SwitchLanguage.tsx +48 -0
  36. package/src/components/form/DialogFormLayout.tsx +111 -0
  37. package/src/components/form/DrawerFormLayout.tsx +13 -32
  38. package/src/components/form/PasswordInput.tsx +211 -0
  39. package/src/components/form/RemoteSelect.tsx +137 -0
  40. package/src/components/form/index.tsx +3 -0
  41. package/src/components/form/table/Table.tsx +2 -1
  42. package/src/components/form/table/styles.ts +19 -0
  43. package/src/components/index.ts +2 -0
  44. package/src/css-variable/CSSVariableProvider.tsx +10 -1
  45. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +96 -0
  46. package/src/flow/actions/dataScope.tsx +3 -0
  47. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -4
  48. package/src/flow/admin-shell/admin-layout/HelpLite.tsx +7 -33
  49. package/src/flow/components/BlockItemCard.tsx +2 -2
  50. package/src/flow/models/base/ActionModel.tsx +8 -7
  51. package/src/flow/models/base/ActionModelCore.tsx +15 -7
  52. package/src/flow/models/base/GridModel.tsx +93 -36
  53. package/src/flow/models/base/__tests__/GridModel.visibleLayout.test.ts +83 -11
  54. package/src/flow/models/blocks/details/DetailsItemModel.tsx +2 -0
  55. package/src/flow/models/blocks/form/FormItemModel.tsx +5 -3
  56. package/src/flow/models/blocks/form/__tests__/FormItemModel.defineChildren.test.ts +108 -0
  57. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +5 -0
  58. package/src/flow/models/blocks/table/TableColumnModel.tsx +2 -0
  59. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +2 -0
  60. package/src/flow/utils/dataScopeFormValueClear.ts +278 -0
  61. package/src/hooks/index.ts +2 -0
  62. package/src/hooks/useCurrentAppInfo.ts +36 -0
  63. package/src/nocobase-buildin-plugin/index.tsx +70 -16
  64. package/src/utils/appVersionHTML.ts +28 -0
  65. package/src/utils/globalDeps.ts +2 -2
  66. package/src/utils/index.tsx +2 -0
  67. 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-alpha.39",
3
+ "version": "2.1.0-alpha.40",
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-alpha.39",
30
- "@nocobase/flow-engine": "2.1.0-alpha.39",
31
- "@nocobase/sdk": "2.1.0-alpha.39",
32
- "@nocobase/shared": "2.1.0-alpha.39",
33
- "@nocobase/utils": "2.1.0-alpha.39",
29
+ "@nocobase/evaluators": "2.1.0-alpha.40",
30
+ "@nocobase/flow-engine": "2.1.0-alpha.40",
31
+ "@nocobase/sdk": "2.1.0-alpha.40",
32
+ "@nocobase/shared": "2.1.0-alpha.40",
33
+ "@nocobase/utils": "2.1.0-alpha.40",
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": "d06ed6b97030866c00e7ce40c9e1bcc773ebf12c"
47
+ "gitHead": "e73f99dd0abefe847f2e50ff0fea1f41a82fd048"
48
48
  }
@@ -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 (favicon) {
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&lt;script&gt;alert(1)&lt;/script&gt;&amp;&quot;</span>',
95
+ );
96
+ expect(getAppVersionHTML(undefined)).toBe('');
97
+ expect(escapeHTML("NocoBase <v2> & 'beta'")).toBe('NocoBase &lt;v2&gt; &amp; &#39;beta&#39;');
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 redirect unauthenticated admin access to v2 signin with replace', async () => {
43
- const replace = vi.fn();
44
- Object.defineProperty(globalThis.window, 'location', {
45
- configurable: true,
46
- value: {
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(replace).toHaveBeenCalledWith('/v2/signin?redirect=%2Fv2%2Fadmin%2F7vu4c2sdk6h');
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 redirect unauthenticated v2 root access to v2 signin with default admin redirect', async () => {
78
- const replace = vi.fn();
79
- Object.defineProperty(globalThis.window, 'location', {
80
- configurable: true,
81
- value: {
82
- ...originalLocation,
83
- pathname: '/nocobase/v2/',
84
- search: '',
85
- hash: '',
86
- replace,
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(replace).toHaveBeenCalledWith('/nocobase/v2/signin?redirect=%2Fnocobase%2Fv2%2Fadmin');
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(replace).not.toHaveBeenCalled();
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(replace).not.toHaveBeenCalled();
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
+ });