@nocobase/client-v2 3.0.0-alpha.1 → 3.0.0-alpha.11

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 (133) hide show
  1. package/es/APIClient.d.ts +12 -0
  2. package/es/acl/aclCheckReadiness.d.ts +11 -0
  3. package/es/authRedirect.d.ts +6 -2
  4. package/es/flow/actions/linkageRules.d.ts +24 -0
  5. package/es/flow/components/FieldAssignValueInput.d.ts +4 -30
  6. package/es/flow/components/field-value-variable/DateVariableEditor.d.ts +24 -0
  7. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +31 -0
  8. package/es/flow/components/field-value-variable/dateValue.d.ts +36 -0
  9. package/es/flow/components/field-value-variable/index.d.ts +11 -0
  10. package/es/flow/components/placeholders/BlockPlaceholder.d.ts +1 -0
  11. package/es/flow/internal/utils/operatorSchemaHelper.d.ts +2 -2
  12. package/es/flow/models/actions/UpdateRecordActionUtils.d.ts +8 -2
  13. package/es/flow/models/base/CollectionBlockModel.d.ts +3 -0
  14. package/es/flow/models/blocks/details/DetailsBlockModel.d.ts +3 -0
  15. package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +1 -0
  16. package/es/flow/models/blocks/filter-form/FilterFormSubmitActionModel.d.ts +3 -1
  17. package/es/flow/models/blocks/form/FormBlockModel.d.ts +7 -0
  18. package/es/flow/models/blocks/form/FormGridModel.d.ts +6 -0
  19. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +1 -0
  20. package/es/flow/models/blocks/table/JSColumnModel.d.ts +2 -0
  21. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  22. package/es/flow/models/blocks/table/TableColumnModel.d.ts +11 -0
  23. package/es/flow/models/blocks/table/utils.d.ts +1 -0
  24. package/es/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.d.ts +2 -1
  25. package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +0 -17
  26. package/es/flow/models/fields/AssociationFieldModel/recordSelectShared.d.ts +41 -0
  27. package/es/flow-compat/passwordUtils.d.ts +1 -1
  28. package/es/index.d.ts +2 -1
  29. package/es/index.mjs +140 -140
  30. package/es/nocobase-buildin-plugin/index.d.ts +1 -0
  31. package/es/settings-app/SettingsGroupNav.d.ts +3 -2
  32. package/es/settings-app/SettingsSearch.d.ts +1 -1
  33. package/es/ui-operation/index.d.ts +15 -0
  34. package/es/ui-operation/ui-operation-codec.d.ts +10 -0
  35. package/lib/index.js +145 -145
  36. package/package.json +7 -7
  37. package/src/APIClient.ts +92 -0
  38. package/src/Application.tsx +3 -1
  39. package/src/__tests__/APIClient.test.tsx +58 -0
  40. package/src/__tests__/app.test.tsx +122 -3
  41. package/src/__tests__/authRedirect.test.ts +17 -0
  42. package/src/__tests__/browserChecker.test.ts +81 -33
  43. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +37 -0
  44. package/src/__tests__/settings-center.test.tsx +312 -11
  45. package/src/__tests__/settings-layout-root.test.tsx +346 -6
  46. package/src/__tests__/settings-runtime-paths.test.ts +1 -1
  47. package/src/__tests__/settings-search-shortcut.test.tsx +321 -0
  48. package/src/__tests__/settings-shell.test.tsx +62 -1
  49. package/src/acl/ACLProvider.tsx +34 -3
  50. package/src/acl/__tests__/ACLProvider.test.tsx +92 -0
  51. package/src/acl/aclCheckReadiness.ts +44 -0
  52. package/src/authRedirect.ts +13 -5
  53. package/src/components/AppComponents.tsx +16 -7
  54. package/src/components/form/filter/CollectionFilterItem.tsx +4 -2
  55. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +17 -0
  56. package/src/components/form/table/Table.tsx +81 -6
  57. package/src/components/form/table/__tests__/Table.columnWidth.test.tsx +433 -0
  58. package/src/flow/actions/__tests__/linkageRules.actionStates.test.ts +33 -0
  59. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +72 -0
  60. package/src/flow/actions/linkageRules.tsx +33 -10
  61. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +78 -59
  62. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +228 -0
  63. package/src/flow/components/FieldAssignValueInput.tsx +38 -621
  64. package/src/flow/components/field-value-variable/DateVariableEditor.tsx +181 -0
  65. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +326 -0
  66. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +380 -0
  67. package/src/flow/components/field-value-variable/dateValue.ts +223 -0
  68. package/src/flow/components/field-value-variable/index.ts +12 -0
  69. package/src/flow/components/filter/VariableFilterItem.tsx +12 -3
  70. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +125 -0
  71. package/src/flow/components/placeholders/BlockPlaceholder.tsx +70 -23
  72. package/src/flow/components/placeholders/__tests__/BlockPlaceholder.test.tsx +57 -7
  73. package/src/flow/internal/utils/operatorSchemaHelper.ts +3 -2
  74. package/src/flow/models/actions/UpdateRecordActionModel.tsx +18 -1
  75. package/src/flow/models/actions/UpdateRecordActionUtils.ts +18 -6
  76. package/src/flow/models/actions/__tests__/UpdateRecordActionModel.test.ts +76 -4
  77. package/src/flow/models/base/CollectionBlockModel.tsx +32 -1
  78. package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts +125 -9
  79. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +28 -53
  80. package/src/flow/models/blocks/details/DetailsBlockModel.tsx +8 -3
  81. package/src/flow/models/blocks/details/__tests__/DetailsBlockModel.initialPaginationChangeRefresh.test.ts +32 -1
  82. package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +36 -5
  83. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +129 -14
  84. package/src/flow/models/blocks/filter-form/FilterFormSubmitActionModel.tsx +57 -4
  85. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts +174 -1
  86. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +359 -1
  87. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +72 -0
  88. package/src/flow/models/blocks/filter-form/__tests__/FilterFormSubmitActionModel.lifecycle.test.ts +90 -0
  89. package/src/flow/models/blocks/filter-manager/FilterManager.ts +5 -0
  90. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +40 -0
  91. package/src/flow/models/blocks/form/FormBlockModel.tsx +47 -0
  92. package/src/flow/models/blocks/form/FormGridModel.tsx +4 -0
  93. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +8 -2
  94. package/src/flow/models/blocks/form/__tests__/runJsFormSubmit.test.ts +131 -0
  95. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +435 -0
  96. package/src/flow/models/blocks/form/value-runtime/rules.ts +67 -14
  97. package/src/flow/models/blocks/form/value-runtime/runtime.ts +43 -19
  98. package/src/flow/models/blocks/table/JSColumnModel.tsx +10 -1
  99. package/src/flow/models/blocks/table/TableBlockModel.tsx +25 -1
  100. package/src/flow/models/blocks/table/TableColumnModel.tsx +18 -8
  101. package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +52 -5
  102. package/src/flow/models/blocks/table/__tests__/TableBlockModel.filterReset.test.ts +78 -0
  103. package/src/flow/models/blocks/table/utils.ts +7 -0
  104. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +33 -1
  105. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +8 -0
  106. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/__tests__/popupContext.test.ts +120 -0
  107. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/actions/PopupSubTableEditActionModel.tsx +10 -2
  108. package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +24 -1
  109. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -136
  110. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +167 -3
  111. package/src/flow/models/fields/AssociationFieldModel/recordSelectShared.tsx +156 -0
  112. package/src/flow/models/fields/DisplayNumberFieldModel.tsx +1 -1
  113. package/src/flow/models/fields/NumberFieldModel.tsx +1 -1
  114. package/src/flow/models/fields/__tests__/DisplayNumberFieldModel.test.ts +33 -0
  115. package/src/flow/models/fields/__tests__/NumberFieldModel.test.tsx +47 -0
  116. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +6 -0
  117. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +27 -2
  118. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +139 -13
  119. package/src/index.ts +2 -0
  120. package/src/layout-manager/LayoutContentRoute.tsx +2 -1
  121. package/src/layout-manager/LayoutRoute.tsx +2 -1
  122. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +87 -1
  123. package/src/nocobase-buildin-plugin/index.tsx +14 -1
  124. package/src/settings-app/SettingsGroupNav.tsx +11 -11
  125. package/src/settings-app/SettingsSearch.tsx +115 -10
  126. package/src/settings-app/SettingsShell.tsx +23 -6
  127. package/src/settings-app/runtimePaths.ts +0 -1
  128. package/src/settings-app/settingsTheme.ts +82 -14
  129. package/src/settings-center/AdminSettingsLayout.tsx +132 -107
  130. package/src/settings-center/__tests__/groups.test.ts +25 -0
  131. package/src/settings-center/groups.ts +1 -1
  132. package/src/ui-operation/index.ts +33 -0
  133. package/src/ui-operation/ui-operation-codec.ts +54 -0
@@ -10,46 +10,81 @@
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
- import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
16
+ import { generatePath, Navigate, Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
17
+ import { getModernClientPrefix } from '../authRedirect';
17
18
  import type { PluginSettingsPageType } from '../PluginSettingsManager';
18
19
  import { useApp } from '../hooks/useApp';
20
+ import { resolveSettingsAppScopeWithinPublicPath } from '../settings-app/settingsDocumentPath';
19
21
  import { AdminSettingsLayoutModel } from './AdminSettingsLayoutModel';
20
22
  import { useSettingsGroups } from './useSettingsGroups';
21
23
  import {
22
24
  ADMIN_SETTINGS_LAYOUT_MODEL_UID,
23
25
  createSettingsPathMap,
24
- findSettingsByName,
25
26
  getDefaultSettingsPath,
26
- getSidebarMenuItems,
27
- getSidebarSelectedKey,
28
27
  matchSettingsRoute,
29
28
  } from './utils';
30
29
 
31
- function SettingsEmpty(props: { type: 'home' | 'route' }) {
30
+ /**
31
+ * Maximum width of the content area.
32
+ *
33
+ * Centers and narrows the content on wide screens: settings pages are mostly forms
34
+ * and lists, and across 2560px a single line sweeps the whole display, which is
35
+ * tiring to read and leaves the actions on the right far from the title on the left.
36
+ */
37
+ const SETTINGS_CONTENT_MAX_WIDTH = 1280;
38
+
39
+ function AdminDocumentRedirect() {
40
+ const app = useApp();
41
+ const rootPublicPath = app.getPublicPath().replace(/\/+$/, '');
42
+ const appScope = resolveSettingsAppScopeWithinPublicPath(app.getPublicPath(), app.router.getBasename?.());
43
+ const targetPath = `${rootPublicPath}/${getModernClientPrefix()}${appScope}/admin`;
44
+
45
+ useEffect(() => {
46
+ window.location.replace(targetPath);
47
+ }, [targetPath]);
48
+
49
+ return app.renderComponent('AppSpin');
50
+ }
51
+
52
+ function SettingsEmpty(props: { type: 'forbidden' | 'home' | 'not-found' }) {
32
53
  const { type } = props;
33
54
  const { t } = useTranslation();
34
55
 
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
- />
56
+ let status: '403' | '404' | 'info';
57
+ let title: string;
58
+ let subTitle: string;
59
+
60
+ if (type === 'forbidden') {
61
+ status = '403';
62
+ title = t('Your current role cannot access Settings');
63
+ subTitle = t('Switch to a role with access, or contact an administrator to request access.');
64
+ } else if (type === 'not-found') {
65
+ status = '404';
66
+ title = t('Settings page not found');
67
+ subTitle = t('The settings page you requested does not exist or has been removed.');
68
+ } else {
69
+ status = 'info';
70
+ title = t('No settings pages available');
71
+ subTitle = t(
72
+ 'No settings pages are currently available in Client V2. Settings registered by migrated plugins will appear here automatically.',
42
73
  );
43
74
  }
44
75
 
45
76
  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
- />
77
+ <div role="status" aria-atomic="true">
78
+ <Result
79
+ status={status}
80
+ title={
81
+ <span role="heading" aria-level={1}>
82
+ {title}
83
+ </span>
84
+ }
85
+ subTitle={subTitle}
86
+ />
87
+ </div>
53
88
  );
54
89
  }
55
90
 
@@ -63,9 +98,12 @@ export const InternalAdminSettingsLayout = () => {
63
98
  const app = useApp();
64
99
  const navigate = useNavigate();
65
100
  const location = useLocation();
101
+ const params = useParams();
102
+ const routeParams = Object.fromEntries(
103
+ Object.entries(params).filter((entry): entry is [string, string] => typeof entry[1] === 'string'),
104
+ );
66
105
  const { token } = theme.useToken();
67
106
  const {
68
- activeGroupSettings,
69
107
  allSettings,
70
108
  currentSetting,
71
109
  currentTopLevelSetting,
@@ -87,10 +125,9 @@ export const InternalAdminSettingsLayout = () => {
87
125
  const settingsRootPath = app.pluginSettingsManager.getRoutePath('');
88
126
  const settingsRootPathWithoutTrailingSlash = settingsRootPath.replace(/\/$/, '');
89
127
 
90
- const sidebarMenus = useMemo(() => getSidebarMenuItems(activeGroupSettings), [activeGroupSettings]);
91
- // 分组里只有一个顶级配置项时不铺左栏:那样整条侧栏只是把顶栏那一项重复一遍。
92
- // 它自己的下级(用户和权限的 用户 / 角色和权限 / 同步,AI 员工的几个页面)改用页头下的 Tab。
93
- const shouldShowSidebar = activeGroupSettings.length > 1;
128
+ // No sidebar: single-entry groups never needed one, and multi-entry groups such as
129
+ // "other settings" now use the hover dropdown in the top bar. Children of a
130
+ // top-level setting (users / roles / sync) keep using the tabs under the header.
94
131
  // 子页面一律走页头下的 Tab:左栏只表达「哪个模块」,模块内部的分页交给 Tab,
95
132
  // 和 v1 设置中心保持一致。
96
133
  const pageTabs = useMemo(() => {
@@ -108,13 +145,6 @@ export const InternalAdminSettingsLayout = () => {
108
145
  );
109
146
  return matched?.key ?? pageTabs[0]?.key;
110
147
  }, [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
148
  // 页头只需要补一句「当前在哪个子页」。
119
149
  const pageSubTitle =
120
150
  currentVisibleSetting && currentVisibleSetting.title !== currentTopLevelSetting?.title
@@ -136,6 +166,18 @@ export const InternalAdminSettingsLayout = () => {
136
166
  location.pathname === settingsRootPath ||
137
167
  location.pathname === settingsRootPathWithoutTrailingSlash ||
138
168
  location.pathname === `${settingsRootPath}index`;
169
+ const currentPathWithoutTrailingSlash = location.pathname.replace(/\/+$/, '');
170
+ const currentTopLevelPathWithoutTrailingSlash = currentTopLevelSetting?.path.replace(/\/+$/, '');
171
+ const isTopLevelSettingsPath =
172
+ currentPathWithoutTrailingSlash === currentTopLevelPathWithoutTrailingSlash &&
173
+ !!currentTopLevelSetting?.children?.length;
174
+ const visibleIndexPath = isTopLevelSettingsPath
175
+ ? currentVisibleTopLevelSetting?.children?.find((item) => item.pageKey === 'index')?.path
176
+ : undefined;
177
+ const firstVisibleChildPath = isTopLevelSettingsPath
178
+ ? getDefaultSettingsPath(currentVisibleTopLevelSetting?.children as PluginSettingsPageType[])
179
+ : undefined;
180
+ const nextVisibleChildPath = visibleIndexPath || firstVisibleChildPath;
139
181
 
140
182
  if (shouldRedirectToDefault && defaultSettingsPath) {
141
183
  return <Navigate replace to={defaultSettingsPath} />;
@@ -146,27 +188,39 @@ export const InternalAdminSettingsLayout = () => {
146
188
  }
147
189
 
148
190
  if (!currentSetting) {
149
- return <SettingsEmpty type="route" />;
191
+ return <SettingsEmpty type="not-found" />;
150
192
  }
151
193
 
152
- if (!currentVisibleSetting && currentSetting.isAllow === false) {
153
- return <SettingsEmpty type="route" />;
194
+ if (currentSetting.isAllow === false) {
195
+ const firstVisibleTabPath = (currentVisibleTopLevelSetting?.children || [])
196
+ .map((tab) => getDefaultSettingsPath([tab]))
197
+ .filter((path): path is string => typeof path === 'string')
198
+ .map((path) => {
199
+ try {
200
+ return generatePath(path, routeParams);
201
+ } catch {
202
+ return null;
203
+ }
204
+ })
205
+ .find((path): path is string => typeof path === 'string');
206
+
207
+ if (firstVisibleTabPath) {
208
+ return <Navigate replace to={firstVisibleTabPath} />;
209
+ }
210
+
211
+ if (!defaultSettingsPath) {
212
+ return <AdminDocumentRedirect />;
213
+ }
214
+
215
+ return <SettingsEmpty type="forbidden" />;
154
216
  }
155
217
 
156
218
  if (currentSetting.link) {
157
219
  return <Navigate replace to={currentSetting.link} />;
158
220
  }
159
221
 
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
- }
222
+ if (nextVisibleChildPath && nextVisibleChildPath !== location.pathname) {
223
+ return <Navigate replace to={nextVisibleChildPath} />;
170
224
  }
171
225
 
172
226
  return (
@@ -179,43 +233,6 @@ export const InternalAdminSettingsLayout = () => {
179
233
  overflow: 'hidden',
180
234
  }}
181
235
  >
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
236
  <Layout.Content
220
237
  style={{
221
238
  background: token.colorBgLayout,
@@ -239,31 +256,39 @@ export const InternalAdminSettingsLayout = () => {
239
256
  overflow: 'auto',
240
257
  }}
241
258
  >
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 }}>
259
+ {/*
260
+ The header is no longer a full-width white band: title and content share one
261
+ background and one max width, so the page reads as a single surface rather
262
+ than a title bar stacked on a content area.
263
+ */}
264
+ <div style={{ margin: '0 auto', maxWidth: SETTINGS_CONTENT_MAX_WIDTH, padding: token.paddingLG }}>
265
+ <PageHeader
266
+ ghost
267
+ title={currentTopLevelSetting?.title}
268
+ subTitle={pageTabs.length ? undefined : pageSubTitle}
269
+ footer={
270
+ pageTabs.length ? (
271
+ <Tabs
272
+ activeKey={activeTabKey}
273
+ items={pageTabs}
274
+ tabBarStyle={{ marginBottom: 0 }}
275
+ onChange={(key) => {
276
+ if (key !== location.pathname) {
277
+ navigate(key);
278
+ }
279
+ }}
280
+ />
281
+ ) : undefined
282
+ }
283
+ // With the white band gone there is nothing separating header from content,
284
+ // so spacing does the job the bottom border used to do implicitly.
285
+ style={{
286
+ background: 'transparent',
287
+ marginBottom: token.marginLG,
288
+ paddingBlock: 0,
289
+ paddingInline: 0,
290
+ }}
291
+ />
267
292
  <Outlet />
268
293
  </div>
269
294
  </div>
@@ -0,0 +1,25 @@
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 { describe, expect, it } from 'vitest';
11
+ import { SETTINGS_GROUPS } from '../groups';
12
+
13
+ describe('settings center groups', () => {
14
+ it('places the plugin manager immediately after AI employees', () => {
15
+ expect(SETTINGS_GROUPS.map((group) => group.key)).toEqual([
16
+ 'applications',
17
+ 'data',
18
+ 'access',
19
+ 'automation',
20
+ 'ai',
21
+ 'plugins',
22
+ 'system',
23
+ ]);
24
+ });
25
+ });
@@ -42,11 +42,11 @@ export type SettingsGroupDefinition = {
42
42
  */
43
43
  export const SETTINGS_GROUPS: SettingsGroupDefinition[] = [
44
44
  { key: 'applications', title: 'Applications', primary: 'multi-portal' },
45
- { key: 'plugins', title: 'Plugin manager', primary: 'plugin-manager' },
46
45
  { key: 'data', title: 'Data sources', primary: 'data-source-manager' },
47
46
  { key: 'access', title: 'Users & Permissions', primary: 'users-permissions' },
48
47
  { key: 'automation', title: 'Workflow', primary: 'workflow' },
49
48
  { key: 'ai', title: 'AI employees', primary: 'ai' },
49
+ { key: 'plugins', title: 'Plugin manager', primary: 'plugin-manager' },
50
50
  { key: 'system', title: 'Other settings' },
51
51
  ];
52
52
 
@@ -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
+ };