@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.
- package/es/APIClient.d.ts +12 -0
- package/es/acl/aclCheckReadiness.d.ts +11 -0
- package/es/authRedirect.d.ts +6 -2
- package/es/flow/actions/linkageRules.d.ts +24 -0
- package/es/flow/components/FieldAssignValueInput.d.ts +4 -30
- package/es/flow/components/field-value-variable/DateVariableEditor.d.ts +24 -0
- package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +31 -0
- package/es/flow/components/field-value-variable/dateValue.d.ts +36 -0
- package/es/flow/components/field-value-variable/index.d.ts +11 -0
- package/es/flow/components/placeholders/BlockPlaceholder.d.ts +1 -0
- package/es/flow/internal/utils/operatorSchemaHelper.d.ts +2 -2
- package/es/flow/models/actions/UpdateRecordActionUtils.d.ts +8 -2
- package/es/flow/models/base/CollectionBlockModel.d.ts +3 -0
- package/es/flow/models/blocks/details/DetailsBlockModel.d.ts +3 -0
- package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +1 -0
- package/es/flow/models/blocks/filter-form/FilterFormSubmitActionModel.d.ts +3 -1
- package/es/flow/models/blocks/form/FormBlockModel.d.ts +7 -0
- package/es/flow/models/blocks/form/FormGridModel.d.ts +6 -0
- package/es/flow/models/blocks/form/value-runtime/rules.d.ts +1 -0
- package/es/flow/models/blocks/table/JSColumnModel.d.ts +2 -0
- package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
- package/es/flow/models/blocks/table/TableColumnModel.d.ts +11 -0
- package/es/flow/models/blocks/table/utils.d.ts +1 -0
- package/es/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.d.ts +2 -1
- package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +0 -17
- package/es/flow/models/fields/AssociationFieldModel/recordSelectShared.d.ts +41 -0
- package/es/flow-compat/passwordUtils.d.ts +1 -1
- package/es/index.d.ts +2 -1
- package/es/index.mjs +140 -140
- 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 +145 -145
- package/package.json +7 -7
- package/src/APIClient.ts +92 -0
- package/src/Application.tsx +3 -1
- package/src/__tests__/APIClient.test.tsx +58 -0
- package/src/__tests__/app.test.tsx +122 -3
- package/src/__tests__/authRedirect.test.ts +17 -0
- package/src/__tests__/browserChecker.test.ts +81 -33
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +37 -0
- package/src/__tests__/settings-center.test.tsx +312 -11
- 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 +62 -1
- package/src/acl/ACLProvider.tsx +34 -3
- package/src/acl/__tests__/ACLProvider.test.tsx +92 -0
- package/src/acl/aclCheckReadiness.ts +44 -0
- package/src/authRedirect.ts +13 -5
- package/src/components/AppComponents.tsx +16 -7
- package/src/components/form/filter/CollectionFilterItem.tsx +4 -2
- package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +17 -0
- package/src/components/form/table/Table.tsx +81 -6
- package/src/components/form/table/__tests__/Table.columnWidth.test.tsx +433 -0
- package/src/flow/actions/__tests__/linkageRules.actionStates.test.ts +33 -0
- package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +72 -0
- package/src/flow/actions/linkageRules.tsx +33 -10
- package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +78 -59
- package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +228 -0
- package/src/flow/components/FieldAssignValueInput.tsx +38 -621
- package/src/flow/components/field-value-variable/DateVariableEditor.tsx +181 -0
- package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +326 -0
- package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +380 -0
- package/src/flow/components/field-value-variable/dateValue.ts +223 -0
- package/src/flow/components/field-value-variable/index.ts +12 -0
- package/src/flow/components/filter/VariableFilterItem.tsx +12 -3
- package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +125 -0
- package/src/flow/components/placeholders/BlockPlaceholder.tsx +70 -23
- package/src/flow/components/placeholders/__tests__/BlockPlaceholder.test.tsx +57 -7
- package/src/flow/internal/utils/operatorSchemaHelper.ts +3 -2
- package/src/flow/models/actions/UpdateRecordActionModel.tsx +18 -1
- package/src/flow/models/actions/UpdateRecordActionUtils.ts +18 -6
- package/src/flow/models/actions/__tests__/UpdateRecordActionModel.test.ts +76 -4
- package/src/flow/models/base/CollectionBlockModel.tsx +32 -1
- package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts +125 -9
- package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +28 -53
- package/src/flow/models/blocks/details/DetailsBlockModel.tsx +8 -3
- package/src/flow/models/blocks/details/__tests__/DetailsBlockModel.initialPaginationChangeRefresh.test.ts +32 -1
- package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +36 -5
- package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +129 -14
- package/src/flow/models/blocks/filter-form/FilterFormSubmitActionModel.tsx +57 -4
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts +174 -1
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +359 -1
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +72 -0
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormSubmitActionModel.lifecycle.test.ts +90 -0
- package/src/flow/models/blocks/filter-manager/FilterManager.ts +5 -0
- package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +40 -0
- package/src/flow/models/blocks/form/FormBlockModel.tsx +47 -0
- package/src/flow/models/blocks/form/FormGridModel.tsx +4 -0
- package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +8 -2
- package/src/flow/models/blocks/form/__tests__/runJsFormSubmit.test.ts +131 -0
- package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +435 -0
- package/src/flow/models/blocks/form/value-runtime/rules.ts +67 -14
- package/src/flow/models/blocks/form/value-runtime/runtime.ts +43 -19
- package/src/flow/models/blocks/table/JSColumnModel.tsx +10 -1
- package/src/flow/models/blocks/table/TableBlockModel.tsx +25 -1
- package/src/flow/models/blocks/table/TableColumnModel.tsx +18 -8
- package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +52 -5
- package/src/flow/models/blocks/table/__tests__/TableBlockModel.filterReset.test.ts +78 -0
- package/src/flow/models/blocks/table/utils.ts +7 -0
- package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +33 -1
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +8 -0
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/__tests__/popupContext.test.ts +120 -0
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/actions/PopupSubTableEditActionModel.tsx +10 -2
- package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +24 -1
- package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -136
- package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +167 -3
- package/src/flow/models/fields/AssociationFieldModel/recordSelectShared.tsx +156 -0
- package/src/flow/models/fields/DisplayNumberFieldModel.tsx +1 -1
- package/src/flow/models/fields/NumberFieldModel.tsx +1 -1
- package/src/flow/models/fields/__tests__/DisplayNumberFieldModel.test.ts +33 -0
- package/src/flow/models/fields/__tests__/NumberFieldModel.test.tsx +47 -0
- package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +6 -0
- package/src/flow/models/fields/mobile-components/MobileSelect.tsx +27 -2
- package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +139 -13
- 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 +23 -6
- package/src/settings-app/runtimePaths.ts +0 -1
- package/src/settings-app/settingsTheme.ts +82 -14
- package/src/settings-center/AdminSettingsLayout.tsx +132 -107
- package/src/settings-center/__tests__/groups.test.ts +25 -0
- package/src/settings-center/groups.ts +1 -1
- package/src/ui-operation/index.ts +33 -0
- package/src/ui-operation/ui-operation-codec.ts +54 -0
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { FlowEngine, FlowEngineProvider, observer } from '@nocobase/flow-engine';
|
|
11
|
-
import { render, screen, waitFor } from '@testing-library/react';
|
|
11
|
+
import { act, render, screen, waitFor } from '@testing-library/react';
|
|
12
12
|
import React from 'react';
|
|
13
13
|
import { createMemoryRouter, Outlet, RouterProvider, useOutlet } from 'react-router-dom';
|
|
14
14
|
import { describe, expect, it, vi } from 'vitest';
|
|
@@ -106,6 +106,64 @@ describe('LayoutRoute', () => {
|
|
|
106
106
|
});
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
+
it('syncs updated router state when navigating to the same layout pathname', async () => {
|
|
110
|
+
const syncLayoutRoute = vi.fn();
|
|
111
|
+
|
|
112
|
+
class StateTrackingLayoutModel extends TestLayoutModel {
|
|
113
|
+
syncLayoutRoute(routeLike: Parameters<BaseLayoutModel['syncLayoutRoute']>[0]) {
|
|
114
|
+
syncLayoutRoute(routeLike);
|
|
115
|
+
return super.syncLayoutRoute(routeLike);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const stateTrackingLayout: LayoutDefinition = {
|
|
120
|
+
...layout,
|
|
121
|
+
layoutModelClass: 'StateTrackingLayoutModel',
|
|
122
|
+
};
|
|
123
|
+
const engine = new FlowEngine();
|
|
124
|
+
engine.registerModels({ StateTrackingLayoutModel });
|
|
125
|
+
engine.context.defineProperty('app', {
|
|
126
|
+
value: {
|
|
127
|
+
layoutManager: {
|
|
128
|
+
getLayout: () => stateTrackingLayout,
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const router = createMemoryRouter(
|
|
134
|
+
[
|
|
135
|
+
{
|
|
136
|
+
id: layout.routeName,
|
|
137
|
+
path: layout.routePath,
|
|
138
|
+
element: <LayoutRoute layoutRouteName="test" />,
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
{
|
|
142
|
+
initialEntries: ['/test'],
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
render(
|
|
147
|
+
<FlowEngineProvider engine={engine}>
|
|
148
|
+
<RouterProvider router={router} />
|
|
149
|
+
</FlowEngineProvider>,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
expect(await screen.findByTestId('layout-route')).toHaveTextContent('test');
|
|
153
|
+
syncLayoutRoute.mockClear();
|
|
154
|
+
|
|
155
|
+
const routeState = {
|
|
156
|
+
__nocobaseOpenViewInputArgs: { popup: { formData: { status: 'todo' } } },
|
|
157
|
+
};
|
|
158
|
+
await act(async () => {
|
|
159
|
+
await router.navigate('/test', { state: routeState });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await waitFor(() => {
|
|
163
|
+
expect(syncLayoutRoute).toHaveBeenCalledWith(expect.objectContaining({ state: routeState }));
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
109
167
|
it('does not activate desktop route loading for generic layouts', async () => {
|
|
110
168
|
const activateLayout = vi.fn(() => vi.fn());
|
|
111
169
|
const engine = new FlowEngine();
|
|
@@ -413,6 +471,34 @@ describe('LayoutContentRoute', () => {
|
|
|
413
471
|
});
|
|
414
472
|
});
|
|
415
473
|
|
|
474
|
+
it('syncs updated router state when navigating to the same content pathname', async () => {
|
|
475
|
+
const syncLayoutRoute = vi.fn();
|
|
476
|
+
|
|
477
|
+
class StateTrackingLayoutModel extends TestLayoutModel {
|
|
478
|
+
syncLayoutRoute(routeLike: Parameters<BaseLayoutModel['syncLayoutRoute']>[0]) {
|
|
479
|
+
syncLayoutRoute(routeLike);
|
|
480
|
+
return super.syncLayoutRoute(routeLike);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const { router } = setup('/test/page-1/view/popup', layout, StateTrackingLayoutModel);
|
|
485
|
+
await waitFor(() => {
|
|
486
|
+
expect(syncLayoutRoute).toHaveBeenCalled();
|
|
487
|
+
});
|
|
488
|
+
syncLayoutRoute.mockClear();
|
|
489
|
+
|
|
490
|
+
const routeState = {
|
|
491
|
+
__nocobaseOpenViewInputArgs: { popup: { formData: { status: 'todo' } } },
|
|
492
|
+
};
|
|
493
|
+
await act(async () => {
|
|
494
|
+
await router.navigate('/test/page-1/view/popup', { state: routeState });
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
await waitFor(() => {
|
|
498
|
+
expect(syncLayoutRoute).toHaveBeenCalledWith(expect.objectContaining({ state: routeState }));
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
416
502
|
it('parses nested layout route by matched layout pathname', async () => {
|
|
417
503
|
const nestedLayout: LayoutDefinition = {
|
|
418
504
|
...layout,
|
|
@@ -181,6 +181,18 @@ const DataSourceBootstrapProvider: FC = ({ children }) => {
|
|
|
181
181
|
return <>{children}</>;
|
|
182
182
|
};
|
|
183
183
|
|
|
184
|
+
export function resolveUnauthenticatedSignInRoute(app: Application, pathname: string) {
|
|
185
|
+
const matchedRoutes = app.router.matchRoutes(pathname) || [];
|
|
186
|
+
for (let index = matchedRoutes.length - 1; index >= 0; index -= 1) {
|
|
187
|
+
const pathnameBase = matchedRoutes[index].pathnameBase.replace(/\/+$/g, '');
|
|
188
|
+
const candidate = `${pathnameBase}/signin`;
|
|
189
|
+
if (app.router.isSkippedAuthCheckRoute(candidate)) {
|
|
190
|
+
return candidate;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return '/signin';
|
|
194
|
+
}
|
|
195
|
+
|
|
184
196
|
function redirectUnauthenticatedRoute(
|
|
185
197
|
app: Application,
|
|
186
198
|
location: { pathname: string; search?: string; hash?: string },
|
|
@@ -191,7 +203,8 @@ function redirectUnauthenticatedRoute(
|
|
|
191
203
|
redirectToV2Signin(app, redirectPath);
|
|
192
204
|
return;
|
|
193
205
|
}
|
|
194
|
-
|
|
206
|
+
const signInPath = resolveUnauthenticatedSignInRoute(app, location.pathname);
|
|
207
|
+
navigate(`${signInPath}?redirect=${encodeURIComponent(redirectPath)}`, { replace: true });
|
|
195
208
|
}
|
|
196
209
|
|
|
197
210
|
const CurrentUserProvider: FC = ({ children }) => {
|
|
@@ -12,6 +12,7 @@ import { ConfigProvider, Menu, theme as antdTheme } from 'antd';
|
|
|
12
12
|
import React, { useCallback, useMemo } from 'react';
|
|
13
13
|
import { useTranslation } from 'react-i18next';
|
|
14
14
|
import { useLocation, useNavigate } from 'react-router-dom';
|
|
15
|
+
import { useACLCheckReady } from '../acl/aclCheckReadiness';
|
|
15
16
|
import { useApp } from '../hooks/useApp';
|
|
16
17
|
import { useSettingsGroups } from '../settings-center/useSettingsGroups';
|
|
17
18
|
import { buildSettingsHeaderMenuTheme } from './settingsTheme';
|
|
@@ -27,8 +28,9 @@ const navStyle: React.CSSProperties = {
|
|
|
27
28
|
/**
|
|
28
29
|
* 设置中心顶栏的一级导航。
|
|
29
30
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
31
|
+
* Applications and the plugin manager have a single page each and act as top-level
|
|
32
|
+
* entries; everything else sits under "other settings" and expands on hover — that
|
|
33
|
+
* group has no sidebar, so the dropdown is the only way into its pages.
|
|
32
34
|
*/
|
|
33
35
|
export const SettingsGroupNav: React.FC = observer(() => {
|
|
34
36
|
const { t } = useTranslation();
|
|
@@ -37,6 +39,7 @@ export const SettingsGroupNav: React.FC = observer(() => {
|
|
|
37
39
|
const location = useLocation();
|
|
38
40
|
const { token } = antdTheme.useToken();
|
|
39
41
|
const { groups, activeGroupKey, currentTopLevelSetting, getGroupEntryPath } = useSettingsGroups();
|
|
42
|
+
const isACLReady = useACLCheckReady(app);
|
|
40
43
|
const headerMenuTheme = useMemo(() => buildSettingsHeaderMenuTheme(token), [token]);
|
|
41
44
|
// 登录 / 找回密码等免鉴权页面共用同一个 shell,这些页面上不该出现设置导航。
|
|
42
45
|
const isAuthRoute = app.router.isSkippedAuthCheckRoute(location.pathname);
|
|
@@ -68,11 +71,8 @@ export const SettingsGroupNav: React.FC = observer(() => {
|
|
|
68
71
|
};
|
|
69
72
|
}
|
|
70
73
|
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
return { key: group.key, label: t(group.title) };
|
|
74
|
-
}
|
|
75
|
-
|
|
74
|
+
// The dropdown still opens while standing inside the group: with the sidebar
|
|
75
|
+
// gone, this is the only way to reach the other pages in it.
|
|
76
76
|
return {
|
|
77
77
|
key: group.key,
|
|
78
78
|
label: t(group.title),
|
|
@@ -83,11 +83,11 @@ export const SettingsGroupNav: React.FC = observer(() => {
|
|
|
83
83
|
label: setting.label ?? setting.title,
|
|
84
84
|
icon: setting.icon,
|
|
85
85
|
})),
|
|
86
|
-
//
|
|
87
|
-
|
|
86
|
+
// The group title only expands the dropdown and is not clickable: it is not a
|
|
87
|
+
// page, so clicking it has no well-defined destination. Pick a page instead.
|
|
88
88
|
};
|
|
89
89
|
}),
|
|
90
|
-
[
|
|
90
|
+
[groups, handleClick, t],
|
|
91
91
|
);
|
|
92
92
|
|
|
93
93
|
// 子菜单标题只有在它的某个子项被选中时才会高亮,所以两个 key 都要给。
|
|
@@ -100,7 +100,7 @@ export const SettingsGroupNav: React.FC = observer(() => {
|
|
|
100
100
|
: [activeGroupKey];
|
|
101
101
|
}, [activeGroupKey, currentTopLevelSetting?.name]);
|
|
102
102
|
|
|
103
|
-
if (isAuthRoute || items.length <= 1) {
|
|
103
|
+
if (isAuthRoute || !isACLReady || items.length <= 1) {
|
|
104
104
|
return <div style={{ flex: 'auto' }} />;
|
|
105
105
|
}
|
|
106
106
|
|
|
@@ -15,12 +15,13 @@ import type { InputRef } from 'antd';
|
|
|
15
15
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
16
16
|
import { useTranslation } from 'react-i18next';
|
|
17
17
|
import { useLocation, useNavigate } from 'react-router-dom';
|
|
18
|
+
import { useACLCheckReady } from '../acl/aclCheckReadiness';
|
|
18
19
|
import { useApp } from '../hooks/useApp';
|
|
19
20
|
import { useSettingsSearch, type SettingsSearchItem } from '../settings-center/useSettingsSearch';
|
|
20
21
|
import { getSettingsHeaderColors } from './settingsTheme';
|
|
21
22
|
|
|
22
23
|
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || '');
|
|
23
|
-
const SHORTCUT_LABEL = isMac ? '⌘
|
|
24
|
+
const SHORTCUT_LABEL = isMac ? '⌘F' : 'Ctrl F';
|
|
24
25
|
|
|
25
26
|
const triggerClassName = css`
|
|
26
27
|
align-items: center;
|
|
@@ -54,7 +55,7 @@ const resultItemClassName = css`
|
|
|
54
55
|
* 设置中心的搜索入口。
|
|
55
56
|
*
|
|
56
57
|
* 顶栏放一个轻量触发器,真正的搜索在弹层里进行;关键词为空时展示最近访问,
|
|
57
|
-
* 这样分组化之后被收进左侧栏的深层页面仍然一步可达。支持 `Cmd/Ctrl +
|
|
58
|
+
* 这样分组化之后被收进左侧栏的深层页面仍然一步可达。支持 `Cmd/Ctrl + F` 唤起。
|
|
58
59
|
*/
|
|
59
60
|
export const SettingsSearch: React.FC = observer(() => {
|
|
60
61
|
const { t } = useTranslation();
|
|
@@ -63,11 +64,15 @@ export const SettingsSearch: React.FC = observer(() => {
|
|
|
63
64
|
const location = useLocation();
|
|
64
65
|
const { token } = antdTheme.useToken();
|
|
65
66
|
const headerColors = getSettingsHeaderColors(token);
|
|
67
|
+
const isACLReady = useACLCheckReady(app);
|
|
66
68
|
const { recentItems, search } = useSettingsSearch();
|
|
67
69
|
const [open, setOpen] = useState(false);
|
|
68
70
|
const [keyword, setKeyword] = useState('');
|
|
69
71
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
70
72
|
const inputRef = useRef<InputRef>(null);
|
|
73
|
+
const triggerRef = useRef<HTMLDivElement>(null);
|
|
74
|
+
const lastActiveElementRef = useRef<HTMLElement | null>(null);
|
|
75
|
+
const isPointerDownRef = useRef(false);
|
|
71
76
|
|
|
72
77
|
// 登录 / 找回密码等免鉴权页面共用同一个 shell,这些页面上没有可搜索的配置项。
|
|
73
78
|
const isAuthRoute = app.router.isSkippedAuthCheckRoute(location.pathname);
|
|
@@ -78,10 +83,33 @@ export const SettingsSearch: React.FC = observer(() => {
|
|
|
78
83
|
setActiveIndex(0);
|
|
79
84
|
}, [keyword]);
|
|
80
85
|
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (isAuthRoute || !isACLReady) {
|
|
88
|
+
setOpen(false);
|
|
89
|
+
setKeyword('');
|
|
90
|
+
}
|
|
91
|
+
}, [isACLReady, isAuthRoute]);
|
|
92
|
+
|
|
93
|
+
const restoreLastActiveElement = useCallback(() => {
|
|
94
|
+
const lastActiveElement = lastActiveElementRef.current;
|
|
95
|
+
lastActiveElementRef.current = null;
|
|
96
|
+
if (lastActiveElement?.isConnected) {
|
|
97
|
+
lastActiveElement.focus({ preventScroll: true });
|
|
98
|
+
}
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
81
101
|
const openPalette = useCallback(() => {
|
|
82
102
|
setKeyword('');
|
|
103
|
+
|
|
104
|
+
if (open) {
|
|
105
|
+
inputRef.current?.focus();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
lastActiveElementRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
110
|
+
triggerRef.current?.focus();
|
|
83
111
|
setOpen(true);
|
|
84
|
-
}, []);
|
|
112
|
+
}, [open]);
|
|
85
113
|
|
|
86
114
|
const go = useCallback(
|
|
87
115
|
(item?: SettingsSearchItem) => {
|
|
@@ -104,29 +132,76 @@ export const SettingsSearch: React.FC = observer(() => {
|
|
|
104
132
|
);
|
|
105
133
|
|
|
106
134
|
useEffect(() => {
|
|
107
|
-
if (isAuthRoute) {
|
|
135
|
+
if (isAuthRoute || !isACLReady) {
|
|
108
136
|
return;
|
|
109
137
|
}
|
|
110
138
|
|
|
111
139
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
112
|
-
//
|
|
140
|
+
// 页面内组件可能自己就用 Cmd/Ctrl+F;它们 preventDefault 之后
|
|
113
141
|
// 这个 window 级监听器不该再抢一次。
|
|
114
142
|
if (event.defaultPrevented) {
|
|
115
143
|
return;
|
|
116
144
|
}
|
|
117
145
|
|
|
118
|
-
|
|
146
|
+
const hasPlatformModifier = isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey;
|
|
147
|
+
|
|
148
|
+
if (event.key?.toLowerCase() === 'f' && hasPlatformModifier && !event.altKey && !event.shiftKey) {
|
|
119
149
|
event.preventDefault();
|
|
120
|
-
|
|
150
|
+
if (!event.repeat) {
|
|
151
|
+
openPalette();
|
|
152
|
+
}
|
|
121
153
|
}
|
|
122
154
|
};
|
|
123
155
|
|
|
124
156
|
window.addEventListener('keydown', onKeyDown);
|
|
125
157
|
return () => window.removeEventListener('keydown', onKeyDown);
|
|
126
|
-
}, [isAuthRoute, openPalette]);
|
|
158
|
+
}, [isACLReady, isAuthRoute, openPalette]);
|
|
159
|
+
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
if (!open) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const onEscapeKeyDown = (event: KeyboardEvent) => {
|
|
166
|
+
if (event.key !== 'Escape' || event.target !== inputRef.current?.input) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
event.preventDefault();
|
|
171
|
+
event.stopPropagation();
|
|
172
|
+
setOpen(false);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const onPointerDown = () => {
|
|
176
|
+
isPointerDownRef.current = true;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const onPointerUp = () => {
|
|
180
|
+
isPointerDownRef.current = false;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
window.addEventListener('keydown', onEscapeKeyDown, true);
|
|
184
|
+
window.addEventListener('pointerdown', onPointerDown, true);
|
|
185
|
+
window.addEventListener('pointerup', onPointerUp, true);
|
|
186
|
+
window.addEventListener('pointercancel', onPointerUp, true);
|
|
187
|
+
return () => {
|
|
188
|
+
isPointerDownRef.current = false;
|
|
189
|
+
window.removeEventListener('keydown', onEscapeKeyDown, true);
|
|
190
|
+
window.removeEventListener('pointerdown', onPointerDown, true);
|
|
191
|
+
window.removeEventListener('pointerup', onPointerUp, true);
|
|
192
|
+
window.removeEventListener('pointercancel', onPointerUp, true);
|
|
193
|
+
};
|
|
194
|
+
}, [open]);
|
|
127
195
|
|
|
128
196
|
const onInputKeyDown = useCallback(
|
|
129
197
|
(event: React.KeyboardEvent) => {
|
|
198
|
+
if (event.key === 'Escape') {
|
|
199
|
+
event.preventDefault();
|
|
200
|
+
event.stopPropagation();
|
|
201
|
+
setOpen(false);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
130
205
|
if (event.key === 'ArrowDown') {
|
|
131
206
|
event.preventDefault();
|
|
132
207
|
setActiveIndex((index) => (results.length ? (index + 1) % results.length : 0));
|
|
@@ -147,13 +222,14 @@ export const SettingsSearch: React.FC = observer(() => {
|
|
|
147
222
|
[activeIndex, go, results],
|
|
148
223
|
);
|
|
149
224
|
|
|
150
|
-
if (isAuthRoute) {
|
|
225
|
+
if (isAuthRoute || !isACLReady) {
|
|
151
226
|
return null;
|
|
152
227
|
}
|
|
153
228
|
|
|
154
229
|
return (
|
|
155
230
|
<>
|
|
156
231
|
<div
|
|
232
|
+
ref={triggerRef}
|
|
157
233
|
className={triggerClassName}
|
|
158
234
|
role="button"
|
|
159
235
|
tabIndex={0}
|
|
@@ -161,6 +237,14 @@ export const SettingsSearch: React.FC = observer(() => {
|
|
|
161
237
|
style={{ color: headerColors.text }}
|
|
162
238
|
onClick={openPalette}
|
|
163
239
|
onKeyDown={(event) => {
|
|
240
|
+
if (event.key === 'Escape' && open) {
|
|
241
|
+
event.preventDefault();
|
|
242
|
+
event.stopPropagation();
|
|
243
|
+
setOpen(false);
|
|
244
|
+
restoreLastActiveElement();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
164
248
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
165
249
|
event.preventDefault();
|
|
166
250
|
openPalette();
|
|
@@ -182,9 +266,17 @@ export const SettingsSearch: React.FC = observer(() => {
|
|
|
182
266
|
footer={null}
|
|
183
267
|
closable={false}
|
|
184
268
|
destroyOnClose
|
|
269
|
+
focusTriggerAfterClose={false}
|
|
185
270
|
width={520}
|
|
186
271
|
styles={{ body: { paddingTop: 4 } }}
|
|
187
|
-
afterOpenChange={(opened) =>
|
|
272
|
+
afterOpenChange={(opened) => {
|
|
273
|
+
if (opened) {
|
|
274
|
+
inputRef.current?.focus();
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
restoreLastActiveElement();
|
|
279
|
+
}}
|
|
188
280
|
onCancel={() => setOpen(false)}
|
|
189
281
|
>
|
|
190
282
|
<Input
|
|
@@ -196,6 +288,19 @@ export const SettingsSearch: React.FC = observer(() => {
|
|
|
196
288
|
size="large"
|
|
197
289
|
value={keyword}
|
|
198
290
|
onChange={(event) => setKeyword(event.target.value)}
|
|
291
|
+
onBlur={(event) => {
|
|
292
|
+
// Browser keyboard handlers may consume Escape before the page sees keydown and only blur the input.
|
|
293
|
+
const nextActiveElement = event.relatedTarget;
|
|
294
|
+
const dialog = event.currentTarget.closest('[role="dialog"]');
|
|
295
|
+
if (
|
|
296
|
+
isPointerDownRef.current ||
|
|
297
|
+
(nextActiveElement instanceof Node && dialog?.contains(nextActiveElement))
|
|
298
|
+
) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
setOpen(false);
|
|
303
|
+
}}
|
|
199
304
|
onKeyDown={onInputKeyDown}
|
|
200
305
|
/>
|
|
201
306
|
<div style={{ borderTop: `${token.lineWidth}px solid ${token.colorSplit}`, margin: '8px -24px 0' }} />
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from '../flow/models/topbar/UserCenterTopbarActionModel';
|
|
19
19
|
import { useApp } from '../hooks/useApp';
|
|
20
20
|
import { useCurrentUserAuthStatus } from '../nocobase-buildin-plugin/currentUserAuthStatus';
|
|
21
|
+
import { addCustomAlgorithmToTheme } from '../theme/customAlgorithm';
|
|
21
22
|
import { SettingsBrand } from './SettingsBrand';
|
|
22
23
|
import { SettingsGroupNav } from './SettingsGroupNav';
|
|
23
24
|
import { SettingsSearch } from './SettingsSearch';
|
|
@@ -79,25 +80,41 @@ export const SettingsShell: FC = ({ children }) => {
|
|
|
79
80
|
const { token } = antdTheme.useToken();
|
|
80
81
|
// 设置中心的外观由主题编辑器里那条「简约」主题记录约束;读不到才用代码里的中性配色兜底。
|
|
81
82
|
const storedThemeConfig = useSettingsThemeConfig();
|
|
83
|
+
const normalizedStoredThemeConfig = useMemo(
|
|
84
|
+
() =>
|
|
85
|
+
storedThemeConfig
|
|
86
|
+
? addCustomAlgorithmToTheme({
|
|
87
|
+
...storedThemeConfig,
|
|
88
|
+
algorithm: Array.isArray(storedThemeConfig.algorithm)
|
|
89
|
+
? [...storedThemeConfig.algorithm]
|
|
90
|
+
: storedThemeConfig.algorithm,
|
|
91
|
+
})
|
|
92
|
+
: storedThemeConfig,
|
|
93
|
+
[storedThemeConfig],
|
|
94
|
+
);
|
|
82
95
|
// 顶栏和补丁样式要按**设置中心自己这套主题**算色,不能直接用上面那个 `token`——
|
|
83
96
|
// 这个组件在自己的 ConfigProvider 外面,`token` 是业务端主题的。用它的话,业务端一切暗黑,
|
|
84
97
|
// 顶栏就跟着变黑,而内容区还是简约的白,一半黑一半白。
|
|
85
98
|
const settingsToken = useMemo(
|
|
86
|
-
() => (
|
|
87
|
-
[
|
|
99
|
+
() => (normalizedStoredThemeConfig ? antdTheme.getDesignToken(normalizedStoredThemeConfig) : token),
|
|
100
|
+
[normalizedStoredThemeConfig, token],
|
|
88
101
|
);
|
|
89
102
|
const settingsShellTheme = useMemo<ThemeConfig>(
|
|
90
103
|
() =>
|
|
91
|
-
|
|
92
|
-
|
|
104
|
+
normalizedStoredThemeConfig
|
|
105
|
+
? withSettingsHeaderTheme(normalizedStoredThemeConfig, settingsToken)
|
|
106
|
+
: buildSettingsNeutralTheme(token),
|
|
107
|
+
[normalizedStoredThemeConfig, settingsToken, token],
|
|
93
108
|
);
|
|
94
109
|
const headerColors = useMemo(() => getSettingsHeaderColors(settingsToken), [settingsToken]);
|
|
95
110
|
const settingsGlobalCss = useMemo(() => buildSettingsGlobalCss(settingsToken), [settingsToken]);
|
|
96
111
|
const authStatus = useCurrentUserAuthStatus(app);
|
|
97
|
-
const
|
|
112
|
+
const routeMatches = app.router.matchRoutes(location.pathname) || [];
|
|
113
|
+
const isAuthenticationRoute = routeMatches.some((match) => {
|
|
98
114
|
const routeId = match.route.id;
|
|
99
115
|
return routeId === 'auth' || routeId?.startsWith('auth.') || routeId === '2fa' || routeId?.startsWith('2fa.');
|
|
100
116
|
});
|
|
117
|
+
const shouldHideHeader = routeMatches.some((match) => match.route.id === 'settingsDetails.idpOAuth.device');
|
|
101
118
|
|
|
102
119
|
if (isAuthenticationRoute) {
|
|
103
120
|
// 登录 / 二次验证页也要套设置中心自己的主题,否则它跟的是业务端主题:
|
|
@@ -114,7 +131,7 @@ export const SettingsShell: FC = ({ children }) => {
|
|
|
114
131
|
);
|
|
115
132
|
}
|
|
116
133
|
|
|
117
|
-
const shouldShowHeader = authStatus === 'authenticated';
|
|
134
|
+
const shouldShowHeader = authStatus === 'authenticated' && !shouldHideHeader;
|
|
118
135
|
const hasUserCenterModel = Boolean(app.flowEngine.getModelClass('UserCenterTopbarActionModel'));
|
|
119
136
|
const userCenter = hasUserCenterModel
|
|
120
137
|
? app.flowEngine.getModel<UserCenterTopbarActionModel>(`topbar-action-${USER_CENTER_ACTION_ID}`) ||
|
|
@@ -26,7 +26,6 @@ type SettingsRuntimeApp = Pick<BaseApplication<any>, 'name'> & {
|
|
|
26
26
|
const LEGACY_EMAIL_OAUTH_PATH = '/admin/settings/mail/oauth2';
|
|
27
27
|
|
|
28
28
|
const ROUTE_MAPPINGS = [
|
|
29
|
-
{ from: '/admin/ai/knowledge-base/detail', to: '/settings/ai/knowledge-base/detail' },
|
|
30
29
|
{ from: '/admin/workflow/executions', to: '/settings/workflow/executions' },
|
|
31
30
|
{ from: '/admin/workflow/workflows', to: '/settings/workflow/workflows' },
|
|
32
31
|
{ from: '/admin/settings', to: '/settings' },
|
|
@@ -236,6 +236,14 @@ export function withSettingsHeaderTheme(theme: ThemeConfig, token: GlobalToken):
|
|
|
236
236
|
};
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Height of one entry in the top bar dropdown.
|
|
241
|
+
*
|
|
242
|
+
* Taken from the user center dropdown (`nb-user-center-item`, 30px) so that the
|
|
243
|
+
* two dropdowns hanging off the same top bar line up.
|
|
244
|
+
*/
|
|
245
|
+
const SETTINGS_MENU_ITEM_HEIGHT = 30;
|
|
246
|
+
|
|
239
247
|
/**
|
|
240
248
|
* 设置中心的全局补丁样式。
|
|
241
249
|
*
|
|
@@ -251,20 +259,26 @@ export function withSettingsHeaderTheme(theme: ThemeConfig, token: GlobalToken):
|
|
|
251
259
|
*/
|
|
252
260
|
export function buildSettingsGlobalCss(token: GlobalToken): string {
|
|
253
261
|
return `
|
|
262
|
+
/* The rules below deliberately drop the .nb-settings-shell prefix: antd renders
|
|
263
|
+
drawers, modals and dropdowns into portals under body, where a shell-scoped
|
|
264
|
+
selector never matches — that is how the edit link inside the collection drawer
|
|
265
|
+
kept antd's default blue. This stylesheet is only injected by the standalone
|
|
266
|
+
settings SPA, so unscoped selectors cannot reach the business side. */
|
|
267
|
+
|
|
254
268
|
/* 最外层 body 的底色来自业务端主题(暗黑下是 #141414)。内容区一般把它整块盖住,
|
|
255
269
|
但内容不满屏、或者滚动橡皮筋回弹时会漏出来,所以这里一并接管。
|
|
256
270
|
设置中心是独立入口的单页应用,这条全局规则不会影响业务端。 */
|
|
257
271
|
body {
|
|
258
272
|
background: ${token.colorBgLayout};
|
|
259
273
|
}
|
|
260
|
-
|
|
274
|
+
a:not(.ant-btn):not(.ant-menu-item):not(.ant-tabs-tab-btn):not([class*="ant-"]) {
|
|
261
275
|
color: ${token.colorTextSecondary};
|
|
262
276
|
}
|
|
263
|
-
|
|
277
|
+
a:not(.ant-btn):not(.ant-menu-item):not(.ant-tabs-tab-btn):not([class*="ant-"]):hover {
|
|
264
278
|
color: ${token.colorText};
|
|
265
279
|
}
|
|
266
|
-
|
|
267
|
-
.
|
|
280
|
+
a:not(.ant-btn):not(.ant-menu-item):not(.ant-tabs-tab-btn),
|
|
281
|
+
.ant-btn-link:not(:disabled) {
|
|
268
282
|
text-decoration: underline;
|
|
269
283
|
text-underline-offset: 2px;
|
|
270
284
|
}
|
|
@@ -284,18 +298,72 @@ body {
|
|
|
284
298
|
.nb-settings-shell .ant-layout-sider .ant-menu-submenu-title::after {
|
|
285
299
|
display: none !important;
|
|
286
300
|
}
|
|
287
|
-
/*
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
301
|
+
/* Entry height matches the other dropdown on the same top bar (the user center,
|
|
302
|
+
whose nb-user-center-item is 30px). antd sizes menu entries with
|
|
303
|
+
controlHeightLG (35px under compact), which is the height of a single clickable
|
|
304
|
+
control; this dropdown is a list of a dozen links and reads loose at 35.
|
|
305
|
+
|
|
306
|
+
Scoped to entries inside the popup only: the horizontal top bar menu shares the
|
|
307
|
+
same Menu tokens, so changing tokens would eat its horizontal padding too. The
|
|
308
|
+
selector needs three classes; two do not outweigh antd's own rule. */
|
|
309
|
+
.ant-menu-submenu-popup .ant-menu.ant-menu-sub.ant-menu-vertical {
|
|
310
|
+
/* With the 4px margin between entries gone, the first and last would sit flush
|
|
311
|
+
against the popup edge, so the container gives the padding back. */
|
|
312
|
+
padding-block: ${token.paddingXXS}px;
|
|
293
313
|
}
|
|
294
|
-
.
|
|
295
|
-
|
|
314
|
+
.ant-menu-submenu-popup .ant-menu-sub > .ant-menu-item,
|
|
315
|
+
.ant-menu-submenu-popup .ant-menu-sub > .ant-menu-submenu > .ant-menu-submenu-title {
|
|
316
|
+
height: ${SETTINGS_MENU_ITEM_HEIGHT}px;
|
|
317
|
+
line-height: ${SETTINGS_MENU_ITEM_HEIGHT}px;
|
|
318
|
+
margin-block: 0;
|
|
296
319
|
}
|
|
297
|
-
|
|
298
|
-
|
|
320
|
+
|
|
321
|
+
/* Info alerts: with the primary color collapsed to black and white, their derived
|
|
322
|
+
background lands on rgba(0,0,0,0.02) — faint enough to read as empty space.
|
|
323
|
+
Rather than reaching for color (which would drag the palette back in), the shape
|
|
324
|
+
carries the weight: a 4px bar on the left, a firmer background and border, a
|
|
325
|
+
bold title and a larger icon. Warning / error / success keep their semantic
|
|
326
|
+
colors and are left alone. */
|
|
327
|
+
.ant-alert-info {
|
|
328
|
+
background: ${token.colorFillSecondary};
|
|
329
|
+
border: ${token.lineWidth}px solid ${token.colorBorder};
|
|
330
|
+
border-inline-start: 4px solid ${token.colorText};
|
|
331
|
+
padding-block: ${token.paddingSM}px;
|
|
332
|
+
}
|
|
333
|
+
.ant-alert-info .ant-alert-icon,
|
|
334
|
+
.ant-alert-info .ant-alert-message {
|
|
335
|
+
color: ${token.colorText};
|
|
336
|
+
}
|
|
337
|
+
.ant-alert-info .ant-alert-icon {
|
|
338
|
+
font-size: ${token.fontSizeLG}px;
|
|
339
|
+
}
|
|
340
|
+
.ant-alert-info .ant-alert-message {
|
|
341
|
+
font-weight: 600;
|
|
342
|
+
}
|
|
343
|
+
.ant-alert-info .ant-alert-description {
|
|
344
|
+
color: ${token.colorTextSecondary};
|
|
345
|
+
}
|
|
346
|
+
/* Three switch shades: off is light grey (colorFill, 0.15), disabled is dark grey
|
|
347
|
+
(colorTextTertiary, 0.45), on is black. Disabled being darker than off is
|
|
348
|
+
deliberate — here "darker" reads as "pinned", not "stronger" — and one light
|
|
349
|
+
against one dark is told apart at a glance, without comparing subtle
|
|
350
|
+
brightness steps. Disabled is not dimmed as a whole: the plugin manager shows
|
|
351
|
+
over a hundred built-in plugins in that state, and dimming makes the page look
|
|
352
|
+
broken. */
|
|
353
|
+
.ant-switch {
|
|
354
|
+
background: ${token.colorFill};
|
|
355
|
+
}
|
|
356
|
+
.ant-switch.ant-switch-checked {
|
|
357
|
+
background: ${token.colorText};
|
|
358
|
+
}
|
|
359
|
+
/* This selector has to outweigh antd's own .ant-switch.ant-switch-disabled (a
|
|
360
|
+
single class no longer does, now that the shell prefix is gone), otherwise its
|
|
361
|
+
opacity: 0.65 survives and washes the dark grey out onto the off state, where
|
|
362
|
+
the two read as the same color. */
|
|
363
|
+
.ant-switch.ant-switch-disabled,
|
|
364
|
+
.ant-switch.ant-switch-disabled.ant-switch-checked {
|
|
365
|
+
background: ${token.colorTextTertiary};
|
|
366
|
+
opacity: 1;
|
|
299
367
|
}
|
|
300
368
|
`;
|
|
301
369
|
}
|