@nocobase/client-v2 3.0.0-alpha.2 → 3.0.0-alpha.4

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 (34) hide show
  1. package/es/acl/aclCheckReadiness.d.ts +11 -0
  2. package/es/index.d.ts +2 -1
  3. package/es/index.mjs +46 -46
  4. package/es/nocobase-buildin-plugin/index.d.ts +1 -0
  5. package/es/settings-app/SettingsGroupNav.d.ts +3 -2
  6. package/es/settings-app/SettingsSearch.d.ts +1 -1
  7. package/es/ui-operation/index.d.ts +15 -0
  8. package/es/ui-operation/ui-operation-codec.d.ts +10 -0
  9. package/lib/index.js +65 -65
  10. package/package.json +7 -7
  11. package/src/__tests__/browserChecker.test.ts +44 -33
  12. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +37 -0
  13. package/src/__tests__/settings-center.test.tsx +110 -10
  14. package/src/__tests__/settings-layout-root.test.tsx +346 -6
  15. package/src/__tests__/settings-runtime-paths.test.ts +1 -1
  16. package/src/__tests__/settings-search-shortcut.test.tsx +321 -0
  17. package/src/__tests__/settings-shell.test.tsx +35 -0
  18. package/src/acl/ACLProvider.tsx +32 -3
  19. package/src/acl/aclCheckReadiness.ts +44 -0
  20. package/src/index.ts +2 -0
  21. package/src/layout-manager/LayoutContentRoute.tsx +2 -1
  22. package/src/layout-manager/LayoutRoute.tsx +2 -1
  23. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +87 -1
  24. package/src/nocobase-buildin-plugin/index.tsx +14 -1
  25. package/src/settings-app/SettingsGroupNav.tsx +11 -11
  26. package/src/settings-app/SettingsSearch.tsx +115 -10
  27. package/src/settings-app/SettingsShell.tsx +4 -2
  28. package/src/settings-app/runtimePaths.ts +0 -1
  29. package/src/settings-app/settingsTheme.ts +82 -14
  30. package/src/settings-center/AdminSettingsLayout.tsx +96 -106
  31. package/src/settings-center/__tests__/groups.test.ts +25 -0
  32. package/src/settings-center/groups.ts +1 -1
  33. package/src/ui-operation/index.ts +33 -0
  34. 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
 
@@ -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
- setLoading(false);
157
+ if (refreshGeneration === refreshGenerationRef.current) {
158
+ setLoading(false);
159
+ }
137
160
  }
138
161
  }, [aclStore, app]);
139
162
 
140
163
  useEffect(() => {
141
- void refresh();
142
- }, [refresh]);
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/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);
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { FlowEngine, FlowEngineProvider, observer } from '@nocobase/flow-engine';
11
- import { render, screen, waitFor } from '@testing-library/react';
11
+ import { act, render, screen, waitFor } from '@testing-library/react';
12
12
  import React from 'react';
13
13
  import { createMemoryRouter, Outlet, RouterProvider, useOutlet } from 'react-router-dom';
14
14
  import { describe, expect, it, vi } from 'vitest';
@@ -106,6 +106,64 @@ describe('LayoutRoute', () => {
106
106
  });
107
107
  });
108
108
 
109
+ it('syncs updated router state when navigating to the same layout pathname', async () => {
110
+ const syncLayoutRoute = vi.fn();
111
+
112
+ class StateTrackingLayoutModel extends TestLayoutModel {
113
+ syncLayoutRoute(routeLike: Parameters<BaseLayoutModel['syncLayoutRoute']>[0]) {
114
+ syncLayoutRoute(routeLike);
115
+ return super.syncLayoutRoute(routeLike);
116
+ }
117
+ }
118
+
119
+ const stateTrackingLayout: LayoutDefinition = {
120
+ ...layout,
121
+ layoutModelClass: 'StateTrackingLayoutModel',
122
+ };
123
+ const engine = new FlowEngine();
124
+ engine.registerModels({ StateTrackingLayoutModel });
125
+ engine.context.defineProperty('app', {
126
+ value: {
127
+ layoutManager: {
128
+ getLayout: () => stateTrackingLayout,
129
+ },
130
+ },
131
+ });
132
+
133
+ const router = createMemoryRouter(
134
+ [
135
+ {
136
+ id: layout.routeName,
137
+ path: layout.routePath,
138
+ element: <LayoutRoute layoutRouteName="test" />,
139
+ },
140
+ ],
141
+ {
142
+ initialEntries: ['/test'],
143
+ },
144
+ );
145
+
146
+ render(
147
+ <FlowEngineProvider engine={engine}>
148
+ <RouterProvider router={router} />
149
+ </FlowEngineProvider>,
150
+ );
151
+
152
+ expect(await screen.findByTestId('layout-route')).toHaveTextContent('test');
153
+ syncLayoutRoute.mockClear();
154
+
155
+ const routeState = {
156
+ __nocobaseOpenViewInputArgs: { popup: { formData: { status: 'todo' } } },
157
+ };
158
+ await act(async () => {
159
+ await router.navigate('/test', { state: routeState });
160
+ });
161
+
162
+ await waitFor(() => {
163
+ expect(syncLayoutRoute).toHaveBeenCalledWith(expect.objectContaining({ state: routeState }));
164
+ });
165
+ });
166
+
109
167
  it('does not activate desktop route loading for generic layouts', async () => {
110
168
  const activateLayout = vi.fn(() => vi.fn());
111
169
  const engine = new FlowEngine();
@@ -413,6 +471,34 @@ describe('LayoutContentRoute', () => {
413
471
  });
414
472
  });
415
473
 
474
+ it('syncs updated router state when navigating to the same content pathname', async () => {
475
+ const syncLayoutRoute = vi.fn();
476
+
477
+ class StateTrackingLayoutModel extends TestLayoutModel {
478
+ syncLayoutRoute(routeLike: Parameters<BaseLayoutModel['syncLayoutRoute']>[0]) {
479
+ syncLayoutRoute(routeLike);
480
+ return super.syncLayoutRoute(routeLike);
481
+ }
482
+ }
483
+
484
+ const { router } = setup('/test/page-1/view/popup', layout, StateTrackingLayoutModel);
485
+ await waitFor(() => {
486
+ expect(syncLayoutRoute).toHaveBeenCalled();
487
+ });
488
+ syncLayoutRoute.mockClear();
489
+
490
+ const routeState = {
491
+ __nocobaseOpenViewInputArgs: { popup: { formData: { status: 'todo' } } },
492
+ };
493
+ await act(async () => {
494
+ await router.navigate('/test/page-1/view/popup', { state: routeState });
495
+ });
496
+
497
+ await waitFor(() => {
498
+ expect(syncLayoutRoute).toHaveBeenCalledWith(expect.objectContaining({ state: routeState }));
499
+ });
500
+ });
501
+
416
502
  it('parses nested layout route by matched layout pathname', async () => {
417
503
  const nestedLayout: LayoutDefinition = {
418
504
  ...layout,
@@ -181,6 +181,18 @@ const DataSourceBootstrapProvider: FC = ({ children }) => {
181
181
  return <>{children}</>;
182
182
  };
183
183
 
184
+ export function resolveUnauthenticatedSignInRoute(app: Application, pathname: string) {
185
+ const matchedRoutes = app.router.matchRoutes(pathname) || [];
186
+ for (let index = matchedRoutes.length - 1; index >= 0; index -= 1) {
187
+ const pathnameBase = matchedRoutes[index].pathnameBase.replace(/\/+$/g, '');
188
+ const candidate = `${pathnameBase}/signin`;
189
+ if (app.router.isSkippedAuthCheckRoute(candidate)) {
190
+ return candidate;
191
+ }
192
+ }
193
+ return '/signin';
194
+ }
195
+
184
196
  function redirectUnauthenticatedRoute(
185
197
  app: Application,
186
198
  location: { pathname: string; search?: string; hash?: string },
@@ -191,7 +203,8 @@ function redirectUnauthenticatedRoute(
191
203
  redirectToV2Signin(app, redirectPath);
192
204
  return;
193
205
  }
194
- navigate(`/signin?redirect=${encodeURIComponent(redirectPath)}`, { replace: true });
206
+ const signInPath = resolveUnauthenticatedSignInRoute(app, location.pathname);
207
+ navigate(`${signInPath}?redirect=${encodeURIComponent(redirectPath)}`, { replace: true });
195
208
  }
196
209
 
197
210
  const CurrentUserProvider: FC = ({ children }) => {