@nocobase/client-v2 3.0.0-alpha.3 → 3.0.0-alpha.5
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/acl/aclCheckReadiness.d.ts +11 -0
- package/es/authRedirect.d.ts +6 -2
- package/es/index.d.ts +2 -1
- package/es/index.mjs +60 -60
- 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 +65 -65
- package/package.json +7 -7
- package/src/__tests__/authRedirect.test.ts +17 -0
- package/src/__tests__/browserChecker.test.ts +44 -33
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +37 -0
- package/src/__tests__/settings-center.test.tsx +110 -10
- 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 +35 -0
- package/src/acl/ACLProvider.tsx +32 -3
- package/src/acl/aclCheckReadiness.ts +44 -0
- package/src/authRedirect.ts +13 -5
- package/src/flow/models/base/CollectionBlockModel.tsx +9 -0
- package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts +60 -0
- 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 +4 -2
- package/src/settings-app/runtimePaths.ts +0 -1
- package/src/settings-app/settingsTheme.ts +82 -14
- package/src/settings-center/AdminSettingsLayout.tsx +96 -106
- package/src/ui-operation/index.ts +33 -0
- 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
|
+
});
|
|
@@ -182,6 +182,41 @@ describe('SettingsShell', () => {
|
|
|
182
182
|
expect(document.querySelector('#nocobase-embed-container')).not.toBeInTheDocument();
|
|
183
183
|
});
|
|
184
184
|
|
|
185
|
+
it('does not display the Settings header on the OAuth device verification route', () => {
|
|
186
|
+
matchRoutes.mockReturnValue([
|
|
187
|
+
{ route: { id: 'settingsDetails' } },
|
|
188
|
+
{ route: { id: 'settingsDetails.idpOAuth.device' } },
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
render(
|
|
192
|
+
<MemoryRouter initialEntries={['/settings/idpOAuth/device?user_code=TKHX-NNCC']}>
|
|
193
|
+
<SettingsShell>
|
|
194
|
+
<div>device verification content</div>
|
|
195
|
+
</SettingsShell>
|
|
196
|
+
</MemoryRouter>,
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
expect(screen.getByText('device verification content')).toBeInTheDocument();
|
|
200
|
+
expect(document.querySelector('header')).toHaveStyle({ display: 'none' });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('continues to display the Settings header on other details routes', () => {
|
|
204
|
+
matchRoutes.mockReturnValue([
|
|
205
|
+
{ route: { id: 'settingsDetails' } },
|
|
206
|
+
{ route: { id: 'settingsDetails.workflow.workflows.id' } },
|
|
207
|
+
]);
|
|
208
|
+
|
|
209
|
+
render(
|
|
210
|
+
<MemoryRouter initialEntries={['/settings/workflow/workflows/1']}>
|
|
211
|
+
<SettingsShell>
|
|
212
|
+
<div>workflow details content</div>
|
|
213
|
+
</SettingsShell>
|
|
214
|
+
</MemoryRouter>,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
expect(screen.getByRole('banner')).toBeVisible();
|
|
218
|
+
});
|
|
219
|
+
|
|
185
220
|
it('does not render the Settings header before authentication completes', () => {
|
|
186
221
|
setCurrentUserAuthStatus(mockApp, 'unknown');
|
|
187
222
|
|
package/src/acl/ACLProvider.tsx
CHANGED
|
@@ -12,6 +12,7 @@ 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
14
|
import { useApp } from '../hooks/useApp';
|
|
15
|
+
import { getACLCheckReady, setACLCheckReady } from './aclCheckReadiness';
|
|
15
16
|
import { createAclSnippetAllow } from './createAclSnippetAllow';
|
|
16
17
|
|
|
17
18
|
export type ACLRoleData = {
|
|
@@ -84,16 +85,23 @@ export const ACLRolesCheckProvider: FC = ({ children }) => {
|
|
|
84
85
|
const location = useLocation();
|
|
85
86
|
const aclStore = ensureACLStore(app);
|
|
86
87
|
const [loading, setLoading] = useState(false);
|
|
88
|
+
const refreshGenerationRef = useRef(0);
|
|
89
|
+
const lastSuccessfulCheckRef = useRef(getACLCheckReady(app));
|
|
87
90
|
const pathnameRef = useRef(location.pathname);
|
|
88
91
|
pathnameRef.current = location.pathname;
|
|
89
92
|
|
|
90
93
|
const refresh = useCallback(async () => {
|
|
91
94
|
if (app.router.isSkippedAuthCheckRoute(pathnameRef.current)) {
|
|
92
95
|
// 认证页等免鉴权路由不需要执行 `roles:check`,避免未登录时产生多余的 401 与 loading 闪烁。
|
|
96
|
+
refreshGenerationRef.current += 1;
|
|
97
|
+
lastSuccessfulCheckRef.current = false;
|
|
98
|
+
setACLCheckReady(app, false);
|
|
93
99
|
setLoading(false);
|
|
94
100
|
return;
|
|
95
101
|
}
|
|
96
102
|
|
|
103
|
+
const refreshGeneration = ++refreshGenerationRef.current;
|
|
104
|
+
setACLCheckReady(app, false);
|
|
97
105
|
const shouldShowLoading = !aclStore.data?.role && !aclStore.data?.snippets?.length;
|
|
98
106
|
if (shouldShowLoading) {
|
|
99
107
|
setLoading(true);
|
|
@@ -109,6 +117,10 @@ export const ACLRolesCheckProvider: FC = ({ children }) => {
|
|
|
109
117
|
const nextData = res?.data?.data || {};
|
|
110
118
|
const nextMeta = res?.data?.meta || {};
|
|
111
119
|
|
|
120
|
+
if (refreshGeneration !== refreshGenerationRef.current) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
112
124
|
aclStore.setData(nextData);
|
|
113
125
|
aclStore.setMeta(nextMeta);
|
|
114
126
|
app.pluginSettingsManager.setAclSnippets(nextData?.snippets || []);
|
|
@@ -117,13 +129,21 @@ export const ACLRolesCheckProvider: FC = ({ children }) => {
|
|
|
117
129
|
app.apiClient.auth.setRole(nextData?.role);
|
|
118
130
|
}
|
|
119
131
|
|
|
132
|
+
lastSuccessfulCheckRef.current = true;
|
|
133
|
+
setACLCheckReady(app, true);
|
|
134
|
+
|
|
120
135
|
if (!createAclSnippetAllow(nextData?.snippets || [], !!nextData?.allowAll)('ui.*')) {
|
|
121
136
|
await app.flowEngine.flowSettings.disable();
|
|
122
137
|
}
|
|
123
138
|
} catch (error) {
|
|
139
|
+
if (refreshGeneration !== refreshGenerationRef.current) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
124
143
|
const status = error?.response?.status || error?.status;
|
|
125
144
|
|
|
126
145
|
if (status === 401) {
|
|
146
|
+
lastSuccessfulCheckRef.current = false;
|
|
127
147
|
aclStore.setData({});
|
|
128
148
|
aclStore.setMeta({});
|
|
129
149
|
app.pluginSettingsManager.setAclSnippets([]);
|
|
@@ -131,15 +151,24 @@ export const ACLRolesCheckProvider: FC = ({ children }) => {
|
|
|
131
151
|
return;
|
|
132
152
|
}
|
|
133
153
|
|
|
154
|
+
setACLCheckReady(app, lastSuccessfulCheckRef.current);
|
|
134
155
|
console.error(error);
|
|
135
156
|
} finally {
|
|
136
|
-
|
|
157
|
+
if (refreshGeneration === refreshGenerationRef.current) {
|
|
158
|
+
setLoading(false);
|
|
159
|
+
}
|
|
137
160
|
}
|
|
138
161
|
}, [aclStore, app]);
|
|
139
162
|
|
|
140
163
|
useEffect(() => {
|
|
141
|
-
|
|
142
|
-
|
|
164
|
+
refresh();
|
|
165
|
+
|
|
166
|
+
return () => {
|
|
167
|
+
refreshGenerationRef.current += 1;
|
|
168
|
+
lastSuccessfulCheckRef.current = false;
|
|
169
|
+
setACLCheckReady(app, false);
|
|
170
|
+
};
|
|
171
|
+
}, [app, refresh]);
|
|
143
172
|
|
|
144
173
|
const value = useMemo<ACLContextValue>(
|
|
145
174
|
() => ({
|
|
@@ -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
|
+
}
|
package/src/authRedirect.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
345
|
-
|
|
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;
|
|
@@ -82,6 +82,15 @@ export class CollectionBlockModel<T = DefaultStructure> extends DataBlockModel<T
|
|
|
82
82
|
const engine = this.context.engine as FlowEngine;
|
|
83
83
|
const currentVersion = this.getDirtyTrackingVersion(engine, dataSourceKey, resource, params);
|
|
84
84
|
|
|
85
|
+
if (resource instanceof MultiRecordResource && this.getDataLoadingMode() === 'manual' && !this.hasActiveFilters()) {
|
|
86
|
+
resource.setData([]);
|
|
87
|
+
resource.setMeta({ count: 0, hasNext: false });
|
|
88
|
+
resource.setPage(1);
|
|
89
|
+
resource.loading = false;
|
|
90
|
+
this.lastSeenDirtyVersion = currentVersion;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
85
94
|
if (forceRefresh) {
|
|
86
95
|
if (this.dirtyRefreshing) return;
|
|
87
96
|
this.dirtyRefreshing = true;
|
package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts
CHANGED
|
@@ -128,4 +128,64 @@ describe('CollectionBlockModel initial beforeRender refresh', () => {
|
|
|
128
128
|
expect(resource.getData()).toEqual([]);
|
|
129
129
|
expect(resource.getMeta('count')).toBe(0);
|
|
130
130
|
});
|
|
131
|
+
|
|
132
|
+
it('skips forced active refresh in manual mode when filters are empty', async () => {
|
|
133
|
+
const { model, resource } = setupModelWithManualMode();
|
|
134
|
+
const refreshSpy = vi.spyOn(resource, 'refresh');
|
|
135
|
+
|
|
136
|
+
resource.setData([{ id: 2, name: 'Stale' }]);
|
|
137
|
+
resource.setMeta({ count: 1, hasNext: true });
|
|
138
|
+
resource.setPage(2);
|
|
139
|
+
resource.loading = true;
|
|
140
|
+
|
|
141
|
+
model.onActive(true);
|
|
142
|
+
await Promise.resolve();
|
|
143
|
+
await Promise.resolve();
|
|
144
|
+
|
|
145
|
+
expect(refreshSpy).not.toHaveBeenCalled();
|
|
146
|
+
expect(resource.getData()).toEqual([]);
|
|
147
|
+
expect(resource.getMeta('count')).toBe(0);
|
|
148
|
+
expect(resource.getMeta('hasNext')).toBe(false);
|
|
149
|
+
expect(resource.getMeta('page')).toBe(1);
|
|
150
|
+
expect(resource.getRequestParameter('page')).toBe(1);
|
|
151
|
+
expect(resource.loading).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('keeps forced active refresh in manual mode when filters are active', async () => {
|
|
155
|
+
const { model, resource } = setupModelWithManualMode();
|
|
156
|
+
const refreshSpy = vi.spyOn(resource, 'refresh').mockResolvedValue();
|
|
157
|
+
|
|
158
|
+
resource.setData([{ id: 2, name: 'Stale' }]);
|
|
159
|
+
resource.setMeta({ count: 1, hasNext: true, page: 2 });
|
|
160
|
+
model.setFilterActive('filter-form-item', true);
|
|
161
|
+
|
|
162
|
+
model.onActive(true);
|
|
163
|
+
await Promise.resolve();
|
|
164
|
+
|
|
165
|
+
expect(refreshSpy).toHaveBeenCalledTimes(1);
|
|
166
|
+
expect(resource.getData()).toEqual([{ id: 2, name: 'Stale' }]);
|
|
167
|
+
expect(resource.getMeta('count')).toBe(1);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('skips forced active refresh in manual mode when only resource filters are active', async () => {
|
|
171
|
+
const { model, resource } = setupModelWithManualMode();
|
|
172
|
+
const refreshSpy = vi.spyOn(resource, 'refresh');
|
|
173
|
+
|
|
174
|
+
resource.setData([{ id: 2, name: 'Stale' }]);
|
|
175
|
+
resource.setMeta({ count: 1, hasNext: true });
|
|
176
|
+
resource.setPage(2);
|
|
177
|
+
resource.loading = true;
|
|
178
|
+
resource.addFilterGroup('data-scope', { status: { $eq: 'active' } });
|
|
179
|
+
|
|
180
|
+
model.onActive(true);
|
|
181
|
+
await Promise.resolve();
|
|
182
|
+
|
|
183
|
+
expect(refreshSpy).not.toHaveBeenCalled();
|
|
184
|
+
expect(resource.getData()).toEqual([]);
|
|
185
|
+
expect(resource.getMeta('count')).toBe(0);
|
|
186
|
+
expect(resource.getMeta('hasNext')).toBe(false);
|
|
187
|
+
expect(resource.getMeta('page')).toBe(1);
|
|
188
|
+
expect(resource.getRequestParameter('page')).toBe(1);
|
|
189
|
+
expect(resource.loading).toBe(false);
|
|
190
|
+
});
|
|
131
191
|
});
|
package/src/index.ts
CHANGED
|
@@ -33,6 +33,7 @@ export {
|
|
|
33
33
|
CurrentUserContext,
|
|
34
34
|
NocoBaseBuildInPlugin,
|
|
35
35
|
NocoBaseBuildInPluginV2,
|
|
36
|
+
resolveUnauthenticatedSignInRoute,
|
|
36
37
|
useCurrentRoles,
|
|
37
38
|
useCurrentUserContext,
|
|
38
39
|
} from './nocobase-buildin-plugin';
|
|
@@ -60,6 +61,7 @@ export {
|
|
|
60
61
|
} from './flow-compat';
|
|
61
62
|
export type { NocoBaseDesktopRoute } from './flow-compat';
|
|
62
63
|
export * from './utils/markdownSanitize';
|
|
64
|
+
export * from './ui-operation';
|
|
63
65
|
export { default as AntdAppProvider } from './theme/AntdAppProvider';
|
|
64
66
|
export { isSettingsApp } from './settings-app/isSettingsApp';
|
|
65
67
|
export { MINIMAL_THEME_UID, useSettingsThemeConfig } from './settings-app/useSettingsThemeConfig';
|
|
@@ -38,11 +38,12 @@ export const LayoutContentRoute = (props: LayoutContentRouteProps) => {
|
|
|
38
38
|
id: lastMatch?.id,
|
|
39
39
|
name: lastMatch?.id,
|
|
40
40
|
pathname: location.pathname,
|
|
41
|
+
state: location.state,
|
|
41
42
|
params: (lastMatch?.params || {}) as Record<string, string | undefined>,
|
|
42
43
|
layoutRouteName: layout.routeName,
|
|
43
44
|
layoutBasePathname: layoutMatch?.pathname,
|
|
44
45
|
};
|
|
45
|
-
}, [layout.routeName, location.pathname, matches]);
|
|
46
|
+
}, [layout.routeName, location.pathname, location.state, matches]);
|
|
46
47
|
if (!model) {
|
|
47
48
|
throw new Error(`[NocoBase] Layout '${layout.routeName}' model '${layout.uid}' is not mounted.`);
|
|
48
49
|
}
|
|
@@ -71,11 +71,12 @@ export const LayoutRoute = (props: LayoutRouteProps) => {
|
|
|
71
71
|
id: lastMatch?.id,
|
|
72
72
|
name: lastMatch?.id,
|
|
73
73
|
pathname: location.pathname,
|
|
74
|
+
state: location.state,
|
|
74
75
|
params: lastMatchParams,
|
|
75
76
|
layoutRouteName: layout.routeName,
|
|
76
77
|
layoutBasePathname: layoutMatch?.pathname,
|
|
77
78
|
};
|
|
78
|
-
}, [lastMatch?.id, lastMatchParams, layout.routeName, layoutMatch?.pathname, location.pathname]);
|
|
79
|
+
}, [lastMatch?.id, lastMatchParams, layout.routeName, layoutMatch?.pathname, location.pathname, location.state]);
|
|
79
80
|
const { loading, data, error } = useRequest(
|
|
80
81
|
async () => {
|
|
81
82
|
const existingModel = flowEngine.getModel<BaseLayoutModel>(layout.uid);
|