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

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
@@ -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
+ });
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { render, screen } from '@testing-library/react';
11
- import { ConfigProvider } from 'antd';
11
+ import { ConfigProvider, theme as antdTheme } from 'antd';
12
12
  import React from 'react';
13
13
  import { MemoryRouter } from 'react-router-dom';
14
14
  import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -19,6 +19,7 @@ import type { ThemeConfig } from '../theme';
19
19
  const userCenterModel = { uid: 'settings-user-center' };
20
20
  const createModel = vi.fn(() => userCenterModel);
21
21
  const matchRoutes = vi.fn(() => [{ route: { id: 'settings' } }]);
22
+ let settingsThemeConfig: ThemeConfig | null = null;
22
23
  const mockApp = {
23
24
  flowEngine: {
24
25
  createModel,
@@ -46,6 +47,10 @@ vi.mock('../settings-app/SettingsSearch', () => ({
46
47
  SettingsSearch: () => <div data-testid="settings-search">search</div>,
47
48
  }));
48
49
 
50
+ vi.mock('../settings-app/useSettingsThemeConfig', () => ({
51
+ useSettingsThemeConfig: () => settingsThemeConfig,
52
+ }));
53
+
49
54
  vi.mock('../flow/admin-shell/admin-layout/HelpLite', () => ({
50
55
  HelpLite: () => <div data-testid="settings-help">help</div>,
51
56
  }));
@@ -60,8 +65,15 @@ vi.mock('@nocobase/flow-engine', async (importOriginal) => {
60
65
  };
61
66
  });
62
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
+
63
74
  describe('SettingsShell', () => {
64
75
  beforeEach(() => {
76
+ settingsThemeConfig = null;
65
77
  createModel.mockClear();
66
78
  matchRoutes.mockReset();
67
79
  matchRoutes.mockReturnValue([{ route: { id: 'settings' } }]);
@@ -111,6 +123,20 @@ describe('SettingsShell', () => {
111
123
  expect(screen.getByRole('banner')).toHaveStyle({ background: '#ffffff' });
112
124
  });
113
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
+
114
140
  it('places the settings content and embed container side by side below the header', () => {
115
141
  const { container } = render(
116
142
  <MemoryRouter initialEntries={['/settings/theme-editor']}>
@@ -182,6 +208,41 @@ describe('SettingsShell', () => {
182
208
  expect(document.querySelector('#nocobase-embed-container')).not.toBeInTheDocument();
183
209
  });
184
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
+
185
246
  it('does not render the Settings header before authentication completes', () => {
186
247
  setCurrentUserAuthStatus(mockApp, 'unknown');
187
248
 
@@ -11,7 +11,9 @@ import { observable } from '@formily/reactive';
11
11
  import React, { useRef } from 'react';
12
12
  import { createContext, useCallback, useContext, useEffect, useMemo, useState, type FC } from 'react';
13
13
  import { useLocation } from 'react-router-dom';
14
+ import { writeFlowSettingsPreference } from '../flow/admin-shell/admin-layout/flowSettingsPreference';
14
15
  import { useApp } from '../hooks/useApp';
16
+ import { getACLCheckReady, setACLCheckReady } from './aclCheckReadiness';
15
17
  import { createAclSnippetAllow } from './createAclSnippetAllow';
16
18
 
17
19
  export type ACLRoleData = {
@@ -84,16 +86,23 @@ export const ACLRolesCheckProvider: FC = ({ children }) => {
84
86
  const location = useLocation();
85
87
  const aclStore = ensureACLStore(app);
86
88
  const [loading, setLoading] = useState(false);
89
+ const refreshGenerationRef = useRef(0);
90
+ const lastSuccessfulCheckRef = useRef(getACLCheckReady(app));
87
91
  const pathnameRef = useRef(location.pathname);
88
92
  pathnameRef.current = location.pathname;
89
93
 
90
94
  const refresh = useCallback(async () => {
91
95
  if (app.router.isSkippedAuthCheckRoute(pathnameRef.current)) {
92
96
  // 认证页等免鉴权路由不需要执行 `roles:check`,避免未登录时产生多余的 401 与 loading 闪烁。
97
+ refreshGenerationRef.current += 1;
98
+ lastSuccessfulCheckRef.current = false;
99
+ setACLCheckReady(app, false);
93
100
  setLoading(false);
94
101
  return;
95
102
  }
96
103
 
104
+ const refreshGeneration = ++refreshGenerationRef.current;
105
+ setACLCheckReady(app, false);
97
106
  const shouldShowLoading = !aclStore.data?.role && !aclStore.data?.snippets?.length;
98
107
  if (shouldShowLoading) {
99
108
  setLoading(true);
@@ -109,6 +118,10 @@ export const ACLRolesCheckProvider: FC = ({ children }) => {
109
118
  const nextData = res?.data?.data || {};
110
119
  const nextMeta = res?.data?.meta || {};
111
120
 
121
+ if (refreshGeneration !== refreshGenerationRef.current) {
122
+ return;
123
+ }
124
+
112
125
  aclStore.setData(nextData);
113
126
  aclStore.setMeta(nextMeta);
114
127
  app.pluginSettingsManager.setAclSnippets(nextData?.snippets || []);
@@ -117,13 +130,22 @@ export const ACLRolesCheckProvider: FC = ({ children }) => {
117
130
  app.apiClient.auth.setRole(nextData?.role);
118
131
  }
119
132
 
133
+ lastSuccessfulCheckRef.current = true;
134
+ setACLCheckReady(app, true);
135
+
120
136
  if (!createAclSnippetAllow(nextData?.snippets || [], !!nextData?.allowAll)('ui.*')) {
137
+ writeFlowSettingsPreference(false);
121
138
  await app.flowEngine.flowSettings.disable();
122
139
  }
123
140
  } catch (error) {
141
+ if (refreshGeneration !== refreshGenerationRef.current) {
142
+ return;
143
+ }
144
+
124
145
  const status = error?.response?.status || error?.status;
125
146
 
126
147
  if (status === 401) {
148
+ lastSuccessfulCheckRef.current = false;
127
149
  aclStore.setData({});
128
150
  aclStore.setMeta({});
129
151
  app.pluginSettingsManager.setAclSnippets([]);
@@ -131,15 +153,24 @@ export const ACLRolesCheckProvider: FC = ({ children }) => {
131
153
  return;
132
154
  }
133
155
 
156
+ setACLCheckReady(app, lastSuccessfulCheckRef.current);
134
157
  console.error(error);
135
158
  } finally {
136
- setLoading(false);
159
+ if (refreshGeneration === refreshGenerationRef.current) {
160
+ setLoading(false);
161
+ }
137
162
  }
138
163
  }, [aclStore, app]);
139
164
 
140
165
  useEffect(() => {
141
- void refresh();
142
- }, [refresh]);
166
+ refresh();
167
+
168
+ return () => {
169
+ refreshGenerationRef.current += 1;
170
+ lastSuccessfulCheckRef.current = false;
171
+ setACLCheckReady(app, false);
172
+ };
173
+ }, [app, refresh]);
143
174
 
144
175
  const value = useMemo<ACLContextValue>(
145
176
  () => ({
@@ -0,0 +1,92 @@
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, waitFor } from '@testing-library/react';
11
+ import React from 'react';
12
+ import { MemoryRouter } from 'react-router-dom';
13
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
14
+ import {
15
+ FLOW_SETTINGS_PREFERENCE_STORAGE_KEY,
16
+ writeFlowSettingsPreference,
17
+ } from '../../flow/admin-shell/admin-layout/flowSettingsPreference';
18
+ import { ACLRolesCheckProvider } from '../ACLProvider';
19
+
20
+ const mocks = vi.hoisted(() => ({
21
+ app: undefined as unknown,
22
+ }));
23
+
24
+ vi.mock('../../hooks/useApp', () => ({
25
+ useApp: () => mocks.app,
26
+ }));
27
+
28
+ describe('ACLRolesCheckProvider', () => {
29
+ beforeEach(() => {
30
+ window.localStorage.removeItem(FLOW_SETTINGS_PREFERENCE_STORAGE_KEY);
31
+ });
32
+
33
+ it('exits UI editing mode when the current role cannot configure pages', async () => {
34
+ const disable = vi.fn(async () => undefined);
35
+ const setRole = vi.fn();
36
+ const aclContext: Record<string, unknown> = {};
37
+ const appContext = {
38
+ acl: undefined,
39
+ defineProperty(name: string, descriptor: PropertyDescriptor) {
40
+ Object.defineProperty(this, name, descriptor);
41
+ },
42
+ ...aclContext,
43
+ };
44
+
45
+ mocks.app = {
46
+ apiClient: {
47
+ auth: {
48
+ role: 'member',
49
+ setRole,
50
+ },
51
+ request: vi.fn(async () => ({
52
+ data: {
53
+ data: {
54
+ role: 'viewer',
55
+ snippets: [],
56
+ },
57
+ meta: {},
58
+ },
59
+ })),
60
+ },
61
+ context: appContext,
62
+ flowEngine: {
63
+ flowSettings: {
64
+ disable,
65
+ },
66
+ },
67
+ pluginSettingsManager: {
68
+ setAclSnippets: vi.fn(),
69
+ },
70
+ renderComponent: () => <div>Loading</div>,
71
+ router: {
72
+ isSkippedAuthCheckRoute: () => false,
73
+ },
74
+ };
75
+ writeFlowSettingsPreference(true);
76
+
77
+ render(
78
+ <MemoryRouter initialEntries={['/admin']}>
79
+ <ACLRolesCheckProvider>
80
+ <div>Page content</div>
81
+ </ACLRolesCheckProvider>
82
+ </MemoryRouter>,
83
+ );
84
+
85
+ expect(await screen.findByText('Page content')).toBeInTheDocument();
86
+ await waitFor(() => {
87
+ expect(disable).toHaveBeenCalledTimes(1);
88
+ });
89
+ expect(window.localStorage.getItem(FLOW_SETTINGS_PREFERENCE_STORAGE_KEY)).toBe('0');
90
+ expect(setRole).toHaveBeenCalledWith('viewer');
91
+ });
92
+ });
@@ -0,0 +1,44 @@
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 { useEffect, useState } from 'react';
11
+
12
+ type ReadinessListener = (ready: boolean) => void;
13
+
14
+ const readinessByApp = new WeakMap<object, boolean>();
15
+ const listenersByApp = new WeakMap<object, Set<ReadinessListener>>();
16
+
17
+ export function getACLCheckReady(app: object) {
18
+ return readinessByApp.get(app) ?? false;
19
+ }
20
+
21
+ export function setACLCheckReady(app: object, ready: boolean) {
22
+ readinessByApp.set(app, ready);
23
+ listenersByApp.get(app)?.forEach((listener) => listener(ready));
24
+ }
25
+
26
+ export function useACLCheckReady(app: object) {
27
+ const [ready, setReady] = useState(() => getACLCheckReady(app));
28
+
29
+ useEffect(() => {
30
+ const listeners = listenersByApp.get(app) || new Set<ReadinessListener>();
31
+ listeners.add(setReady);
32
+ listenersByApp.set(app, listeners);
33
+ setReady(getACLCheckReady(app));
34
+
35
+ return () => {
36
+ listeners.delete(setReady);
37
+ if (!listeners.size) {
38
+ listenersByApp.delete(app);
39
+ }
40
+ };
41
+ }, [app]);
42
+
43
+ return ready;
44
+ }
@@ -328,10 +328,14 @@ export function getCurrentV2RedirectPath(app: AppLike, locationLike: LocationLik
328
328
  *
329
329
  * @param app 当前 v2 应用实例
330
330
  * @param targetPath 登录后回跳地址
331
+ * @param options 可选的登录路由配置
331
332
  * @returns 指向 v2 登录页的 href
332
333
  */
333
- export function buildV2SigninHref(app: AppLike, targetPath: string) {
334
- return `${getV2SigninPath(app)}?redirect=${encodeURIComponent(targetPath)}`;
334
+ export function buildV2SigninHref(app: AppLike, targetPath: string, options?: { signInRoutePath?: string }) {
335
+ const signInPath = options?.signInRoutePath
336
+ ? normalizeV2RedirectPath(app, options.signInRoutePath, options.signInRoutePath)
337
+ : getV2SigninPath(app);
338
+ return `${signInPath}?redirect=${encodeURIComponent(targetPath)}`;
335
339
  }
336
340
 
337
341
  /**
@@ -339,10 +343,14 @@ export function buildV2SigninHref(app: AppLike, targetPath: string) {
339
343
  *
340
344
  * @param app 当前 v2 应用实例
341
345
  * @param targetPath 登录后回跳地址
342
- * @param options 跳转选项
346
+ * @param options 跳转和登录路由选项
343
347
  */
344
- export function redirectToV2Signin(app: AppLike, targetPath: string, options?: { replace?: boolean }) {
345
- const href = buildV2SigninHref(app, targetPath);
348
+ export function redirectToV2Signin(
349
+ app: AppLike,
350
+ targetPath: string,
351
+ options?: { replace?: boolean; signInRoutePath?: string },
352
+ ) {
353
+ const href = buildV2SigninHref(app, targetPath, options);
346
354
  if (options?.replace === false) {
347
355
  window.location.href = href;
348
356
  return;