@nocobase/client-v2 2.3.0-beta.5 → 2.4.0-alpha.1

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 (90) hide show
  1. package/es/acl/ACLProvider.d.ts +10 -0
  2. package/es/acl/aclCheckReadiness.d.ts +11 -0
  3. package/es/authRedirect.d.ts +11 -2
  4. package/es/components/AttachmentUpload.d.ts +51 -0
  5. package/es/components/index.d.ts +1 -0
  6. package/es/flow/admin-shell/admin-layout/NocoBaseLogo.d.ts +10 -0
  7. package/es/index.d.ts +4 -1
  8. package/es/index.mjs +129 -118
  9. package/es/nocobase-buildin-plugin/currentUserAuthStatus.d.ts +11 -0
  10. package/es/nocobase-buildin-plugin/index.d.ts +10 -0
  11. package/es/settings-app/SettingsApplication.d.ts +18 -0
  12. package/es/settings-app/SettingsBrand.d.ts +16 -0
  13. package/es/settings-app/SettingsBuildInPlugin.d.ts +9 -0
  14. package/es/settings-app/SettingsDocumentRedirect.d.ts +10 -0
  15. package/es/settings-app/SettingsGroupNav.d.ts +18 -0
  16. package/es/settings-app/SettingsPluginSettingsManager.d.ts +14 -0
  17. package/es/settings-app/SettingsRouterManager.d.ts +15 -0
  18. package/es/settings-app/SettingsSearch.d.ts +17 -0
  19. package/es/settings-app/SettingsShell.d.ts +10 -0
  20. package/es/settings-app/isSettingsApp.d.ts +18 -0
  21. package/es/settings-app/runtimePaths.d.ts +18 -0
  22. package/es/settings-app/settingsDocumentPath.d.ts +14 -0
  23. package/es/settings-app/settingsTheme.d.ts +87 -0
  24. package/es/settings-app/useSettingsThemeConfig.d.ts +22 -0
  25. package/es/settings-center/AdminSettingsLayout.d.ts +2 -2
  26. package/es/settings-center/groups.d.ts +66 -0
  27. package/es/settings-center/useSettingsGroups.d.ts +36 -0
  28. package/es/settings-center/useSettingsSearch.d.ts +35 -0
  29. package/es/settings-center/utils.d.ts +24 -0
  30. package/es/theme/index.d.ts +1 -0
  31. package/es/theme/itemActive.d.ts +24 -0
  32. package/es/theme/type.d.ts +9 -0
  33. package/lib/index.js +157 -146
  34. package/package.json +7 -7
  35. package/src/__tests__/app.test.tsx +122 -3
  36. package/src/__tests__/authRedirect.test.ts +154 -0
  37. package/src/__tests__/browserChecker.test.ts +172 -6
  38. package/src/__tests__/mockSettingsApplication.ts +29 -0
  39. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +81 -8
  40. package/src/__tests__/plugin-manager.test.tsx +7 -6
  41. package/src/__tests__/settings-application.test.ts +164 -0
  42. package/src/__tests__/settings-auth-routes.test.ts +71 -0
  43. package/src/__tests__/settings-center.test.tsx +168 -130
  44. package/src/__tests__/settings-layout-root.test.tsx +600 -0
  45. package/src/__tests__/settings-runtime-paths.test.ts +99 -0
  46. package/src/__tests__/settings-search-shortcut.test.tsx +321 -0
  47. package/src/__tests__/settings-shell.test.tsx +261 -0
  48. package/src/acl/ACLProvider.tsx +53 -3
  49. package/src/acl/aclCheckReadiness.ts +44 -0
  50. package/src/authRedirect.ts +85 -8
  51. package/src/components/AppComponents.tsx +16 -7
  52. package/src/components/AttachmentUpload.tsx +275 -0
  53. package/src/components/index.ts +1 -0
  54. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -56
  55. package/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx +77 -0
  56. package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +37 -14
  57. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +77 -13
  58. package/src/flow/internal/components/Markdown/DisplayMarkdown.tsx +14 -9
  59. package/src/flow/internal/components/Markdown/__tests__/DisplayMarkdown.test.tsx +46 -0
  60. package/src/flow/models/fields/DisplayHtmlFieldModel.tsx +2 -1
  61. package/src/flow/models/fields/RichTextFieldModel/__tests__/RichTextFieldModel.test.tsx +74 -0
  62. package/src/flow/models/fields/RichTextFieldModel/index.tsx +27 -6
  63. package/src/flow/models/topbar/TopbarActionModel.tsx +52 -180
  64. package/src/index.ts +11 -1
  65. package/src/nocobase-buildin-plugin/currentUserAuthStatus.ts +58 -0
  66. package/src/nocobase-buildin-plugin/index.tsx +112 -71
  67. package/src/settings-app/SettingsApplication.ts +31 -0
  68. package/src/settings-app/SettingsBrand.tsx +108 -0
  69. package/src/settings-app/SettingsBuildInPlugin.ts +10 -0
  70. package/src/settings-app/SettingsDocumentRedirect.tsx +26 -0
  71. package/src/settings-app/SettingsGroupNav.tsx +124 -0
  72. package/src/settings-app/SettingsPluginSettingsManager.ts +38 -0
  73. package/src/settings-app/SettingsRouterManager.ts +92 -0
  74. package/src/settings-app/SettingsSearch.tsx +343 -0
  75. package/src/settings-app/SettingsShell.tsx +184 -0
  76. package/src/settings-app/isSettingsApp.ts +21 -0
  77. package/src/settings-app/runtimePaths.ts +103 -0
  78. package/src/settings-app/settingsDocumentPath.ts +66 -0
  79. package/src/settings-app/settingsTheme.ts +369 -0
  80. package/src/settings-app/useSettingsThemeConfig.ts +100 -0
  81. package/src/settings-center/AdminSettingsLayout.tsx +154 -169
  82. package/src/settings-center/SystemSettingsPage.tsx +4 -170
  83. package/src/settings-center/__tests__/groups.test.ts +25 -0
  84. package/src/settings-center/groups.ts +108 -0
  85. package/src/settings-center/useSettingsGroups.ts +105 -0
  86. package/src/settings-center/useSettingsSearch.ts +173 -0
  87. package/src/settings-center/utils.tsx +50 -1
  88. package/src/theme/index.tsx +1 -0
  89. package/src/theme/itemActive.ts +31 -0
  90. package/src/theme/type.ts +10 -0
@@ -18,7 +18,6 @@ import { theme as antdTheme, ConfigProvider, Grid, Popover } from 'antd';
18
18
  import { createStyles, createGlobalStyle } from 'antd-style';
19
19
  import React, { FC, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
20
20
  import ReactDOM from 'react-dom';
21
- import { useTranslation } from 'react-i18next';
22
21
  import { useLocation } from 'react-router-dom';
23
22
  import {
24
23
  AdminLayoutMenuModelRenderer,
@@ -35,7 +34,6 @@ import { ResetThemeTokenAndKeepAlgorithm } from './ResetThemeTokenAndKeepAlgorit
35
34
  import { PinnedPluginListLite } from './PinnedPluginListLite';
36
35
  import { useApplications } from './useApplications';
37
36
  import { useGlobalTheme, type CustomToken } from '../../../theme';
38
- import { useSystemSettings } from '../../system-settings';
39
37
  import { NocoBaseDesktopRouteType, type NocoBaseDesktopRoute } from '../../../flow-compat';
40
38
  import {
41
39
  FLOW_SETTINGS_PREFERENCE_CHANGE_EVENT,
@@ -44,60 +42,7 @@ import {
44
42
  } from './flowSettingsPreference';
45
43
  import { joinAdminLayoutRoutePath, type AdminLayoutRoutePathLike } from './resolveAdminRouteRuntimeTarget';
46
44
  import { useAppListRender } from './AppListRender';
47
-
48
- const className1 = css`
49
- height: var(--nb-header-height);
50
- margin-right: 4px;
51
- display: inline-flex;
52
- flex-shrink: 0;
53
- color: #fff;
54
- padding: 0;
55
- align-items: center;
56
- `;
57
- const className1WithFixedWidth = css`
58
- ${className1}
59
- width: 168px;
60
- `;
61
- const className1WithAutoWidth = css`
62
- ${className1}
63
- width: auto;
64
- min-width: 168px;
65
- `;
66
- const className2 = css`
67
- object-fit: contain;
68
- width: 100%;
69
- height: 100%;
70
- `;
71
- const className3 = css`
72
- width: 100%;
73
- height: 100%;
74
- font-weight: 500;
75
- text-align: center;
76
- overflow: hidden;
77
- text-overflow: ellipsis;
78
- white-space: nowrap;
79
- `;
80
-
81
- const NocoBaseLogo = observer(() => {
82
- const { token } = antdTheme.useToken();
83
- const customToken = token as CustomToken;
84
- const result = useSystemSettings();
85
- const { t } = useTranslation('lm-collections');
86
- const fontSizeStyle = useMemo(() => ({ fontSize: customToken.fontSizeHeading3 }), [customToken.fontSizeHeading3]);
87
-
88
- const hasLogo = result?.data?.data?.logo?.url;
89
- const logo = hasLogo ? (
90
- <img className={className2} src={result?.data?.data?.logo?.url} />
91
- ) : (
92
- <span style={fontSizeStyle} className={className3}>
93
- {t(result?.data?.data?.title)}
94
- </span>
95
- );
96
-
97
- return (
98
- <div className={hasLogo ? className1WithFixedWidth : className1WithAutoWidth}>{result?.loading ? null : logo}</div>
99
- );
100
- });
45
+ import { NocoBaseLogo } from './NocoBaseLogo';
101
46
 
102
47
  const resetStyle = css`
103
48
  .ant-layout-sider-children {
@@ -0,0 +1,77 @@
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 { css } from '@emotion/css';
11
+ import { observer } from '@nocobase/flow-engine';
12
+ import { theme as antdTheme } from 'antd';
13
+ import React, { useMemo } from 'react';
14
+ import { useTranslation } from 'react-i18next';
15
+ import { useSystemSettings } from '../../system-settings';
16
+ import type { CustomToken } from '../../../theme';
17
+
18
+ const logoContainerClassName = css`
19
+ height: var(--nb-header-height);
20
+ margin-right: 4px;
21
+ display: inline-flex;
22
+ flex-shrink: 0;
23
+ padding: 0;
24
+ align-items: center;
25
+ `;
26
+
27
+ const fixedWidthClassName = css`
28
+ ${logoContainerClassName}
29
+ width: 168px;
30
+ `;
31
+
32
+ const autoWidthClassName = css`
33
+ ${logoContainerClassName}
34
+ width: auto;
35
+ min-width: 168px;
36
+ `;
37
+
38
+ const logoImageClassName = css`
39
+ object-fit: contain;
40
+ width: 100%;
41
+ height: 100%;
42
+ `;
43
+
44
+ const titleClassName = css`
45
+ width: 100%;
46
+ height: 100%;
47
+ font-weight: 500;
48
+ text-align: center;
49
+ overflow: hidden;
50
+ text-overflow: ellipsis;
51
+ white-space: nowrap;
52
+ `;
53
+
54
+ export const NocoBaseLogo = observer(() => {
55
+ const { token } = antdTheme.useToken();
56
+ const customToken = token as CustomToken;
57
+ const result = useSystemSettings();
58
+ const { t } = useTranslation('lm-collections');
59
+ const title = t(result?.data?.data?.title || 'NocoBase');
60
+ const logoUrl = result?.data?.data?.logo?.url;
61
+ const titleStyle = useMemo<React.CSSProperties>(
62
+ () => ({ color: customToken.colorTextHeaderMenu, fontSize: customToken.fontSizeHeading3 }),
63
+ [customToken.colorTextHeaderMenu, customToken.fontSizeHeading3],
64
+ );
65
+
66
+ const logo = logoUrl ? (
67
+ <img alt={title} className={logoImageClassName} src={logoUrl} />
68
+ ) : (
69
+ <span style={titleStyle} className={titleClassName}>
70
+ {title}
71
+ </span>
72
+ );
73
+
74
+ return <div className={logoUrl ? fixedWidthClassName : autoWidthClassName}>{result?.loading ? null : logo}</div>;
75
+ });
76
+
77
+ NocoBaseLogo.displayName = 'NocoBaseLogo';
@@ -24,7 +24,9 @@ const topbarActionsBarClassName = css`
24
24
  align-items: center;
25
25
  color: var(--nb-topbar-action-color);
26
26
 
27
- .nb-topbar-actions-list {
27
+ .nb-topbar-actions-list,
28
+ .nb-topbar-utility-actions-list,
29
+ .nb-topbar-plugin-settings-action {
28
30
  display: inline-flex;
29
31
  align-items: center;
30
32
  height: 100%;
@@ -127,12 +129,18 @@ const getTopbarActionId = (action: TopbarActionModel) => {
127
129
  return action?.actionId || action?.uid || '';
128
130
  };
129
131
 
132
+ const PLUGIN_SETTINGS_ACTION_ID = 'plugin-settings';
133
+
130
134
  const TopbarActionsContent = React.memo((props: { actions: TopbarActionModel[]; onActionClick?: () => void }) => {
131
135
  const { allow } = useAclSnippets();
132
136
  const { token } = theme.useToken();
133
137
  const customToken = token as CustomToken;
134
138
  const actions = useMemo(() => getVisibleTopbarActions(props.actions, allow), [allow, props.actions]);
135
- const mainActions = actions.filter((action) => getTopbarActionId(action) !== USER_CENTER_ACTION_ID);
139
+ const mainActions = actions.filter((action) => {
140
+ const actionId = getTopbarActionId(action);
141
+ return actionId !== PLUGIN_SETTINGS_ACTION_ID && actionId !== USER_CENTER_ACTION_ID;
142
+ });
143
+ const pluginSettingsAction = actions.find((action) => getTopbarActionId(action) === PLUGIN_SETTINGS_ACTION_ID);
136
144
  const userCenterAction = actions.find((action) => getTopbarActionId(action) === USER_CENTER_ACTION_ID);
137
145
 
138
146
  return (
@@ -153,18 +161,33 @@ const TopbarActionsContent = React.memo((props: { actions: TopbarActionModel[];
153
161
  <ConfigProvider theme={dividerTheme}>
154
162
  <Divider type="vertical" />
155
163
  </ConfigProvider>
156
- <HelpLite />
157
- {userCenterAction ? (
158
- <ErrorBoundary
159
- key={userCenterAction.uid}
160
- FallbackComponent={TopbarActionErrorFallback}
161
- onError={(error) => {
162
- console.error('[NocoBase] Topbar action render failed.', error);
163
- }}
164
- >
165
- <FlowModelRenderer model={userCenterAction} />
166
- </ErrorBoundary>
167
- ) : null}
164
+ <div className="nb-topbar-utility-actions-list">
165
+ {pluginSettingsAction ? (
166
+ <span className="nb-topbar-plugin-settings-action" onClick={props.onActionClick}>
167
+ <ErrorBoundary
168
+ key={pluginSettingsAction.uid}
169
+ FallbackComponent={TopbarActionErrorFallback}
170
+ onError={(error) => {
171
+ console.error('[NocoBase] Topbar action render failed.', error);
172
+ }}
173
+ >
174
+ <FlowModelRenderer model={pluginSettingsAction} />
175
+ </ErrorBoundary>
176
+ </span>
177
+ ) : null}
178
+ <HelpLite />
179
+ {userCenterAction ? (
180
+ <ErrorBoundary
181
+ key={userCenterAction.uid}
182
+ FallbackComponent={TopbarActionErrorFallback}
183
+ onError={(error) => {
184
+ console.error('[NocoBase] Topbar action render failed.', error);
185
+ }}
186
+ >
187
+ <FlowModelRenderer model={userCenterAction} />
188
+ </ErrorBoundary>
189
+ ) : null}
190
+ </div>
168
191
  </div>
169
192
  );
170
193
  });
@@ -136,6 +136,16 @@ describe('TopbarActionsBar helpers', () => {
136
136
  title: 'Plugin manager',
137
137
  path: '/admin/settings/plugin-manager',
138
138
  icon: null,
139
+ sort: -200,
140
+ componentLoader: async () => null,
141
+ },
142
+ {
143
+ key: 'multi-portal',
144
+ name: 'multi-portal',
145
+ title: 'Portal manager',
146
+ path: '/admin/settings/multi-portal',
147
+ icon: null,
148
+ sort: -300,
139
149
  componentLoader: async () => null,
140
150
  },
141
151
  {
@@ -159,13 +169,14 @@ describe('TopbarActionsBar helpers', () => {
159
169
  });
160
170
 
161
171
  expect((items as any[]).map((item) => item.type || item.key)).toEqual([
172
+ 'multi-portal',
162
173
  'plugin-manager',
163
174
  'divider',
164
175
  'system-settings',
165
176
  'divider',
166
177
  'security',
167
178
  ]);
168
- expect((items as any[])[2]).toMatchObject({
179
+ expect((items as any[]).find((item) => item.key === 'system-settings')).toMatchObject({
169
180
  key: 'system-settings',
170
181
  name: 'system-settings',
171
182
  path: '/admin/settings/system-settings',
@@ -191,7 +202,7 @@ describe('TopbarActionsBar helpers', () => {
191
202
  renderSettingsLabel((items as any[])[0].label, '/sales/p1');
192
203
 
193
204
  const link = screen.getByRole('link', { name: 'System settings' });
194
- expect(link).toHaveAttribute('href', '/nocobase/v/admin/settings/system-settings');
205
+ expect(link).toHaveAttribute('href', '/nocobase/settings/system-settings');
195
206
  expect(link).toHaveAttribute('target', '_blank');
196
207
  expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
197
208
  expect(link).toHaveAttribute('rel', expect.stringContaining('noreferrer'));
@@ -216,7 +227,7 @@ describe('TopbarActionsBar helpers', () => {
216
227
  renderSettingsLabel((items as any[])[0].label, '/sales/p1');
217
228
 
218
229
  const link = screen.getByRole('link', { name: 'Plugin manager' });
219
- expect(link).toHaveAttribute('href', '/nocobase/v/admin/settings/plugin-manager');
230
+ expect(link).toHaveAttribute('href', '/nocobase/settings/plugin-manager');
220
231
  expect(link).toHaveAttribute('target', '_blank');
221
232
  expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
222
233
  expect(link).toHaveAttribute('rel', expect.stringContaining('noreferrer'));
@@ -241,13 +252,13 @@ describe('TopbarActionsBar helpers', () => {
241
252
  renderSettingsLabel((items as any[])[0].label, '/apps/a_9xlild35jir/crm-amd/ekeisumx1zu');
242
253
 
243
254
  const link = screen.getByRole('link', { name: 'System settings' });
244
- expect(link).toHaveAttribute('href', '/nocobase/v/apps/a_9xlild35jir/admin/settings/system-settings');
255
+ expect(link).toHaveAttribute('href', '/nocobase/settings/apps/a_9xlild35jir/system-settings');
245
256
  expect(link).toHaveAttribute('target', '_blank');
246
257
  expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
247
258
  expect(link).toHaveAttribute('rel', expect.stringContaining('noreferrer'));
248
259
  });
249
260
 
250
- it('should keep regular admin settings as SPA links inside admin runtime', () => {
261
+ it('should open regular admin settings in the standalone SPA from admin runtime', () => {
251
262
  const items = getTopbarPluginSettingsItems({
252
263
  canManagePlugins: false,
253
264
  t: (key) => key,
@@ -266,11 +277,12 @@ describe('TopbarActionsBar helpers', () => {
266
277
  renderSettingsLabel((items as any[])[0].label, '/admin/settings/routes');
267
278
 
268
279
  const link = screen.getByRole('link', { name: 'Routes' });
269
- expect(link).toHaveAttribute('href', '/admin/settings/routes');
270
- expect(link).not.toHaveAttribute('target', '_blank');
280
+ expect(link).toHaveAttribute('href', '/nocobase/settings/routes');
281
+ expect(link).toHaveAttribute('target', '_blank');
282
+ expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
271
283
  });
272
284
 
273
- it('should keep sub-app admin settings in the current window inside sub-app admin runtime', () => {
285
+ it('should open sub-app settings in the standalone SPA from sub-app admin runtime', () => {
274
286
  const items = getTopbarPluginSettingsItems({
275
287
  canManagePlugins: false,
276
288
  t: (key) => key,
@@ -289,8 +301,9 @@ describe('TopbarActionsBar helpers', () => {
289
301
  renderSettingsLabel((items as any[])[0].label, '/apps/a_9xlild35jir/admin/settings/routes');
290
302
 
291
303
  const link = screen.getByRole('link', { name: 'Routes' });
292
- expect(link).toHaveAttribute('href', '/nocobase/v/apps/a_9xlild35jir/admin/settings/routes');
293
- expect(link).not.toHaveAttribute('target', '_blank');
304
+ expect(link).toHaveAttribute('href', '/nocobase/settings/apps/a_9xlild35jir/routes');
305
+ expect(link).toHaveAttribute('target', '_blank');
306
+ expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
294
307
  });
295
308
 
296
309
  it('should not treat admin-like paths as admin runtime', () => {
@@ -312,11 +325,11 @@ describe('TopbarActionsBar helpers', () => {
312
325
  renderSettingsLabel((items as any[])[0].label, '/admin2/foo');
313
326
 
314
327
  const link = screen.getByRole('link', { name: 'System settings' });
315
- expect(link).toHaveAttribute('href', '/nocobase/v/admin/settings/system-settings');
328
+ expect(link).toHaveAttribute('href', '/nocobase/settings/system-settings');
316
329
  expect(link).toHaveAttribute('target', '_blank');
317
330
  });
318
331
 
319
- it('should not duplicate the basename when building new-tab settings hrefs', () => {
332
+ it('should remove the modern basename when building standalone settings hrefs', () => {
320
333
  const items = getTopbarPluginSettingsItems({
321
334
  canManagePlugins: false,
322
335
  t: (key) => key,
@@ -335,7 +348,30 @@ describe('TopbarActionsBar helpers', () => {
335
348
  renderSettingsLabel((items as any[])[0].label, '/sales/p1');
336
349
 
337
350
  const link = screen.getByRole('link', { name: 'System settings' });
338
- expect(link).toHaveAttribute('href', '/nocobase/v/admin/settings/system-settings');
351
+ expect(link).toHaveAttribute('href', '/nocobase/settings/system-settings');
352
+ });
353
+
354
+ it('should preserve the new sub-app scope in standalone settings hrefs', () => {
355
+ const items = getTopbarPluginSettingsItems({
356
+ canManagePlugins: false,
357
+ t: (key) => key,
358
+ settings: [
359
+ {
360
+ key: 'system-settings',
361
+ name: 'system-settings',
362
+ title: 'System settings',
363
+ path: '/admin/settings/system-settings',
364
+ icon: null,
365
+ componentLoader: async () => null,
366
+ },
367
+ ] as any,
368
+ });
369
+
370
+ renderSettingsLabel((items as any[])[0].label, '/_app/a_new/admin/settings/system-settings');
371
+
372
+ const link = screen.getByRole('link', { name: 'System settings' });
373
+ expect(link).toHaveAttribute('href', '/nocobase/settings/_app/a_new/system-settings');
374
+ expect(link).toHaveAttribute('target', '_blank');
339
375
  });
340
376
 
341
377
  it('should keep external settings opening in a new tab', () => {
@@ -402,6 +438,34 @@ describe('TopbarActionsBar', () => {
402
438
  vi.restoreAllMocks();
403
439
  });
404
440
 
441
+ it('should group plugin settings with Help and user center', () => {
442
+ render(
443
+ <TopbarActionsBar
444
+ actions={[
445
+ createAction({ uid: 'notification', actionId: 'notification' }),
446
+ createAction({ uid: 'plugin-settings', actionId: 'plugin-settings' }),
447
+ createAction({ uid: 'user-center', actionId: 'user-center' }),
448
+ ]}
449
+ />,
450
+ );
451
+
452
+ const notification = screen.getByTestId('flow-model-notification');
453
+ const pluginSettings = screen.getByTestId('flow-model-plugin-settings');
454
+ const help = screen.getByTestId('help-lite');
455
+ const userCenter = screen.getByTestId('flow-model-user-center');
456
+ const mainGroup = notification.closest('.nb-topbar-actions-list');
457
+ const utilityGroup = pluginSettings.closest('.nb-topbar-utility-actions-list');
458
+
459
+ expect(mainGroup).toContainElement(notification);
460
+ expect(mainGroup).not.toContainElement(pluginSettings);
461
+ expect(utilityGroup).not.toBeNull();
462
+ expect(utilityGroup).toContainElement(pluginSettings);
463
+ expect(utilityGroup).toContainElement(help);
464
+ expect(utilityGroup).toContainElement(userCenter);
465
+ expect(pluginSettings.compareDocumentPosition(help) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
466
+ expect(help.compareDocumentPosition(userCenter) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
467
+ });
468
+
405
469
  it('should keep HelpLite rendered when one action fails', () => {
406
470
  vi.spyOn(console, 'error').mockImplementation(() => {});
407
471
 
@@ -7,16 +7,17 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import React, { useRef, useState, useEffect } from 'react';
11
- import { useParseMarkdown, convertToText } from './util';
12
- import { useMarkdownStyles } from './style';
10
+ import React, { useEffect, useRef, useState } from 'react';
13
11
  import { Tooltip } from 'antd';
12
+ import { useMarkdownStyles } from './style';
13
+ import { convertToText, useParseMarkdown } from './util';
14
14
 
15
15
  export const DisplayMarkdown = (props) => {
16
- const { textOnly, overflowMode, style } = props;
16
+ const { textOnly, overflowMode, sanitizeHtml, style } = props;
17
17
  const markdownClass = useMarkdownStyles();
18
18
  const { html = '' } = useParseMarkdown(props.value);
19
- const text: any = convertToText(html);
19
+ const displayHtml = sanitizeHtml ? sanitizeHtml(html) : html;
20
+ const text = convertToText(displayHtml);
20
21
  const isEllipsis = overflowMode === 'ellipsis';
21
22
 
22
23
  const contentRef = useRef<HTMLDivElement>(null);
@@ -29,7 +30,7 @@ export const DisplayMarkdown = (props) => {
29
30
  const overflowed = el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight;
30
31
  setIsOverflowed(overflowed);
31
32
  }
32
- }, [html, style, overflowMode]);
33
+ }, [displayHtml, style, overflowMode]);
33
34
 
34
35
  // 通用样式(用于 ellipsis 模式)
35
36
  const baseStyle: React.CSSProperties = {
@@ -48,11 +49,15 @@ export const DisplayMarkdown = (props) => {
48
49
  : {}),
49
50
  };
50
51
 
51
- const content = (
52
+ const content = textOnly ? (
53
+ <div ref={contentRef} className={`${markdownClass} nb-markdown`} style={baseStyle}>
54
+ {text}
55
+ </div>
56
+ ) : (
52
57
  <div
53
58
  ref={contentRef}
54
59
  className={`${markdownClass} nb-markdown`}
55
- dangerouslySetInnerHTML={{ __html: textOnly ? text : html }}
60
+ dangerouslySetInnerHTML={{ __html: displayHtml }}
56
61
  style={baseStyle}
57
62
  />
58
63
  );
@@ -61,7 +66,7 @@ export const DisplayMarkdown = (props) => {
61
66
  <Tooltip
62
67
  title={
63
68
  <div
64
- dangerouslySetInnerHTML={{ __html: html }}
69
+ dangerouslySetInnerHTML={{ __html: displayHtml }}
65
70
  style={{
66
71
  maxHeight: 500,
67
72
  overflowY: 'auto',
@@ -0,0 +1,46 @@
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 } from '@nocobase/test/client';
11
+ import { sanitizeRichTextHtml } from '@nocobase/utils/client';
12
+ import React from 'react';
13
+ import { DisplayMarkdown } from '../DisplayMarkdown';
14
+
15
+ vi.mock('../util', () => ({
16
+ useParseMarkdown: (value: string) => ({ html: value }),
17
+ convertToText: (value: string) => {
18
+ const element = document.createElement('div');
19
+ element.innerHTML = value;
20
+ return element.textContent || '';
21
+ },
22
+ }));
23
+
24
+ describe('DisplayMarkdown', () => {
25
+ it('sanitizes HTML mode', () => {
26
+ const { container } = render(
27
+ <DisplayMarkdown
28
+ value={'<p>safe</p><img src="https://example.com/image.png" onerror="alert(1)"><script>alert(1)</script>'}
29
+ sanitizeHtml={sanitizeRichTextHtml}
30
+ textOnly={false}
31
+ />,
32
+ );
33
+
34
+ expect(container.querySelector('p')).toHaveTextContent('safe');
35
+ expect(container.querySelector('img')).not.toHaveAttribute('onerror');
36
+ expect(container.querySelector('script')).toBeNull();
37
+ });
38
+
39
+ it('renders decoded text as a text node', () => {
40
+ const payload = '&lt;img src=x onerror=alert(document.domain)&gt;';
41
+ const { container } = render(<DisplayMarkdown value={payload} sanitizeHtml={sanitizeRichTextHtml} textOnly />);
42
+
43
+ expect(container).toHaveTextContent('<img src=x onerror=alert(document.domain)>');
44
+ expect(container.querySelector('img')).toBeNull();
45
+ });
46
+ });
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { DisplayItemModel, tExpr } from '@nocobase/flow-engine';
11
+ import { sanitizeRichTextHtml } from '@nocobase/utils/client';
11
12
  import React from 'react';
12
13
  import { DisplayMarkdown } from '../../internal/components/Markdown/DisplayMarkdown';
13
14
  import { DisplayTitleFieldModel } from './DisplayTitleFieldModel';
@@ -15,7 +16,7 @@ import { DisplayTitleFieldModel } from './DisplayTitleFieldModel';
15
16
  export class DisplayHtmlFieldModel extends DisplayTitleFieldModel {
16
17
  public renderComponent(value) {
17
18
  const { textOnly = true } = this.props;
18
- return <DisplayMarkdown {...this.props} textOnly={textOnly} value={value} />;
19
+ return <DisplayMarkdown {...this.props} sanitizeHtml={sanitizeRichTextHtml} textOnly={textOnly} value={value} />;
19
20
  }
20
21
  }
21
22
 
@@ -0,0 +1,74 @@
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 { act, render } from '@nocobase/test/client';
11
+ import { sanitizeRichTextHtml } from '@nocobase/utils/client';
12
+ import React from 'react';
13
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
14
+ import { RichTextField } from '..';
15
+
16
+ const mockState = vi.hoisted(() => ({
17
+ onChange: undefined as ((value: string) => void) | undefined,
18
+ value: undefined as unknown,
19
+ }));
20
+
21
+ vi.mock('../../../../../flow-compat', async (importOriginal) => {
22
+ const actual = await importOriginal<typeof import('../../../../../flow-compat')>();
23
+ return {
24
+ ...actual,
25
+ lazy: () => (props: { onChange?: (value: string) => void; value?: unknown }) => {
26
+ mockState.onChange = props.onChange;
27
+ mockState.value = props.value;
28
+ return null;
29
+ },
30
+ };
31
+ });
32
+
33
+ describe('RichTextField edit mode sanitization', () => {
34
+ beforeEach(() => {
35
+ mockState.onChange = undefined;
36
+ mockState.value = undefined;
37
+ });
38
+
39
+ it('sanitizes stored HTML before passing it to ReactQuill', () => {
40
+ const payload = '<p>safe</p><img src="https://example.com/image.png" onerror="alert(1)"><script>alert(1)</script>';
41
+
42
+ render(<RichTextField value={payload} onChange={vi.fn()} />);
43
+
44
+ expect(mockState.value).toBe(sanitizeRichTextHtml(payload));
45
+ });
46
+
47
+ it('preserves Delta values', () => {
48
+ const delta = { ops: [{ insert: 'safe\n' }] };
49
+
50
+ render(<RichTextField value={delta} onChange={vi.fn()} />);
51
+
52
+ expect(mockState.value).toBe(delta);
53
+ });
54
+
55
+ it('does not normalize active editor feedback', () => {
56
+ const editorValue = '<p>Hello<br>World</p>';
57
+ expect(sanitizeRichTextHtml(editorValue)).not.toBe(editorValue);
58
+
59
+ const { rerender } = render(<RichTextField value="" onChange={vi.fn()} />);
60
+ act(() => mockState.onChange?.(editorValue));
61
+ rerender(<RichTextField value={editorValue} onChange={vi.fn()} />);
62
+
63
+ expect(mockState.value).toBe(editorValue);
64
+ });
65
+
66
+ it('sanitizes subsequent external values', () => {
67
+ const payload = '<img src=x onerror="alert(1)">';
68
+ const { rerender } = render(<RichTextField value="" onChange={vi.fn()} />);
69
+
70
+ rerender(<RichTextField value={payload} onChange={vi.fn()} />);
71
+
72
+ expect(mockState.value).toBe(sanitizeRichTextHtml(payload));
73
+ });
74
+ });
@@ -9,6 +9,8 @@
9
9
 
10
10
  import React from 'react';
11
11
  import { largeField, EditableItemModel } from '@nocobase/flow-engine';
12
+ import { sanitizeRichTextHtml } from '@nocobase/utils/client';
13
+ import type ReactQuillComponent from 'react-quill';
12
14
  import { lazy } from '../../../../flow-compat';
13
15
  import { useRichTextStyles } from './style';
14
16
  import { FieldModel } from '../../base';
@@ -26,6 +28,8 @@ const ReactQuill = lazy(async () => {
26
28
  return import('react-quill');
27
29
  });
28
30
 
31
+ type ReactQuillValue = ReactQuillComponent.ReactQuillProps['value'];
32
+
29
33
  export const RichTextField = (props) => {
30
34
  const richTextClass = useRichTextStyles();
31
35
  const boundsClass = React.useMemo(() => `quill-bounds-${Math.random().toString(36).slice(2, 9)}`, []);
@@ -83,19 +87,36 @@ export const RichTextField = (props) => {
83
87
  'break',
84
88
  ];
85
89
  const { value, onChange, disabled, modules: propsModules, formats: propsFormats } = props;
90
+ const previousIncomingValueRef = React.useRef(value);
91
+ const pendingEditorValueRef = React.useRef<{ value: ReactQuillValue }>();
92
+ const [editorValue, setEditorValue] = React.useState<ReactQuillValue>(() =>
93
+ typeof value === 'string' ? sanitizeRichTextHtml(value) : value,
94
+ );
95
+
96
+ React.useEffect(() => {
97
+ if (Object.is(value, previousIncomingValueRef.current)) {
98
+ return;
99
+ }
100
+ previousIncomingValueRef.current = value;
101
+ const pendingEditorValue = pendingEditorValueRef.current;
102
+ pendingEditorValueRef.current = undefined;
103
+ if (pendingEditorValue && Object.is(value, pendingEditorValue.value)) {
104
+ return;
105
+ }
106
+ setEditorValue(typeof value === 'string' ? sanitizeRichTextHtml(value) : value);
107
+ }, [value]);
86
108
 
87
109
  return (
88
110
  <ReactQuill
89
111
  className={`${richTextClass} ${boundsClass}`}
90
112
  modules={propsModules || modules}
91
113
  formats={propsFormats || formats}
92
- value={value}
114
+ value={editorValue}
93
115
  onChange={(value) => {
94
- if (value === '<p><br></p>') {
95
- onChange('');
96
- } else {
97
- onChange(value);
98
- }
116
+ const nextValue = value === '<p><br></p>' ? '' : value;
117
+ pendingEditorValueRef.current = { value: nextValue };
118
+ setEditorValue(nextValue);
119
+ onChange(nextValue);
99
120
  }}
100
121
  readOnly={disabled}
101
122
  bounds={`.${boundsClass}`}