@nocobase/client-v2 3.0.0-alpha.3 → 3.0.0-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/es/acl/aclCheckReadiness.d.ts +11 -0
- package/es/index.d.ts +2 -1
- package/es/index.mjs +46 -46
- package/es/nocobase-buildin-plugin/index.d.ts +1 -0
- package/es/settings-app/SettingsGroupNav.d.ts +3 -2
- package/es/settings-app/SettingsSearch.d.ts +1 -1
- package/es/ui-operation/index.d.ts +15 -0
- package/es/ui-operation/ui-operation-codec.d.ts +10 -0
- package/lib/index.js +65 -65
- package/package.json +7 -7
- package/src/__tests__/browserChecker.test.ts +44 -33
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +37 -0
- package/src/__tests__/settings-center.test.tsx +110 -10
- package/src/__tests__/settings-layout-root.test.tsx +346 -6
- package/src/__tests__/settings-runtime-paths.test.ts +1 -1
- package/src/__tests__/settings-search-shortcut.test.tsx +321 -0
- package/src/__tests__/settings-shell.test.tsx +35 -0
- package/src/acl/ACLProvider.tsx +32 -3
- package/src/acl/aclCheckReadiness.ts +44 -0
- package/src/index.ts +2 -0
- package/src/layout-manager/LayoutContentRoute.tsx +2 -1
- package/src/layout-manager/LayoutRoute.tsx +2 -1
- package/src/layout-manager/__tests__/LayoutRoute.test.tsx +87 -1
- package/src/nocobase-buildin-plugin/index.tsx +14 -1
- package/src/settings-app/SettingsGroupNav.tsx +11 -11
- package/src/settings-app/SettingsSearch.tsx +115 -10
- package/src/settings-app/SettingsShell.tsx +4 -2
- package/src/settings-app/runtimePaths.ts +0 -1
- package/src/settings-app/settingsTheme.ts +82 -14
- package/src/settings-center/AdminSettingsLayout.tsx +96 -106
- package/src/ui-operation/index.ts +33 -0
- package/src/ui-operation/ui-operation-codec.ts +54 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { PageHeader } from '@ant-design/pro-layout';
|
|
11
11
|
import { css } from '@emotion/css';
|
|
12
12
|
import { FlowModelRenderer, useFlowEngine } from '@nocobase/flow-engine';
|
|
13
|
-
import { Layout,
|
|
13
|
+
import { Layout, Result, Tabs, theme } from 'antd';
|
|
14
14
|
import React, { useEffect, useMemo, useRef } from 'react';
|
|
15
15
|
import { useTranslation } from 'react-i18next';
|
|
16
16
|
import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
|
@@ -21,35 +21,55 @@ import { useSettingsGroups } from './useSettingsGroups';
|
|
|
21
21
|
import {
|
|
22
22
|
ADMIN_SETTINGS_LAYOUT_MODEL_UID,
|
|
23
23
|
createSettingsPathMap,
|
|
24
|
-
findSettingsByName,
|
|
25
24
|
getDefaultSettingsPath,
|
|
26
|
-
getSidebarMenuItems,
|
|
27
|
-
getSidebarSelectedKey,
|
|
28
25
|
matchSettingsRoute,
|
|
29
26
|
} from './utils';
|
|
30
27
|
|
|
31
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Maximum width of the content area.
|
|
30
|
+
*
|
|
31
|
+
* Centers and narrows the content on wide screens: settings pages are mostly forms
|
|
32
|
+
* and lists, and across 2560px a single line sweeps the whole display, which is
|
|
33
|
+
* tiring to read and leaves the actions on the right far from the title on the left.
|
|
34
|
+
*/
|
|
35
|
+
const SETTINGS_CONTENT_MAX_WIDTH = 1280;
|
|
36
|
+
|
|
37
|
+
function SettingsEmpty(props: { type: 'forbidden' | 'home' | 'not-found' }) {
|
|
32
38
|
const { type } = props;
|
|
33
39
|
const { t } = useTranslation();
|
|
34
40
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
let status: '403' | '404' | 'info';
|
|
42
|
+
let title: string;
|
|
43
|
+
let subTitle: string;
|
|
44
|
+
|
|
45
|
+
if (type === 'forbidden') {
|
|
46
|
+
status = '403';
|
|
47
|
+
title = t('Your current role cannot access Settings');
|
|
48
|
+
subTitle = t('Switch to a role with access, or contact an administrator to request access.');
|
|
49
|
+
} else if (type === 'not-found') {
|
|
50
|
+
status = '404';
|
|
51
|
+
title = t('Settings page not found');
|
|
52
|
+
subTitle = t('The settings page you requested does not exist or has been removed.');
|
|
53
|
+
} else {
|
|
54
|
+
status = 'info';
|
|
55
|
+
title = t('No settings pages available');
|
|
56
|
+
subTitle = t(
|
|
57
|
+
'No settings pages are currently available in Client V2. Settings registered by migrated plugins will appear here automatically.',
|
|
42
58
|
);
|
|
43
59
|
}
|
|
44
60
|
|
|
45
61
|
return (
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
62
|
+
<div role="status" aria-atomic="true">
|
|
63
|
+
<Result
|
|
64
|
+
status={status}
|
|
65
|
+
title={
|
|
66
|
+
<span role="heading" aria-level={1}>
|
|
67
|
+
{title}
|
|
68
|
+
</span>
|
|
69
|
+
}
|
|
70
|
+
subTitle={subTitle}
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
53
73
|
);
|
|
54
74
|
}
|
|
55
75
|
|
|
@@ -65,7 +85,6 @@ export const InternalAdminSettingsLayout = () => {
|
|
|
65
85
|
const location = useLocation();
|
|
66
86
|
const { token } = theme.useToken();
|
|
67
87
|
const {
|
|
68
|
-
activeGroupSettings,
|
|
69
88
|
allSettings,
|
|
70
89
|
currentSetting,
|
|
71
90
|
currentTopLevelSetting,
|
|
@@ -87,10 +106,9 @@ export const InternalAdminSettingsLayout = () => {
|
|
|
87
106
|
const settingsRootPath = app.pluginSettingsManager.getRoutePath('');
|
|
88
107
|
const settingsRootPathWithoutTrailingSlash = settingsRootPath.replace(/\/$/, '');
|
|
89
108
|
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
const shouldShowSidebar = activeGroupSettings.length > 1;
|
|
109
|
+
// No sidebar: single-entry groups never needed one, and multi-entry groups such as
|
|
110
|
+
// "other settings" now use the hover dropdown in the top bar. Children of a
|
|
111
|
+
// top-level setting (users / roles / sync) keep using the tabs under the header.
|
|
94
112
|
// 子页面一律走页头下的 Tab:左栏只表达「哪个模块」,模块内部的分页交给 Tab,
|
|
95
113
|
// 和 v1 设置中心保持一致。
|
|
96
114
|
const pageTabs = useMemo(() => {
|
|
@@ -108,13 +126,6 @@ export const InternalAdminSettingsLayout = () => {
|
|
|
108
126
|
);
|
|
109
127
|
return matched?.key ?? pageTabs[0]?.key;
|
|
110
128
|
}, [location.pathname, pageTabs]);
|
|
111
|
-
// 命中的可能是被折叠掉的子项,要换算成左栏里真实存在的那一级,否则整个左栏都不高亮。
|
|
112
|
-
const selectedMenuKey = useMemo(
|
|
113
|
-
() =>
|
|
114
|
-
getSidebarSelectedKey(activeGroupSettings, currentVisibleSetting?.name) ||
|
|
115
|
-
getSidebarSelectedKey(activeGroupSettings, currentVisibleTopLevelSetting?.name),
|
|
116
|
-
[activeGroupSettings, currentVisibleSetting?.name, currentVisibleTopLevelSetting?.name],
|
|
117
|
-
);
|
|
118
129
|
// 页头只需要补一句「当前在哪个子页」。
|
|
119
130
|
const pageSubTitle =
|
|
120
131
|
currentVisibleSetting && currentVisibleSetting.title !== currentTopLevelSetting?.title
|
|
@@ -136,6 +147,18 @@ export const InternalAdminSettingsLayout = () => {
|
|
|
136
147
|
location.pathname === settingsRootPath ||
|
|
137
148
|
location.pathname === settingsRootPathWithoutTrailingSlash ||
|
|
138
149
|
location.pathname === `${settingsRootPath}index`;
|
|
150
|
+
const currentPathWithoutTrailingSlash = location.pathname.replace(/\/+$/, '');
|
|
151
|
+
const currentTopLevelPathWithoutTrailingSlash = currentTopLevelSetting?.path.replace(/\/+$/, '');
|
|
152
|
+
const isTopLevelSettingsPath =
|
|
153
|
+
currentPathWithoutTrailingSlash === currentTopLevelPathWithoutTrailingSlash &&
|
|
154
|
+
!!currentTopLevelSetting?.children?.length;
|
|
155
|
+
const visibleIndexPath = isTopLevelSettingsPath
|
|
156
|
+
? currentVisibleTopLevelSetting?.children?.find((item) => item.pageKey === 'index')?.path
|
|
157
|
+
: undefined;
|
|
158
|
+
const firstVisibleChildPath = isTopLevelSettingsPath
|
|
159
|
+
? getDefaultSettingsPath(currentVisibleTopLevelSetting?.children as PluginSettingsPageType[])
|
|
160
|
+
: undefined;
|
|
161
|
+
const nextVisibleChildPath = visibleIndexPath || firstVisibleChildPath;
|
|
139
162
|
|
|
140
163
|
if (shouldRedirectToDefault && defaultSettingsPath) {
|
|
141
164
|
return <Navigate replace to={defaultSettingsPath} />;
|
|
@@ -146,27 +169,23 @@ export const InternalAdminSettingsLayout = () => {
|
|
|
146
169
|
}
|
|
147
170
|
|
|
148
171
|
if (!currentSetting) {
|
|
149
|
-
return <SettingsEmpty type="
|
|
172
|
+
return <SettingsEmpty type="not-found" />;
|
|
150
173
|
}
|
|
151
174
|
|
|
152
|
-
if (
|
|
153
|
-
|
|
175
|
+
if (currentSetting.isAllow === false) {
|
|
176
|
+
if (nextVisibleChildPath && nextVisibleChildPath !== location.pathname) {
|
|
177
|
+
return <Navigate replace to={nextVisibleChildPath} />;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return <SettingsEmpty type="forbidden" />;
|
|
154
181
|
}
|
|
155
182
|
|
|
156
183
|
if (currentSetting.link) {
|
|
157
184
|
return <Navigate replace to={currentSetting.link} />;
|
|
158
185
|
}
|
|
159
186
|
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
-
const firstVisibleChildPath = getDefaultSettingsPath(
|
|
163
|
-
currentVisibleTopLevelSetting?.children as PluginSettingsPageType[],
|
|
164
|
-
);
|
|
165
|
-
const nextPath = visibleIndexPath || firstVisibleChildPath;
|
|
166
|
-
|
|
167
|
-
if (nextPath && nextPath !== location.pathname) {
|
|
168
|
-
return <Navigate replace to={nextPath} />;
|
|
169
|
-
}
|
|
187
|
+
if (nextVisibleChildPath && nextVisibleChildPath !== location.pathname) {
|
|
188
|
+
return <Navigate replace to={nextVisibleChildPath} />;
|
|
170
189
|
}
|
|
171
190
|
|
|
172
191
|
return (
|
|
@@ -179,43 +198,6 @@ export const InternalAdminSettingsLayout = () => {
|
|
|
179
198
|
overflow: 'hidden',
|
|
180
199
|
}}
|
|
181
200
|
>
|
|
182
|
-
{shouldShowSidebar ? (
|
|
183
|
-
<Layout.Sider
|
|
184
|
-
width={200}
|
|
185
|
-
style={{
|
|
186
|
-
background: token.colorBgContainer,
|
|
187
|
-
borderInlineEnd: `${token.lineWidth}px solid ${token.colorBorderSecondary}`,
|
|
188
|
-
minHeight: 0,
|
|
189
|
-
overflowY: 'auto',
|
|
190
|
-
overflowX: 'hidden',
|
|
191
|
-
}}
|
|
192
|
-
>
|
|
193
|
-
<Menu
|
|
194
|
-
mode="inline"
|
|
195
|
-
inlineIndent={16}
|
|
196
|
-
selectedKeys={selectedMenuKey ? [selectedMenuKey] : []}
|
|
197
|
-
style={{ height: '100%', borderInlineEnd: 'none' }}
|
|
198
|
-
onClick={({ key }) => {
|
|
199
|
-
const setting = findSettingsByName(activeGroupSettings, String(key));
|
|
200
|
-
if (!setting) {
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
if (setting.link) {
|
|
205
|
-
window.open(setting.link, '_blank', 'noopener,noreferrer');
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const targetPath = setting.children?.length ? getDefaultSettingsPath(setting.children) : setting.path;
|
|
210
|
-
|
|
211
|
-
if (targetPath && targetPath !== location.pathname) {
|
|
212
|
-
navigate(targetPath);
|
|
213
|
-
}
|
|
214
|
-
}}
|
|
215
|
-
items={sidebarMenus}
|
|
216
|
-
/>
|
|
217
|
-
</Layout.Sider>
|
|
218
|
-
) : null}
|
|
219
201
|
<Layout.Content
|
|
220
202
|
style={{
|
|
221
203
|
background: token.colorBgLayout,
|
|
@@ -239,31 +221,39 @@ export const InternalAdminSettingsLayout = () => {
|
|
|
239
221
|
overflow: 'auto',
|
|
240
222
|
}}
|
|
241
223
|
>
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
224
|
+
{/*
|
|
225
|
+
The header is no longer a full-width white band: title and content share one
|
|
226
|
+
background and one max width, so the page reads as a single surface rather
|
|
227
|
+
than a title bar stacked on a content area.
|
|
228
|
+
*/}
|
|
229
|
+
<div style={{ margin: '0 auto', maxWidth: SETTINGS_CONTENT_MAX_WIDTH, padding: token.paddingLG }}>
|
|
230
|
+
<PageHeader
|
|
231
|
+
ghost
|
|
232
|
+
title={currentTopLevelSetting?.title}
|
|
233
|
+
subTitle={pageTabs.length ? undefined : pageSubTitle}
|
|
234
|
+
footer={
|
|
235
|
+
pageTabs.length ? (
|
|
236
|
+
<Tabs
|
|
237
|
+
activeKey={activeTabKey}
|
|
238
|
+
items={pageTabs}
|
|
239
|
+
tabBarStyle={{ marginBottom: 0 }}
|
|
240
|
+
onChange={(key) => {
|
|
241
|
+
if (key !== location.pathname) {
|
|
242
|
+
navigate(key);
|
|
243
|
+
}
|
|
244
|
+
}}
|
|
245
|
+
/>
|
|
246
|
+
) : undefined
|
|
247
|
+
}
|
|
248
|
+
// With the white band gone there is nothing separating header from content,
|
|
249
|
+
// so spacing does the job the bottom border used to do implicitly.
|
|
250
|
+
style={{
|
|
251
|
+
background: 'transparent',
|
|
252
|
+
marginBottom: token.marginLG,
|
|
253
|
+
paddingBlock: 0,
|
|
254
|
+
paddingInline: 0,
|
|
255
|
+
}}
|
|
256
|
+
/>
|
|
267
257
|
<Outlet />
|
|
268
258
|
</div>
|
|
269
259
|
</div>
|
|
@@ -0,0 +1,33 @@
|
|
|
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 {
|
|
11
|
+
decodeUIOperation as decode,
|
|
12
|
+
encodeUIOperation as encode,
|
|
13
|
+
parseUIOperation as parse,
|
|
14
|
+
removeUIOperation,
|
|
15
|
+
UI_OPERATION_QUERY_KEY,
|
|
16
|
+
type UIOperation,
|
|
17
|
+
} from '@nocobase/shared';
|
|
18
|
+
import { browserUIOperationCodec } from './ui-operation-codec';
|
|
19
|
+
|
|
20
|
+
export function parseUIOperation(search: string): UIOperation | undefined {
|
|
21
|
+
return parse(search, browserUIOperationCodec);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function decodeUIOperation(encoded: string): UIOperation | undefined {
|
|
25
|
+
return decode(encoded, browserUIOperationCodec);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function encodeUIOperation(operation: UIOperation): string {
|
|
29
|
+
return encode(operation, browserUIOperationCodec);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export { browserUIOperationCodec, removeUIOperation, UI_OPERATION_QUERY_KEY };
|
|
33
|
+
export type { UIOperation };
|
|
@@ -0,0 +1,54 @@
|
|
|
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 type { UIOperationCodec } from '@nocobase/shared';
|
|
11
|
+
|
|
12
|
+
const base64UrlPattern = /^[A-Za-z0-9_-]*$/;
|
|
13
|
+
const utf8Encoder = new TextEncoder();
|
|
14
|
+
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
|
|
15
|
+
|
|
16
|
+
function bytesToBinaryString(bytes: Uint8Array): string {
|
|
17
|
+
let result = '';
|
|
18
|
+
for (const byte of bytes) {
|
|
19
|
+
result += String.fromCharCode(byte);
|
|
20
|
+
}
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function binaryStringToBytes(value: string): Uint8Array {
|
|
25
|
+
const bytes = new Uint8Array(value.length);
|
|
26
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
27
|
+
bytes[index] = value.charCodeAt(index);
|
|
28
|
+
}
|
|
29
|
+
return bytes;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function encodeBrowserUIOperation(value: string): string {
|
|
33
|
+
return btoa(bytesToBinaryString(utf8Encoder.encode(value)))
|
|
34
|
+
.replace(/\+/g, '-')
|
|
35
|
+
.replace(/\//g, '_')
|
|
36
|
+
.replace(/=+$/, '');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const browserUIOperationCodec: UIOperationCodec = {
|
|
40
|
+
encode: encodeBrowserUIOperation,
|
|
41
|
+
decode(value) {
|
|
42
|
+
if (!base64UrlPattern.test(value) || value.length % 4 === 1) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const paddedValue = `${value.replace(/-/g, '+').replace(/_/g, '/')}${'='.repeat((4 - (value.length % 4)) % 4)}`;
|
|
48
|
+
const decoded = utf8Decoder.decode(binaryStringToBytes(atob(paddedValue)));
|
|
49
|
+
return encodeBrowserUIOperation(decoded) === value ? decoded : undefined;
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
};
|