@nocobase/client-v2 2.3.0-beta.6 → 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.
- package/es/acl/ACLProvider.d.ts +10 -0
- package/es/acl/aclCheckReadiness.d.ts +11 -0
- package/es/authRedirect.d.ts +11 -2
- package/es/components/AttachmentUpload.d.ts +51 -0
- package/es/components/index.d.ts +1 -0
- package/es/flow/admin-shell/admin-layout/NocoBaseLogo.d.ts +10 -0
- package/es/index.d.ts +4 -1
- package/es/index.mjs +129 -118
- package/es/nocobase-buildin-plugin/currentUserAuthStatus.d.ts +11 -0
- package/es/nocobase-buildin-plugin/index.d.ts +10 -0
- package/es/settings-app/SettingsApplication.d.ts +18 -0
- package/es/settings-app/SettingsBrand.d.ts +16 -0
- package/es/settings-app/SettingsBuildInPlugin.d.ts +9 -0
- package/es/settings-app/SettingsDocumentRedirect.d.ts +10 -0
- package/es/settings-app/SettingsGroupNav.d.ts +18 -0
- package/es/settings-app/SettingsPluginSettingsManager.d.ts +14 -0
- package/es/settings-app/SettingsRouterManager.d.ts +15 -0
- package/es/settings-app/SettingsSearch.d.ts +17 -0
- package/es/settings-app/SettingsShell.d.ts +10 -0
- package/es/settings-app/isSettingsApp.d.ts +18 -0
- package/es/settings-app/runtimePaths.d.ts +18 -0
- package/es/settings-app/settingsDocumentPath.d.ts +14 -0
- package/es/settings-app/settingsTheme.d.ts +87 -0
- package/es/settings-app/useSettingsThemeConfig.d.ts +22 -0
- package/es/settings-center/AdminSettingsLayout.d.ts +2 -2
- package/es/settings-center/groups.d.ts +66 -0
- package/es/settings-center/useSettingsGroups.d.ts +36 -0
- package/es/settings-center/useSettingsSearch.d.ts +35 -0
- package/es/settings-center/utils.d.ts +24 -0
- package/es/theme/index.d.ts +1 -0
- package/es/theme/itemActive.d.ts +24 -0
- package/es/theme/type.d.ts +9 -0
- package/lib/index.js +157 -146
- package/package.json +7 -7
- package/src/__tests__/app.test.tsx +122 -3
- package/src/__tests__/authRedirect.test.ts +154 -0
- package/src/__tests__/browserChecker.test.ts +172 -6
- package/src/__tests__/mockSettingsApplication.ts +29 -0
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +81 -8
- package/src/__tests__/plugin-manager.test.tsx +7 -6
- package/src/__tests__/settings-application.test.ts +164 -0
- package/src/__tests__/settings-auth-routes.test.ts +71 -0
- package/src/__tests__/settings-center.test.tsx +168 -130
- package/src/__tests__/settings-layout-root.test.tsx +600 -0
- package/src/__tests__/settings-runtime-paths.test.ts +99 -0
- package/src/__tests__/settings-search-shortcut.test.tsx +321 -0
- package/src/__tests__/settings-shell.test.tsx +261 -0
- package/src/acl/ACLProvider.tsx +53 -3
- package/src/acl/aclCheckReadiness.ts +44 -0
- package/src/authRedirect.ts +85 -8
- package/src/components/AppComponents.tsx +16 -7
- package/src/components/AttachmentUpload.tsx +275 -0
- package/src/components/index.ts +1 -0
- package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -56
- package/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx +77 -0
- package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +37 -14
- package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +77 -13
- package/src/flow/internal/components/Markdown/DisplayMarkdown.tsx +14 -9
- package/src/flow/internal/components/Markdown/__tests__/DisplayMarkdown.test.tsx +46 -0
- package/src/flow/models/fields/DisplayHtmlFieldModel.tsx +2 -1
- package/src/flow/models/fields/RichTextFieldModel/__tests__/RichTextFieldModel.test.tsx +74 -0
- package/src/flow/models/fields/RichTextFieldModel/index.tsx +27 -6
- package/src/flow/models/topbar/TopbarActionModel.tsx +52 -180
- package/src/index.ts +11 -1
- package/src/nocobase-buildin-plugin/currentUserAuthStatus.ts +58 -0
- package/src/nocobase-buildin-plugin/index.tsx +112 -71
- package/src/settings-app/SettingsApplication.ts +31 -0
- package/src/settings-app/SettingsBrand.tsx +108 -0
- package/src/settings-app/SettingsBuildInPlugin.ts +10 -0
- package/src/settings-app/SettingsDocumentRedirect.tsx +26 -0
- package/src/settings-app/SettingsGroupNav.tsx +124 -0
- package/src/settings-app/SettingsPluginSettingsManager.ts +38 -0
- package/src/settings-app/SettingsRouterManager.ts +92 -0
- package/src/settings-app/SettingsSearch.tsx +343 -0
- package/src/settings-app/SettingsShell.tsx +184 -0
- package/src/settings-app/isSettingsApp.ts +21 -0
- package/src/settings-app/runtimePaths.ts +103 -0
- package/src/settings-app/settingsDocumentPath.ts +66 -0
- package/src/settings-app/settingsTheme.ts +369 -0
- package/src/settings-app/useSettingsThemeConfig.ts +100 -0
- package/src/settings-center/AdminSettingsLayout.tsx +154 -169
- package/src/settings-center/SystemSettingsPage.tsx +4 -170
- package/src/settings-center/__tests__/groups.test.ts +25 -0
- package/src/settings-center/groups.ts +108 -0
- package/src/settings-center/useSettingsGroups.ts +105 -0
- package/src/settings-center/useSettingsSearch.ts +173 -0
- package/src/settings-center/utils.tsx +50 -1
- package/src/theme/index.tsx +1 -0
- package/src/theme/itemActive.ts +31 -0
- 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) =>
|
|
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
|
-
<
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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[])
|
|
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/
|
|
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/
|
|
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/
|
|
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
|
|
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', '/
|
|
270
|
-
expect(link).
|
|
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
|
|
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/
|
|
293
|
-
expect(link).
|
|
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/
|
|
328
|
+
expect(link).toHaveAttribute('href', '/nocobase/settings/system-settings');
|
|
316
329
|
expect(link).toHaveAttribute('target', '_blank');
|
|
317
330
|
});
|
|
318
331
|
|
|
319
|
-
it('should
|
|
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/
|
|
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
|
|
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
|
|
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
|
-
}, [
|
|
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:
|
|
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:
|
|
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 = '<img src=x onerror=alert(document.domain)>';
|
|
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={
|
|
114
|
+
value={editorValue}
|
|
93
115
|
onChange={(value) => {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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}`}
|