@nocobase/client-v2 2.3.0-beta.6 → 2.4.0-alpha.2

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 (90) hide show
  1. package/es/acl/ACLProvider.d.ts +10 -0
  2. package/es/acl/aclCheckReadiness.d.ts +11 -0
  3. package/es/authRedirect.d.ts +11 -2
  4. package/es/components/AttachmentUpload.d.ts +51 -0
  5. package/es/components/index.d.ts +1 -0
  6. package/es/flow/admin-shell/admin-layout/NocoBaseLogo.d.ts +10 -0
  7. package/es/index.d.ts +4 -1
  8. package/es/index.mjs +129 -118
  9. package/es/nocobase-buildin-plugin/currentUserAuthStatus.d.ts +11 -0
  10. package/es/nocobase-buildin-plugin/index.d.ts +10 -0
  11. package/es/settings-app/SettingsApplication.d.ts +18 -0
  12. package/es/settings-app/SettingsBrand.d.ts +16 -0
  13. package/es/settings-app/SettingsBuildInPlugin.d.ts +9 -0
  14. package/es/settings-app/SettingsDocumentRedirect.d.ts +10 -0
  15. package/es/settings-app/SettingsGroupNav.d.ts +18 -0
  16. package/es/settings-app/SettingsPluginSettingsManager.d.ts +14 -0
  17. package/es/settings-app/SettingsRouterManager.d.ts +15 -0
  18. package/es/settings-app/SettingsSearch.d.ts +17 -0
  19. package/es/settings-app/SettingsShell.d.ts +10 -0
  20. package/es/settings-app/isSettingsApp.d.ts +18 -0
  21. package/es/settings-app/runtimePaths.d.ts +18 -0
  22. package/es/settings-app/settingsDocumentPath.d.ts +14 -0
  23. package/es/settings-app/settingsTheme.d.ts +87 -0
  24. package/es/settings-app/useSettingsThemeConfig.d.ts +22 -0
  25. package/es/settings-center/AdminSettingsLayout.d.ts +2 -2
  26. package/es/settings-center/groups.d.ts +66 -0
  27. package/es/settings-center/useSettingsGroups.d.ts +36 -0
  28. package/es/settings-center/useSettingsSearch.d.ts +35 -0
  29. package/es/settings-center/utils.d.ts +24 -0
  30. package/es/theme/index.d.ts +1 -0
  31. package/es/theme/itemActive.d.ts +24 -0
  32. package/es/theme/type.d.ts +9 -0
  33. package/lib/index.js +157 -146
  34. package/package.json +7 -7
  35. package/src/__tests__/app.test.tsx +122 -3
  36. package/src/__tests__/authRedirect.test.ts +154 -0
  37. package/src/__tests__/browserChecker.test.ts +172 -6
  38. package/src/__tests__/mockSettingsApplication.ts +29 -0
  39. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +81 -8
  40. package/src/__tests__/plugin-manager.test.tsx +7 -6
  41. package/src/__tests__/settings-application.test.ts +164 -0
  42. package/src/__tests__/settings-auth-routes.test.ts +71 -0
  43. package/src/__tests__/settings-center.test.tsx +168 -130
  44. package/src/__tests__/settings-layout-root.test.tsx +600 -0
  45. package/src/__tests__/settings-runtime-paths.test.ts +99 -0
  46. package/src/__tests__/settings-search-shortcut.test.tsx +321 -0
  47. package/src/__tests__/settings-shell.test.tsx +261 -0
  48. package/src/acl/ACLProvider.tsx +53 -3
  49. package/src/acl/aclCheckReadiness.ts +44 -0
  50. package/src/authRedirect.ts +85 -8
  51. package/src/components/AppComponents.tsx +16 -7
  52. package/src/components/AttachmentUpload.tsx +275 -0
  53. package/src/components/index.ts +1 -0
  54. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -56
  55. package/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx +77 -0
  56. package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +37 -14
  57. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +77 -13
  58. package/src/flow/internal/components/Markdown/DisplayMarkdown.tsx +14 -9
  59. package/src/flow/internal/components/Markdown/__tests__/DisplayMarkdown.test.tsx +46 -0
  60. package/src/flow/models/fields/DisplayHtmlFieldModel.tsx +2 -1
  61. package/src/flow/models/fields/RichTextFieldModel/__tests__/RichTextFieldModel.test.tsx +74 -0
  62. package/src/flow/models/fields/RichTextFieldModel/index.tsx +27 -6
  63. package/src/flow/models/topbar/TopbarActionModel.tsx +52 -180
  64. package/src/index.ts +11 -1
  65. package/src/nocobase-buildin-plugin/currentUserAuthStatus.ts +58 -0
  66. package/src/nocobase-buildin-plugin/index.tsx +112 -71
  67. package/src/settings-app/SettingsApplication.ts +31 -0
  68. package/src/settings-app/SettingsBrand.tsx +108 -0
  69. package/src/settings-app/SettingsBuildInPlugin.ts +10 -0
  70. package/src/settings-app/SettingsDocumentRedirect.tsx +26 -0
  71. package/src/settings-app/SettingsGroupNav.tsx +124 -0
  72. package/src/settings-app/SettingsPluginSettingsManager.ts +38 -0
  73. package/src/settings-app/SettingsRouterManager.ts +92 -0
  74. package/src/settings-app/SettingsSearch.tsx +343 -0
  75. package/src/settings-app/SettingsShell.tsx +184 -0
  76. package/src/settings-app/isSettingsApp.ts +21 -0
  77. package/src/settings-app/runtimePaths.ts +103 -0
  78. package/src/settings-app/settingsDocumentPath.ts +66 -0
  79. package/src/settings-app/settingsTheme.ts +369 -0
  80. package/src/settings-app/useSettingsThemeConfig.ts +100 -0
  81. package/src/settings-center/AdminSettingsLayout.tsx +154 -169
  82. package/src/settings-center/SystemSettingsPage.tsx +4 -170
  83. package/src/settings-center/__tests__/groups.test.ts +25 -0
  84. package/src/settings-center/groups.ts +108 -0
  85. package/src/settings-center/useSettingsGroups.ts +105 -0
  86. package/src/settings-center/useSettingsSearch.ts +173 -0
  87. package/src/settings-center/utils.tsx +50 -1
  88. package/src/theme/index.tsx +1 -0
  89. package/src/theme/itemActive.ts +31 -0
  90. package/src/theme/type.ts +10 -0
@@ -0,0 +1,99 @@
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 { afterEach, describe, expect, it } from 'vitest';
11
+ import { resolveStandaloneSettingsPath } from '../settings-app/runtimePaths';
12
+
13
+ const originalModernPrefix = window.__nocobase_modern_client_prefix__;
14
+
15
+ const createApp = (publicPath: string, basename: string, name = 'main') =>
16
+ ({
17
+ name,
18
+ getPublicPath: () => publicPath,
19
+ router: {
20
+ getBasename: () => basename,
21
+ },
22
+ }) as any;
23
+
24
+ describe('standalone settings runtime paths', () => {
25
+ afterEach(() => {
26
+ window.__nocobase_modern_client_prefix__ = originalModernPrefix;
27
+ });
28
+
29
+ it('maps legacy v2 settings paths to the root-public standalone SPA', () => {
30
+ const app = createApp('/nocobase/v/', '/nocobase/v');
31
+
32
+ expect(resolveStandaloneSettingsPath(app, '/nocobase/v/admin/settings/system-settings?tab=mail#smtp')).toBe(
33
+ '/nocobase/settings/system-settings?tab=mail#smtp',
34
+ );
35
+ expect(resolveStandaloneSettingsPath(app, '/nocobase/v/admin/workflow/workflows/42?tab=nodes#canvas')).toBe(
36
+ '/nocobase/settings/workflow/workflows/42?tab=nodes#canvas',
37
+ );
38
+ expect(resolveStandaloneSettingsPath(app, '/nocobase/v/admin/workflow/executions/88')).toBe(
39
+ '/nocobase/settings/workflow/executions/88',
40
+ );
41
+ expect(resolveStandaloneSettingsPath(app, '/nocobase/v/admin/settings/ai/knowledge-base/detail/k1/documents')).toBe(
42
+ '/nocobase/settings/ai/knowledge-base/detail/k1/documents',
43
+ );
44
+ });
45
+
46
+ it('preserves both supported sub-application scopes', () => {
47
+ const appsApp = createApp('/nocobase/v/', '/nocobase/v/apps/demo');
48
+ const newApp = createApp('/nocobase/v/', '/nocobase/v/_app/demo');
49
+
50
+ expect(resolveStandaloneSettingsPath(appsApp, '/nocobase/v/apps/demo/admin/settings/routes')).toBe(
51
+ '/nocobase/settings/apps/demo/routes',
52
+ );
53
+ expect(resolveStandaloneSettingsPath(newApp, '/nocobase/v/_app/demo/admin/settings/routes')).toBe(
54
+ '/nocobase/settings/_app/demo/routes',
55
+ );
56
+ });
57
+
58
+ it('supports a custom modern prefix without leaking it into settings URLs', () => {
59
+ window.__nocobase_modern_client_prefix__ = 'modern';
60
+ const app = createApp('/base/modern/', '/base/modern');
61
+
62
+ expect(resolveStandaloneSettingsPath(app, '/base/modern/admin/settings/security')).toBe('/base/settings/security');
63
+ });
64
+
65
+ it('does not treat an apps segment inside the main application public path as a sub-application scope', () => {
66
+ window.__nocobase_modern_client_prefix__ = 'modern';
67
+ const app = createApp('/tenant/apps/root/modern/', '/tenant/apps/root/modern');
68
+
69
+ expect(resolveStandaloneSettingsPath(app, '/tenant/apps/root/modern/admin/settings/security')).toBe(
70
+ '/tenant/apps/root/settings/security',
71
+ );
72
+ });
73
+
74
+ it.each(['apps', '_app'])(
75
+ 'does not remove a matching %s segment from the root public path for a real sub-application',
76
+ (scope) => {
77
+ window.__nocobase_modern_client_prefix__ = 'modern';
78
+ const app = createApp(`/tenant/${scope}/demo/modern/`, `/tenant/${scope}/demo/modern/${scope}/demo`, 'demo');
79
+
80
+ expect(
81
+ resolveStandaloneSettingsPath(app, `/tenant/${scope}/demo/modern/${scope}/demo/admin/settings/security`),
82
+ ).toBe(`/tenant/${scope}/demo/settings/${scope}/demo/security`);
83
+ expect(
84
+ resolveStandaloneSettingsPath(
85
+ app,
86
+ `/tenant/${scope}/demo/modern/${scope}/demo/admin/settings/mail/oauth2?code=abc#done`,
87
+ ),
88
+ ).toBe(`/tenant/${scope}/demo/${scope}/demo/admin/settings/mail/oauth2?code=abc#done`);
89
+ },
90
+ );
91
+
92
+ it('returns the legacy V1 callback for Email OAuth', () => {
93
+ const app = createApp('/nocobase/v/', '/nocobase/v/apps/demo');
94
+
95
+ expect(resolveStandaloneSettingsPath(app, '/nocobase/v/apps/demo/admin/settings/mail/oauth2?code=abc#done')).toBe(
96
+ '/nocobase/apps/demo/admin/settings/mail/oauth2?code=abc#done',
97
+ );
98
+ });
99
+ });
@@ -0,0 +1,321 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
11
+ import React from 'react';
12
+ import * as ReactRouterDOM from 'react-router-dom';
13
+ import { afterEach, describe, expect, it, vi } from 'vitest';
14
+
15
+ vi.mock('../acl/aclCheckReadiness', () => ({
16
+ useACLCheckReady: () => true,
17
+ }));
18
+
19
+ vi.mock('../hooks/useApp', () => ({
20
+ useApp: () => ({
21
+ router: {
22
+ isSkippedAuthCheckRoute: () => false,
23
+ },
24
+ }),
25
+ }));
26
+
27
+ vi.mock('../settings-center/useSettingsSearch', () => ({
28
+ useSettingsSearch: () => ({
29
+ recentItems: [],
30
+ search: vi.fn(() => []),
31
+ }),
32
+ }));
33
+
34
+ vi.mock('react-i18next', async (importOriginal) => {
35
+ const actual = await importOriginal<typeof import('react-i18next')>();
36
+ return {
37
+ ...actual,
38
+ useTranslation: () => ({ t: (key: string) => key }),
39
+ };
40
+ });
41
+
42
+ const platforms: Array<{
43
+ acceptedModifier: KeyboardEventInit;
44
+ label: string;
45
+ name: string;
46
+ platform: string;
47
+ rejectedModifier: KeyboardEventInit;
48
+ }> = [
49
+ {
50
+ acceptedModifier: { metaKey: true },
51
+ label: '⌘F',
52
+ name: 'macOS',
53
+ platform: 'MacIntel',
54
+ rejectedModifier: { ctrlKey: true },
55
+ },
56
+ {
57
+ acceptedModifier: { ctrlKey: true },
58
+ label: 'Ctrl F',
59
+ name: 'Windows',
60
+ platform: 'Win32',
61
+ rejectedModifier: { metaKey: true },
62
+ },
63
+ ];
64
+
65
+ async function renderSettingsSearch(platform: string) {
66
+ Object.defineProperty(window.navigator, 'platform', {
67
+ configurable: true,
68
+ value: platform,
69
+ });
70
+ vi.resetModules();
71
+ vi.doMock('react', () => ({ ...React, default: React }));
72
+ vi.doMock('react-router-dom', () => ReactRouterDOM);
73
+
74
+ const { SettingsSearch } = await import('../settings-app/SettingsSearch');
75
+ return render(
76
+ <ReactRouterDOM.MemoryRouter initialEntries={['/settings/system-settings']}>
77
+ <button type="button">Outside control</button>
78
+ <SettingsSearch />
79
+ </ReactRouterDOM.MemoryRouter>,
80
+ );
81
+ }
82
+
83
+ function dispatchShortcut(
84
+ init: KeyboardEventInit,
85
+ options: {
86
+ alreadyPrevented?: boolean;
87
+ } = {},
88
+ ) {
89
+ const event = new KeyboardEvent('keydown', {
90
+ bubbles: true,
91
+ cancelable: true,
92
+ ...init,
93
+ });
94
+ if (options.alreadyPrevented) {
95
+ event.preventDefault();
96
+ }
97
+ fireEvent(window, event);
98
+ return event;
99
+ }
100
+
101
+ describe('SettingsSearch shortcut', () => {
102
+ afterEach(() => {
103
+ cleanup();
104
+ vi.doUnmock('react');
105
+ vi.doUnmock('react-router-dom');
106
+ Reflect.deleteProperty(window.navigator, 'platform');
107
+ });
108
+
109
+ it.each(platforms)('displays $label on $name', async ({ label, platform }) => {
110
+ await renderSettingsSearch(platform);
111
+
112
+ expect(screen.getByTitle('Search settings')).toHaveTextContent(label);
113
+ });
114
+
115
+ it.each(platforms)(
116
+ 'opens only for the exact platform shortcut on $name',
117
+ async ({ acceptedModifier, platform, rejectedModifier }) => {
118
+ await renderSettingsSearch(platform);
119
+
120
+ const rejectedShortcuts: Array<{
121
+ name: string;
122
+ init: KeyboardEventInit;
123
+ alreadyPrevented?: boolean;
124
+ }> = [
125
+ { name: 'F without a modifier', init: { key: 'f' } },
126
+ { name: 'the other platform modifier + F', init: { key: 'f', ...rejectedModifier } },
127
+ { name: 'Ctrl+K', init: { key: 'k', ctrlKey: true } },
128
+ { name: 'Meta+K', init: { key: 'k', metaKey: true } },
129
+ { name: 'Alt with the platform shortcut', init: { key: 'f', ...acceptedModifier, altKey: true } },
130
+ { name: 'Shift with the platform shortcut', init: { key: 'f', ...acceptedModifier, shiftKey: true } },
131
+ { name: 'Ctrl+Meta+F', init: { key: 'f', ctrlKey: true, metaKey: true } },
132
+ {
133
+ name: 'an already handled platform shortcut',
134
+ init: { key: 'f', ...acceptedModifier },
135
+ alreadyPrevented: true,
136
+ },
137
+ ];
138
+
139
+ for (const shortcut of rejectedShortcuts) {
140
+ const event = dispatchShortcut(shortcut.init, { alreadyPrevented: shortcut.alreadyPrevented });
141
+ expect(screen.queryByRole('dialog'), shortcut.name).not.toBeInTheDocument();
142
+ expect(event.defaultPrevented, shortcut.name).toBe(shortcut.alreadyPrevented === true);
143
+ }
144
+
145
+ const repeatEvent = dispatchShortcut({ key: 'f', ...acceptedModifier, repeat: true });
146
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
147
+ expect(repeatEvent.defaultPrevented).toBe(true);
148
+
149
+ const event = dispatchShortcut({ key: 'f', ...acceptedModifier });
150
+
151
+ expect(event.defaultPrevented).toBe(true);
152
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
153
+ },
154
+ );
155
+
156
+ it.each([
157
+ { activation: 'click', key: null },
158
+ { activation: 'Enter', key: 'Enter' },
159
+ { activation: 'Space', key: ' ' },
160
+ ])('closes immediately with Escape after opening by $activation', async ({ key }) => {
161
+ await renderSettingsSearch('MacIntel');
162
+
163
+ const outsideControl = screen.getByRole('button', { name: 'Outside control' });
164
+ const trigger = screen.getByTitle('Search settings');
165
+ const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
166
+ if (key) {
167
+ trigger.focus();
168
+ fireEvent.keyDown(trigger, { key });
169
+ expect(trigger).toHaveFocus();
170
+ fireEvent(document.activeElement as Element, event);
171
+ } else {
172
+ outsideControl.focus();
173
+ trigger.focus();
174
+ fireEvent.click(trigger);
175
+ expect(trigger).toHaveFocus();
176
+ fireEvent(document.activeElement as Element, event);
177
+ }
178
+
179
+ expect(event.defaultPrevented).toBe(true);
180
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
181
+ expect(trigger).toHaveFocus();
182
+
183
+ const closedEvent = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
184
+ fireEvent(trigger, closedEvent);
185
+ expect(closedEvent.defaultPrevented).toBe(false);
186
+ });
187
+
188
+ it('moves focus to the trigger before opening from the platform shortcut', async () => {
189
+ await renderSettingsSearch('MacIntel');
190
+
191
+ const outsideControl = screen.getByRole('button', { name: 'Outside control' });
192
+ const trigger = screen.getByTitle('Search settings');
193
+ outsideControl.focus();
194
+
195
+ const shortcutEvent = new KeyboardEvent('keydown', {
196
+ key: 'f',
197
+ metaKey: true,
198
+ bubbles: true,
199
+ cancelable: true,
200
+ });
201
+ fireEvent(outsideControl, shortcutEvent);
202
+
203
+ expect(shortcutEvent.defaultPrevented).toBe(true);
204
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
205
+ expect(trigger).toHaveFocus();
206
+
207
+ const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
208
+ fireEvent(document.activeElement as Element, escapeEvent);
209
+ expect(escapeEvent.defaultPrevented).toBe(true);
210
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
211
+ expect(outsideControl).toHaveFocus();
212
+ });
213
+
214
+ it('keeps immediate Escape from reaching a lower overlay', async () => {
215
+ await renderSettingsSearch('MacIntel');
216
+
217
+ const trigger = screen.getByTitle('Search settings');
218
+ fireEvent.click(trigger);
219
+ const lowerOverlayKeyDown = vi.fn();
220
+ window.addEventListener('keydown', lowerOverlayKeyDown);
221
+
222
+ const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
223
+ fireEvent(trigger, event);
224
+ window.removeEventListener('keydown', lowerOverlayKeyDown);
225
+
226
+ expect(lowerOverlayKeyDown).not.toHaveBeenCalled();
227
+ });
228
+
229
+ it('closes from the dialog Escape path and restores the original focus', async () => {
230
+ await renderSettingsSearch('MacIntel');
231
+
232
+ const outsideControl = screen.getByRole('button', { name: 'Outside control' });
233
+ outsideControl.focus();
234
+ dispatchShortcut({ key: 'f', metaKey: true });
235
+ const input = screen.getByPlaceholderText('Search settings');
236
+ act(() => input.focus());
237
+ expect(input).toHaveFocus();
238
+
239
+ const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
240
+ fireEvent(input, escapeEvent);
241
+
242
+ expect(escapeEvent.defaultPrevented).toBe(true);
243
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
244
+ expect(outsideControl).toHaveFocus();
245
+ });
246
+
247
+ it('closes before a downstream capture handler consumes Escape', async () => {
248
+ await renderSettingsSearch('MacIntel');
249
+
250
+ dispatchShortcut({ key: 'f', metaKey: true });
251
+ const input = screen.getByPlaceholderText('Search settings');
252
+ act(() => input.focus());
253
+ const downstreamCapture = vi.fn((event: KeyboardEvent) => {
254
+ input.blur();
255
+ event.stopPropagation();
256
+ });
257
+ document.addEventListener('keydown', downstreamCapture, true);
258
+
259
+ const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
260
+ try {
261
+ fireEvent(input, escapeEvent);
262
+ } finally {
263
+ document.removeEventListener('keydown', downstreamCapture, true);
264
+ }
265
+
266
+ expect(escapeEvent.defaultPrevented).toBe(true);
267
+ expect(downstreamCapture).not.toHaveBeenCalled();
268
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
269
+ });
270
+
271
+ it('closes when the browser consumes Escape by blurring the input', async () => {
272
+ await renderSettingsSearch('MacIntel');
273
+
274
+ const outsideControl = screen.getByRole('button', { name: 'Outside control' });
275
+ outsideControl.focus();
276
+ dispatchShortcut({ key: 'f', metaKey: true });
277
+ const input = screen.getByPlaceholderText('Search settings');
278
+ act(() => input.focus());
279
+
280
+ act(() => input.blur());
281
+
282
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
283
+ expect(outsideControl).toHaveFocus();
284
+ });
285
+
286
+ it('keeps the dialog open when a pointer action blurs the input', async () => {
287
+ await renderSettingsSearch('MacIntel');
288
+
289
+ dispatchShortcut({ key: 'f', metaKey: true });
290
+ const input = screen.getByPlaceholderText('Search settings');
291
+ act(() => input.focus());
292
+
293
+ fireEvent.pointerDown(input);
294
+ act(() => input.blur());
295
+ fireEvent.pointerUp(input);
296
+
297
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
298
+ });
299
+
300
+ it('keeps focus inside the dialog when the platform shortcut is pressed again', async () => {
301
+ await renderSettingsSearch('MacIntel');
302
+
303
+ dispatchShortcut({ key: 'f', metaKey: true });
304
+ const input = screen.getByPlaceholderText('Search settings');
305
+ act(() => input.focus());
306
+ expect(input).toHaveFocus();
307
+ fireEvent.change(input, { target: { value: 'portal' } });
308
+
309
+ const shortcutEvent = new KeyboardEvent('keydown', {
310
+ key: 'f',
311
+ metaKey: true,
312
+ bubbles: true,
313
+ cancelable: true,
314
+ });
315
+ fireEvent(input, shortcutEvent);
316
+
317
+ expect(shortcutEvent.defaultPrevented).toBe(true);
318
+ expect(input).toHaveValue('');
319
+ expect(input).toHaveFocus();
320
+ });
321
+ });
@@ -0,0 +1,261 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { render, screen } from '@testing-library/react';
11
+ import { ConfigProvider, theme as antdTheme } from 'antd';
12
+ import React from 'react';
13
+ import { MemoryRouter } from 'react-router-dom';
14
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
15
+ import { setCurrentUserAuthStatus } from '../nocobase-buildin-plugin/currentUserAuthStatus';
16
+ import { SettingsShell } from '../settings-app/SettingsShell';
17
+ import type { ThemeConfig } from '../theme';
18
+
19
+ const userCenterModel = { uid: 'settings-user-center' };
20
+ const createModel = vi.fn(() => userCenterModel);
21
+ const matchRoutes = vi.fn(() => [{ route: { id: 'settings' } }]);
22
+ let settingsThemeConfig: ThemeConfig | null = null;
23
+ const mockApp = {
24
+ flowEngine: {
25
+ createModel,
26
+ getModel: vi.fn(() => null),
27
+ getModelClass: vi.fn(() => true),
28
+ },
29
+ router: {
30
+ matchRoutes,
31
+ },
32
+ };
33
+
34
+ vi.mock('../hooks/useApp', () => ({
35
+ useApp: () => mockApp,
36
+ }));
37
+
38
+ vi.mock('../settings-app/SettingsBrand', () => ({
39
+ SettingsBrand: () => <div data-testid="settings-logo">logo</div>,
40
+ }));
41
+
42
+ vi.mock('../settings-app/SettingsGroupNav', () => ({
43
+ SettingsGroupNav: () => <div data-testid="settings-group-nav">groups</div>,
44
+ }));
45
+
46
+ vi.mock('../settings-app/SettingsSearch', () => ({
47
+ SettingsSearch: () => <div data-testid="settings-search">search</div>,
48
+ }));
49
+
50
+ vi.mock('../settings-app/useSettingsThemeConfig', () => ({
51
+ useSettingsThemeConfig: () => settingsThemeConfig,
52
+ }));
53
+
54
+ vi.mock('../flow/admin-shell/admin-layout/HelpLite', () => ({
55
+ HelpLite: () => <div data-testid="settings-help">help</div>,
56
+ }));
57
+
58
+ vi.mock('@nocobase/flow-engine', async (importOriginal) => {
59
+ const actual = await importOriginal<typeof import('@nocobase/flow-engine')>();
60
+ return {
61
+ ...actual,
62
+ FlowModelRenderer: ({ model }: { model: { uid: string } }) => (
63
+ <div data-testid="settings-user-center">{model.uid}</div>
64
+ ),
65
+ };
66
+ });
67
+
68
+ const TokenProbe = () => {
69
+ const { token } = antdTheme.useToken();
70
+
71
+ return <div data-testid="settings-theme-token">{`${token.marginBlock}:${token.sizeLG}`}</div>;
72
+ };
73
+
74
+ describe('SettingsShell', () => {
75
+ beforeEach(() => {
76
+ settingsThemeConfig = null;
77
+ createModel.mockClear();
78
+ matchRoutes.mockReset();
79
+ matchRoutes.mockReturnValue([{ route: { id: 'settings' } }]);
80
+ setCurrentUserAuthStatus(mockApp, 'authenticated');
81
+ });
82
+
83
+ it('renders only the settings brand, group nav, help and user center around its content', () => {
84
+ render(
85
+ <MemoryRouter initialEntries={['/settings/system-settings']}>
86
+ <SettingsShell>
87
+ <div>settings content</div>
88
+ </SettingsShell>
89
+ </MemoryRouter>,
90
+ );
91
+
92
+ expect(screen.getByTestId('settings-logo')).toBeInTheDocument();
93
+ expect(screen.getByTestId('settings-group-nav')).toBeInTheDocument();
94
+ expect(screen.getByTestId('settings-search')).toBeInTheDocument();
95
+ expect(screen.getByTestId('settings-help')).toBeInTheDocument();
96
+ expect(screen.getByTestId('settings-user-center')).toHaveTextContent('settings-user-center');
97
+ expect(screen.getByText('settings content')).toBeInTheDocument();
98
+ expect(screen.queryByTestId('plugin-settings-button')).not.toBeInTheDocument();
99
+ expect(screen.queryByTestId('notifications-button')).not.toBeInTheDocument();
100
+ });
101
+
102
+ it('keeps its neutral header instead of following the business-side header color', () => {
103
+ render(
104
+ <ConfigProvider
105
+ theme={
106
+ {
107
+ token: {
108
+ colorBgHeader: '#001529',
109
+ colorPrimary: '#1777FF',
110
+ },
111
+ } as ThemeConfig
112
+ }
113
+ >
114
+ <MemoryRouter initialEntries={['/settings/system-settings']}>
115
+ <SettingsShell>
116
+ <div>settings content</div>
117
+ </SettingsShell>
118
+ </MemoryRouter>
119
+ </ConfigProvider>,
120
+ );
121
+
122
+ // 主题编辑器里的深色顶栏只作用于业务端;设置中心固定用容器底色。
123
+ expect(screen.getByRole('banner')).toHaveStyle({ background: '#ffffff' });
124
+ });
125
+
126
+ it('preserves NocoBase custom tokens in the stored compact theme', () => {
127
+ settingsThemeConfig = { algorithm: antdTheme.compactAlgorithm };
128
+
129
+ render(
130
+ <MemoryRouter initialEntries={['/settings/system-settings']}>
131
+ <SettingsShell>
132
+ <TokenProbe />
133
+ </SettingsShell>
134
+ </MemoryRouter>,
135
+ );
136
+
137
+ expect(screen.getByTestId('settings-theme-token')).toHaveTextContent('16:16');
138
+ });
139
+
140
+ it('places the settings content and embed container side by side below the header', () => {
141
+ const { container } = render(
142
+ <MemoryRouter initialEntries={['/settings/theme-editor']}>
143
+ <SettingsShell>
144
+ <div>settings content</div>
145
+ </SettingsShell>
146
+ </MemoryRouter>,
147
+ );
148
+
149
+ const header = screen.getByRole('banner');
150
+ const content = screen.getByRole('main');
151
+ const embedContainer = container.querySelector<HTMLElement>('#nocobase-embed-container');
152
+ const workspace = content.parentElement;
153
+
154
+ expect(embedContainer).not.toBeNull();
155
+ if (!embedContainer) {
156
+ throw new Error('Expected the Settings shell to render the global embed container');
157
+ }
158
+ expect(header.nextElementSibling).toBe(workspace);
159
+ expect(workspace).toHaveStyle({
160
+ display: 'flex',
161
+ flex: '1',
162
+ minWidth: '0',
163
+ minHeight: '0',
164
+ overflow: 'hidden',
165
+ });
166
+ expect(workspace?.children).toHaveLength(2);
167
+ expect(workspace?.firstElementChild).toBe(content);
168
+ expect(workspace?.lastElementChild).toBe(embedContainer);
169
+ expect(content).toHaveStyle({
170
+ flex: '1',
171
+ minWidth: '0',
172
+ minHeight: '0',
173
+ overflow: 'hidden',
174
+ });
175
+ expect(embedContainer).toHaveStyle({
176
+ flexShrink: '0',
177
+ height: '100%',
178
+ position: 'relative',
179
+ });
180
+
181
+ embedContainer.style.width = '33.3%';
182
+ embedContainer.style.maxWidth = '800px';
183
+ expect(workspace?.lastElementChild).toBe(embedContainer);
184
+ expect(embedContainer).toHaveStyle({ width: '33.3%', maxWidth: '800px' });
185
+
186
+ embedContainer.style.width = 'auto';
187
+ embedContainer.style.maxWidth = 'none';
188
+ expect(content).toHaveStyle({ flex: '1' });
189
+ expect(embedContainer).toHaveStyle({ width: 'auto', maxWidth: 'none' });
190
+ });
191
+
192
+ it.each(['auth.signin', '2fa.verify'])('does not render the settings shell for %s', (routeId) => {
193
+ matchRoutes.mockReturnValue([{ route: { id: routeId } }]);
194
+
195
+ render(
196
+ <MemoryRouter initialEntries={[routeId === 'auth.signin' ? '/settings/signin' : '/settings/2fa']}>
197
+ <SettingsShell>
198
+ <div>authentication content</div>
199
+ </SettingsShell>
200
+ </MemoryRouter>,
201
+ );
202
+
203
+ expect(screen.getByText('authentication content')).toBeInTheDocument();
204
+ expect(screen.queryByRole('banner')).not.toBeInTheDocument();
205
+ expect(screen.queryByTestId('settings-logo')).not.toBeInTheDocument();
206
+ expect(screen.queryByTestId('settings-help')).not.toBeInTheDocument();
207
+ expect(screen.queryByTestId('settings-user-center')).not.toBeInTheDocument();
208
+ expect(document.querySelector('#nocobase-embed-container')).not.toBeInTheDocument();
209
+ });
210
+
211
+ it('does not display the Settings header on the OAuth device verification route', () => {
212
+ matchRoutes.mockReturnValue([
213
+ { route: { id: 'settingsDetails' } },
214
+ { route: { id: 'settingsDetails.idpOAuth.device' } },
215
+ ]);
216
+
217
+ render(
218
+ <MemoryRouter initialEntries={['/settings/idpOAuth/device?user_code=TKHX-NNCC']}>
219
+ <SettingsShell>
220
+ <div>device verification content</div>
221
+ </SettingsShell>
222
+ </MemoryRouter>,
223
+ );
224
+
225
+ expect(screen.getByText('device verification content')).toBeInTheDocument();
226
+ expect(document.querySelector('header')).toHaveStyle({ display: 'none' });
227
+ });
228
+
229
+ it('continues to display the Settings header on other details routes', () => {
230
+ matchRoutes.mockReturnValue([
231
+ { route: { id: 'settingsDetails' } },
232
+ { route: { id: 'settingsDetails.workflow.workflows.id' } },
233
+ ]);
234
+
235
+ render(
236
+ <MemoryRouter initialEntries={['/settings/workflow/workflows/1']}>
237
+ <SettingsShell>
238
+ <div>workflow details content</div>
239
+ </SettingsShell>
240
+ </MemoryRouter>,
241
+ );
242
+
243
+ expect(screen.getByRole('banner')).toBeVisible();
244
+ });
245
+
246
+ it('does not render the Settings header before authentication completes', () => {
247
+ setCurrentUserAuthStatus(mockApp, 'unknown');
248
+
249
+ render(
250
+ <MemoryRouter initialEntries={['/settings/system-settings']}>
251
+ <SettingsShell>
252
+ <div>settings content</div>
253
+ </SettingsShell>
254
+ </MemoryRouter>,
255
+ );
256
+
257
+ expect(screen.getByText('settings content')).toBeInTheDocument();
258
+ expect(document.querySelector('header')).toHaveStyle({ display: 'none' });
259
+ expect(screen.queryByTestId('settings-logo')).not.toBeVisible();
260
+ });
261
+ });