@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.
Files changed (32) hide show
  1. package/es/acl/aclCheckReadiness.d.ts +11 -0
  2. package/es/index.d.ts +2 -1
  3. package/es/index.mjs +46 -46
  4. package/es/nocobase-buildin-plugin/index.d.ts +1 -0
  5. package/es/settings-app/SettingsGroupNav.d.ts +3 -2
  6. package/es/settings-app/SettingsSearch.d.ts +1 -1
  7. package/es/ui-operation/index.d.ts +15 -0
  8. package/es/ui-operation/ui-operation-codec.d.ts +10 -0
  9. package/lib/index.js +65 -65
  10. package/package.json +7 -7
  11. package/src/__tests__/browserChecker.test.ts +44 -33
  12. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +37 -0
  13. package/src/__tests__/settings-center.test.tsx +110 -10
  14. package/src/__tests__/settings-layout-root.test.tsx +346 -6
  15. package/src/__tests__/settings-runtime-paths.test.ts +1 -1
  16. package/src/__tests__/settings-search-shortcut.test.tsx +321 -0
  17. package/src/__tests__/settings-shell.test.tsx +35 -0
  18. package/src/acl/ACLProvider.tsx +32 -3
  19. package/src/acl/aclCheckReadiness.ts +44 -0
  20. package/src/index.ts +2 -0
  21. package/src/layout-manager/LayoutContentRoute.tsx +2 -1
  22. package/src/layout-manager/LayoutRoute.tsx +2 -1
  23. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +87 -1
  24. package/src/nocobase-buildin-plugin/index.tsx +14 -1
  25. package/src/settings-app/SettingsGroupNav.tsx +11 -11
  26. package/src/settings-app/SettingsSearch.tsx +115 -10
  27. package/src/settings-app/SettingsShell.tsx +4 -2
  28. package/src/settings-app/runtimePaths.ts +0 -1
  29. package/src/settings-app/settingsTheme.ts +82 -14
  30. package/src/settings-center/AdminSettingsLayout.tsx +96 -106
  31. package/src/ui-operation/index.ts +33 -0
  32. 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, Menu, Result, Tabs, theme } from 'antd';
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
- function SettingsEmpty(props: { type: 'home' | 'route' }) {
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
- if (type === 'route') {
36
- return (
37
- <Result
38
- status="warning"
39
- title={t('Current settings page is unavailable')}
40
- subTitle={t('The requested settings page does not exist or you do not have permission to access it.')}
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
- <Result
47
- status="info"
48
- title={t('No settings pages available')}
49
- subTitle={t(
50
- 'No settings pages are currently available in Client V2. Settings registered by migrated plugins will appear here automatically.',
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
- const sidebarMenus = useMemo(() => getSidebarMenuItems(activeGroupSettings), [activeGroupSettings]);
91
- // 分组里只有一个顶级配置项时不铺左栏:那样整条侧栏只是把顶栏那一项重复一遍。
92
- // 它自己的下级(用户和权限的 用户 / 角色和权限 / 同步,AI 员工的几个页面)改用页头下的 Tab。
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="route" />;
172
+ return <SettingsEmpty type="not-found" />;
150
173
  }
151
174
 
152
- if (!currentVisibleSetting && currentSetting.isAllow === false) {
153
- return <SettingsEmpty type="route" />;
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 (location.pathname === currentTopLevelSetting?.path && currentTopLevelSetting?.children?.length) {
161
- const visibleIndexPath = currentVisibleTopLevelSetting?.children?.find((item) => item.pageKey === 'index')?.path;
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
- <PageHeader
243
- ghost={false}
244
- title={currentTopLevelSetting?.title}
245
- subTitle={pageTabs.length ? undefined : pageSubTitle}
246
- footer={
247
- pageTabs.length ? (
248
- <Tabs
249
- activeKey={activeTabKey}
250
- items={pageTabs}
251
- tabBarStyle={{ marginBottom: 0 }}
252
- onChange={(key) => {
253
- if (key !== location.pathname) {
254
- navigate(key);
255
- }
256
- }}
257
- />
258
- ) : undefined
259
- }
260
- style={{
261
- background: token.colorBgContainer,
262
- borderBlockEnd: `${token.lineWidth}px solid ${token.colorBorderSecondary}`,
263
- paddingBottom: pageTabs.length ? 0 : token.padding,
264
- }}
265
- />
266
- <div style={{ padding: token.paddingLG }}>
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
+ };