@nocobase/client-v2 2.1.31 → 2.2.0-alpha.10

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 (170) hide show
  1. package/es/APIClient.d.ts +1 -3
  2. package/es/BaseApplication.d.ts +2 -0
  3. package/es/PluginSettingsManager.d.ts +33 -0
  4. package/es/RouteRepository.d.ts +29 -6
  5. package/es/RouterManager.d.ts +15 -0
  6. package/es/authRedirect.d.ts +1 -0
  7. package/es/components/category-tabs/SortableCategoryTabs.d.ts +55 -0
  8. package/es/components/category-tabs/index.d.ts +9 -0
  9. package/es/components/form/TypedVariableInput.d.ts +31 -5
  10. package/es/components/form/filter/CollectionFilter.d.ts +2 -0
  11. package/es/components/form/filter/CollectionFilterItem.d.ts +11 -1
  12. package/es/components/form/filter/CollectionFilterPanel.d.ts +2 -0
  13. package/es/components/form/filter/index.d.ts +2 -0
  14. package/es/components/form/filter/useFilterActionProps.d.ts +2 -0
  15. package/es/components/index.d.ts +2 -0
  16. package/es/entry-actions/EntryActionManager.d.ts +24 -0
  17. package/es/entry-actions/index.d.ts +9 -0
  18. package/es/flow/actions/index.d.ts +1 -1
  19. package/es/flow/actions/linkageRules.d.ts +2 -0
  20. package/es/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.d.ts +2 -0
  21. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.d.ts +2 -1
  22. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +3 -0
  23. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.d.ts +3 -2
  24. package/es/flow/admin-shell/admin-layout/AdminLayoutSlotModels.d.ts +5 -0
  25. package/es/flow/admin-shell/admin-layout/AppListRender.d.ts +2 -1
  26. package/es/flow/admin-shell/admin-layout/AppSwitcherActionPanelModel.d.ts +24 -0
  27. package/es/flow/admin-shell/admin-layout/index.d.ts +1 -0
  28. package/es/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.d.ts +6 -0
  29. package/es/flow/admin-shell/admin-layout/useApplications.d.ts +7 -2
  30. package/es/flow/components/FieldAssignExactDatePicker.d.ts +1 -0
  31. package/es/flow/components/FieldAssignValueInput.d.ts +9 -0
  32. package/es/flow/components/RunJSValueEditor.d.ts +1 -0
  33. package/es/flow/components/filter/FilterGroup.d.ts +1 -0
  34. package/es/flow/components/filter/VariableFilterItem.d.ts +6 -1
  35. package/es/flow/index.d.ts +1 -0
  36. package/es/flow/models/base/ActionModelCore.d.ts +2 -0
  37. package/es/flow/models/base/GridModel.d.ts +1 -1
  38. package/es/flow/models/base/PageModel/PageModel.d.ts +12 -0
  39. package/es/flow/models/base/PageModel/PageModelTabBar.d.ts +22 -0
  40. package/es/flow/models/base/PageModel/PageTabModel.d.ts +12 -0
  41. package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +9 -0
  42. package/es/flow-compat/routeTypes.d.ts +1 -0
  43. package/es/index.d.ts +7 -1
  44. package/es/index.mjs +223 -135
  45. package/es/utils/getRouteRuntimeVersion.d.ts +23 -0
  46. package/es/utils/index.d.ts +1 -0
  47. package/lib/index.js +220 -132
  48. package/package.json +7 -7
  49. package/src/APIClient.ts +1 -10
  50. package/src/Application.tsx +4 -0
  51. package/src/BaseApplication.tsx +2 -0
  52. package/src/PluginSettingsManager.ts +53 -0
  53. package/src/RouteRepository.ts +151 -24
  54. package/src/RouterManager.tsx +142 -1
  55. package/src/__tests__/PluginSettingsManager.test.ts +13 -0
  56. package/src/__tests__/RouteRepository.test.ts +239 -0
  57. package/src/__tests__/RouterManager.test.ts +125 -0
  58. package/src/__tests__/app.test.tsx +18 -0
  59. package/src/__tests__/authRedirect.test.ts +17 -0
  60. package/src/__tests__/browserChecker.test.ts +61 -0
  61. package/src/__tests__/exports.test.ts +16 -0
  62. package/src/__tests__/getRouteRuntimeVersion.test.ts +68 -0
  63. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +28 -0
  64. package/src/__tests__/plugin-manager.test.tsx +44 -2
  65. package/src/__tests__/settings-center.test.tsx +40 -1
  66. package/src/authRedirect.ts +38 -0
  67. package/src/components/README.md +7 -1
  68. package/src/components/README.zh-CN.md +6 -1
  69. package/src/components/category-tabs/SortableCategoryTabs.tsx +210 -0
  70. package/src/components/category-tabs/index.ts +10 -0
  71. package/src/components/form/JsonTextArea.tsx +4 -0
  72. package/src/components/form/TypedVariableInput.tsx +452 -101
  73. package/src/components/form/__tests__/TypedVariableInput.test.tsx +364 -12
  74. package/src/components/form/filter/CollectionFilter.tsx +4 -0
  75. package/src/components/form/filter/CollectionFilterItem.tsx +32 -7
  76. package/src/components/form/filter/CollectionFilterPanel.tsx +4 -0
  77. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +38 -0
  78. package/src/components/form/filter/__tests__/useFilterActionProps.test.tsx +95 -0
  79. package/src/components/form/filter/index.ts +2 -0
  80. package/src/components/form/filter/useFilterActionProps.ts +37 -6
  81. package/src/components/index.ts +2 -0
  82. package/src/entry-actions/EntryActionManager.ts +76 -0
  83. package/src/entry-actions/index.ts +10 -0
  84. package/src/flow/__tests__/FlowRoute.test.tsx +1084 -145
  85. package/src/flow/actions/__tests__/linkageRules.tab.test.ts +171 -0
  86. package/src/flow/actions/customVariable.tsx +1 -2
  87. package/src/flow/actions/index.ts +2 -0
  88. package/src/flow/actions/linkageRules.tsx +65 -8
  89. package/src/flow/actions/runjs.tsx +2 -14
  90. package/src/flow/admin-shell/BaseLayoutModel.tsx +13 -0
  91. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +46 -13
  92. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.test.tsx +486 -0
  93. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +64 -8
  94. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.ts +5 -3
  95. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +61 -9
  96. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +71 -8
  97. package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +1 -1
  98. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +23 -9
  99. package/src/flow/admin-shell/admin-layout/AppListRender.tsx +13 -2
  100. package/src/flow/admin-shell/admin-layout/AppSwitcherActionPanelModel.tsx +289 -0
  101. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +435 -0
  102. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +99 -0
  103. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +77 -0
  104. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +214 -2
  105. package/src/flow/admin-shell/admin-layout/index.ts +1 -0
  106. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +20 -0
  107. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +33 -5
  108. package/src/flow/admin-shell/admin-layout/useApplications.tsx +81 -12
  109. package/src/flow/components/DefaultValue.tsx +1 -2
  110. package/src/flow/components/FieldAssignExactDatePicker.tsx +25 -11
  111. package/src/flow/components/FieldAssignValueInput.tsx +76 -9
  112. package/src/flow/components/FlowRoute.tsx +185 -28
  113. package/src/flow/components/RunJSValueEditor.tsx +9 -1
  114. package/src/flow/components/__tests__/FieldAssignValueInput.context.test.tsx +84 -2
  115. package/src/flow/components/code-editor/__tests__/useCodeRunner.test.tsx +2 -17
  116. package/src/flow/components/code-editor/hooks/useCodeRunner.ts +2 -14
  117. package/src/flow/components/code-editor/runjsDiagnostics.ts +8 -45
  118. package/src/flow/components/filter/FilterGroup.tsx +33 -5
  119. package/src/flow/components/filter/VariableFilterItem.tsx +158 -10
  120. package/src/flow/components/filter/__tests__/FilterGroup.test.tsx +45 -0
  121. package/src/flow/components/filter/__tests__/VariableFilterItem.leftMetaTree.test.tsx +9 -0
  122. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +70 -1
  123. package/src/flow/index.ts +1 -0
  124. package/src/flow/models/actions/JSActionModel.tsx +2 -7
  125. package/src/flow/models/actions/JSCollectionActionModel.tsx +2 -7
  126. package/src/flow/models/actions/JSItemActionModel.tsx +2 -7
  127. package/src/flow/models/actions/JSRecordActionModel.tsx +2 -7
  128. package/src/flow/models/base/ActionModel.tsx +21 -8
  129. package/src/flow/models/base/ActionModelCore.tsx +5 -0
  130. package/src/flow/models/base/GridModel.tsx +38 -7
  131. package/src/flow/models/base/PageModel/PageModel.tsx +387 -53
  132. package/src/flow/models/base/PageModel/PageModelTabBar.tsx +114 -0
  133. package/src/flow/models/base/PageModel/PageTabModel.tsx +186 -3
  134. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +790 -14
  135. package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.module-isolation.test.tsx +130 -0
  136. package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.test.tsx +118 -0
  137. package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +586 -1
  138. package/src/flow/models/base/__tests__/ActionModel.test.ts +83 -0
  139. package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +239 -1
  140. package/src/flow/models/base/__tests__/transformRowsToSingleColumn.test.ts +48 -0
  141. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +1 -2
  142. package/src/flow/models/blocks/filter-form/FilterFormJSActionModel.tsx +2 -7
  143. package/src/flow/models/blocks/form/JSFormActionModel.tsx +2 -7
  144. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +87 -0
  145. package/src/flow/models/blocks/form/value-runtime/rules.ts +2 -2
  146. package/src/flow/models/blocks/form/value-runtime/runtime.ts +91 -0
  147. package/src/flow/models/blocks/js-block/JSBlock.tsx +2 -7
  148. package/src/flow/models/blocks/table/JSColumnModel.tsx +1 -9
  149. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +1 -1
  150. package/src/flow/models/blocks/table/TableBlockModel.tsx +13 -3
  151. package/src/flow/models/blocks/table/__tests__/TableActionsColumnModel.test.tsx +54 -0
  152. package/src/flow/models/blocks/table/__tests__/TableBlockModel.mobileSettingsButtons.test.tsx +78 -0
  153. package/src/flow/models/fields/JSEditableFieldModel.tsx +2 -11
  154. package/src/flow/models/fields/JSFieldModel.tsx +2 -7
  155. package/src/flow/models/fields/JSItemModel.tsx +2 -14
  156. package/src/flow/models/fields/VariableFieldFormModel.tsx +3 -3
  157. package/src/flow/models/fields/__tests__/VariableFieldFormModel.test.tsx +51 -0
  158. package/src/flow/models/topbar/TopbarActionModel.tsx +168 -10
  159. package/src/flow-compat/Popover.tsx +43 -4
  160. package/src/flow-compat/__tests__/Popover.test.tsx +34 -0
  161. package/src/flow-compat/routeTypes.ts +1 -0
  162. package/src/index.ts +13 -1
  163. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +41 -1
  164. package/src/nocobase-buildin-plugin/index.tsx +19 -3
  165. package/src/settings-center/AdminSettingsLayout.tsx +10 -2
  166. package/src/settings-center/SystemSettingsPage.tsx +0 -1
  167. package/src/settings-center/plugin-manager/PluginCard.tsx +2 -2
  168. package/src/settings-center/utils.tsx +0 -6
  169. package/src/utils/getRouteRuntimeVersion.ts +185 -0
  170. package/src/utils/index.tsx +1 -0
@@ -0,0 +1,486 @@
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 { FlowEngine, FlowEngineProvider } from '@nocobase/flow-engine';
11
+ import { act, render, screen, waitFor } from '@testing-library/react';
12
+ import React from 'react';
13
+ import { createMemoryRouter, Outlet, RouterProvider } from 'react-router-dom';
14
+ import { describe, expect, it, vi } from 'vitest';
15
+ import { NocoBaseDesktopRouteType } from '../../../flow-compat';
16
+ import { RouteRepository } from '../../../RouteRepository';
17
+ import { AdminLayoutEntryGuard } from './AdminLayoutEntryGuard';
18
+ import type { AdminLayoutModel } from './AdminLayoutModel';
19
+
20
+ describe('AdminLayoutEntryGuard', () => {
21
+ it('should ensure accessible routes for the active layout before resolving the landing route', async () => {
22
+ const engine = new FlowEngine();
23
+ const request = vi.fn().mockResolvedValue({
24
+ data: {
25
+ data: [],
26
+ },
27
+ });
28
+ const routeRepository = new RouteRepository({
29
+ api: {
30
+ request,
31
+ resource: vi.fn(),
32
+ },
33
+ } as never);
34
+ routeRepository.setRoutes([], 'admin-layout-model');
35
+ const deactivateLayout = routeRepository.activateLayout({
36
+ uid: 'mobile-layout-model',
37
+ });
38
+ engine.context.defineProperty('routeRepository', {
39
+ value: routeRepository,
40
+ });
41
+ engine.context.defineProperty('app', {
42
+ value: {
43
+ router: {
44
+ getBasename: () => '',
45
+ },
46
+ },
47
+ });
48
+
49
+ const model = {
50
+ layout: {
51
+ routeName: 'mobile',
52
+ routePath: '/mobile',
53
+ uid: 'mobile-layout-model',
54
+ },
55
+ } as AdminLayoutModel;
56
+ const router = createMemoryRouter(
57
+ [
58
+ {
59
+ path: '/mobile',
60
+ id: 'mobile',
61
+ element: (
62
+ <FlowEngineProvider engine={engine}>
63
+ <AdminLayoutEntryGuard model={model}>
64
+ <div>Mobile layout shell</div>
65
+ </AdminLayoutEntryGuard>
66
+ </FlowEngineProvider>
67
+ ),
68
+ },
69
+ ],
70
+ {
71
+ initialEntries: ['/mobile'],
72
+ },
73
+ );
74
+
75
+ render(<RouterProvider router={router} />);
76
+
77
+ await waitFor(() => {
78
+ expect(request).toHaveBeenCalledWith({
79
+ url: '/desktopRoutes:listAccessible',
80
+ params: {
81
+ tree: true,
82
+ sort: 'sort',
83
+ layout: 'mobile-layout-model',
84
+ },
85
+ });
86
+ });
87
+ expect(screen.getByText('Mobile layout shell')).toBeInTheDocument();
88
+ deactivateLayout();
89
+ });
90
+
91
+ it('should activate the guard layout before loading accessible routes', async () => {
92
+ const engine = new FlowEngine();
93
+ const request = vi.fn().mockResolvedValue({
94
+ data: {
95
+ data: [],
96
+ },
97
+ });
98
+ const routeRepository = new RouteRepository({
99
+ api: {
100
+ request,
101
+ resource: vi.fn(),
102
+ },
103
+ } as never);
104
+ engine.context.defineProperty('routeRepository', {
105
+ value: routeRepository,
106
+ });
107
+ engine.context.defineProperty('app', {
108
+ value: {
109
+ router: {
110
+ getBasename: () => '',
111
+ },
112
+ },
113
+ });
114
+
115
+ const model = {
116
+ layout: {
117
+ routeName: 'mobile',
118
+ routePath: '/mobile',
119
+ uid: 'mobile-layout-model',
120
+ },
121
+ } as AdminLayoutModel;
122
+ const router = createMemoryRouter(
123
+ [
124
+ {
125
+ path: '/mobile',
126
+ id: 'mobile',
127
+ element: (
128
+ <FlowEngineProvider engine={engine}>
129
+ <AdminLayoutEntryGuard model={model}>
130
+ <div>Mobile layout shell</div>
131
+ </AdminLayoutEntryGuard>
132
+ </FlowEngineProvider>
133
+ ),
134
+ },
135
+ ],
136
+ {
137
+ initialEntries: ['/mobile'],
138
+ },
139
+ );
140
+
141
+ render(<RouterProvider router={router} />);
142
+
143
+ await waitFor(() => {
144
+ expect(request).toHaveBeenCalledWith({
145
+ url: '/desktopRoutes:listAccessible',
146
+ params: {
147
+ tree: true,
148
+ sort: 'sort',
149
+ layout: 'mobile-layout-model',
150
+ },
151
+ });
152
+ });
153
+ expect(screen.getByText('Mobile layout shell')).toBeInTheDocument();
154
+ });
155
+
156
+ it('should keep guard stable when layout getter returns a new object with the same fields', async () => {
157
+ const engine = new FlowEngine();
158
+ const request = vi.fn().mockResolvedValue({
159
+ data: {
160
+ data: [],
161
+ },
162
+ });
163
+ const routeRepository = new RouteRepository({
164
+ api: {
165
+ request,
166
+ resource: vi.fn(),
167
+ },
168
+ } as never);
169
+ const activateLayout = vi.spyOn(routeRepository, 'activateLayout');
170
+ engine.context.defineProperty('routeRepository', {
171
+ value: routeRepository,
172
+ });
173
+ engine.context.defineProperty('app', {
174
+ value: {
175
+ router: {
176
+ getBasename: () => '',
177
+ },
178
+ },
179
+ });
180
+
181
+ const model = {
182
+ get layout() {
183
+ return {
184
+ authCheck: true,
185
+ routeName: 'admin',
186
+ routePath: '/admin',
187
+ uid: 'admin-layout-model',
188
+ };
189
+ },
190
+ } as AdminLayoutModel;
191
+ let forceRerender = () => {};
192
+ const GuardShell = () => {
193
+ const [, setVersion] = React.useState(0);
194
+ forceRerender = () => setVersion((version) => version + 1);
195
+ return (
196
+ <AdminLayoutEntryGuard model={model}>
197
+ <div>Admin layout shell</div>
198
+ </AdminLayoutEntryGuard>
199
+ );
200
+ };
201
+ const router = createMemoryRouter(
202
+ [
203
+ {
204
+ path: '/admin',
205
+ id: 'admin',
206
+ element: (
207
+ <FlowEngineProvider engine={engine}>
208
+ <GuardShell />
209
+ </FlowEngineProvider>
210
+ ),
211
+ },
212
+ ],
213
+ {
214
+ initialEntries: ['/admin'],
215
+ },
216
+ );
217
+
218
+ render(<RouterProvider router={router} />);
219
+
220
+ await waitFor(() => {
221
+ expect(screen.getByText('Admin layout shell')).toBeInTheDocument();
222
+ });
223
+ expect(activateLayout).toHaveBeenCalledTimes(1);
224
+
225
+ await act(async () => {
226
+ forceRerender();
227
+ });
228
+
229
+ expect(activateLayout).toHaveBeenCalledTimes(1);
230
+ expect(request).toHaveBeenCalledTimes(1);
231
+ });
232
+
233
+ it('should reactivate the guard when stable layout fields change', async () => {
234
+ const engine = new FlowEngine();
235
+ const request = vi.fn().mockResolvedValue({
236
+ data: {
237
+ data: [],
238
+ },
239
+ });
240
+ const routeRepository = new RouteRepository({
241
+ api: {
242
+ request,
243
+ resource: vi.fn(),
244
+ },
245
+ } as never);
246
+ const activateLayout = vi.spyOn(routeRepository, 'activateLayout');
247
+ engine.context.defineProperty('routeRepository', {
248
+ value: routeRepository,
249
+ });
250
+ engine.context.defineProperty('app', {
251
+ value: {
252
+ router: {
253
+ getBasename: () => '',
254
+ },
255
+ },
256
+ });
257
+
258
+ let layoutUid = 'admin-layout-model';
259
+ const model = {
260
+ get layout() {
261
+ return {
262
+ authCheck: true,
263
+ routeName: 'admin',
264
+ routePath: '/admin',
265
+ uid: layoutUid,
266
+ };
267
+ },
268
+ } as AdminLayoutModel;
269
+ let forceRerender = () => {};
270
+ const GuardShell = () => {
271
+ const [, setVersion] = React.useState(0);
272
+ forceRerender = () => setVersion((version) => version + 1);
273
+ return (
274
+ <AdminLayoutEntryGuard model={model}>
275
+ <div>Admin layout shell</div>
276
+ </AdminLayoutEntryGuard>
277
+ );
278
+ };
279
+ const router = createMemoryRouter(
280
+ [
281
+ {
282
+ path: '/admin',
283
+ id: 'admin',
284
+ element: (
285
+ <FlowEngineProvider engine={engine}>
286
+ <GuardShell />
287
+ </FlowEngineProvider>
288
+ ),
289
+ },
290
+ ],
291
+ {
292
+ initialEntries: ['/admin'],
293
+ },
294
+ );
295
+
296
+ render(<RouterProvider router={router} />);
297
+
298
+ await waitFor(() => {
299
+ expect(screen.getByText('Admin layout shell')).toBeInTheDocument();
300
+ });
301
+ expect(activateLayout).toHaveBeenCalledWith(expect.objectContaining({ uid: 'admin-layout-model' }));
302
+
303
+ await act(async () => {
304
+ layoutUid = 'admin-layout-next';
305
+ forceRerender();
306
+ });
307
+
308
+ expect(activateLayout).toHaveBeenCalledTimes(2);
309
+ expect(activateLayout).toHaveBeenLastCalledWith(expect.objectContaining({ uid: 'admin-layout-next' }));
310
+ });
311
+
312
+ it('should redirect group id paths to the first v2 landing route', async () => {
313
+ const modernWindow = window as Window & { __nocobase_modern_client_prefix__?: string };
314
+ modernWindow.__nocobase_modern_client_prefix__ = 'v2';
315
+ const engine = new FlowEngine();
316
+ const request = vi.fn().mockResolvedValue({
317
+ data: {
318
+ data: [],
319
+ },
320
+ });
321
+ const routeRepository = new RouteRepository({
322
+ api: {
323
+ request,
324
+ resource: vi.fn(),
325
+ },
326
+ } as never);
327
+ routeRepository.setRoutes([
328
+ {
329
+ id: 1,
330
+ title: 'Group',
331
+ type: NocoBaseDesktopRouteType.group,
332
+ children: [
333
+ {
334
+ id: 11,
335
+ title: 'Flow page',
336
+ schemaUid: 'flow-page-1',
337
+ type: NocoBaseDesktopRouteType.flowPage,
338
+ },
339
+ ],
340
+ },
341
+ ]);
342
+ engine.context.defineProperty('routeRepository', {
343
+ value: routeRepository,
344
+ });
345
+ engine.context.defineProperty('app', {
346
+ value: {
347
+ getPublicPath: () => '/apps/demo/v2/',
348
+ router: {
349
+ getBasename: () => '/apps/demo/v2',
350
+ },
351
+ },
352
+ });
353
+
354
+ const model = {
355
+ layout: {
356
+ routeName: 'admin',
357
+ routePath: '/admin',
358
+ uid: 'admin-layout-model',
359
+ },
360
+ } as AdminLayoutModel;
361
+ const router = createMemoryRouter(
362
+ [
363
+ {
364
+ path: '/admin',
365
+ id: 'admin',
366
+ element: (
367
+ <FlowEngineProvider engine={engine}>
368
+ <AdminLayoutEntryGuard model={model}>
369
+ <Outlet />
370
+ </AdminLayoutEntryGuard>
371
+ </FlowEngineProvider>
372
+ ),
373
+ children: [
374
+ {
375
+ path: ':name',
376
+ id: 'admin.__page',
377
+ element: <div>Admin page shell</div>,
378
+ },
379
+ ],
380
+ },
381
+ ],
382
+ {
383
+ initialEntries: ['/admin/1'],
384
+ },
385
+ );
386
+
387
+ try {
388
+ render(<RouterProvider router={router} />);
389
+
390
+ await waitFor(() => {
391
+ expect(router.state.location.pathname).toBe('/admin/flow-page-1');
392
+ });
393
+ expect(await screen.findByText('Admin page shell')).toBeInTheDocument();
394
+ } finally {
395
+ delete modernWindow.__nocobase_modern_client_prefix__;
396
+ }
397
+ });
398
+
399
+ it('should prefer schema uid routes before resolving numeric group ids', async () => {
400
+ const modernWindow = window as Window & { __nocobase_modern_client_prefix__?: string };
401
+ modernWindow.__nocobase_modern_client_prefix__ = 'v2';
402
+ const engine = new FlowEngine();
403
+ const routeRepository = {
404
+ activateLayout: vi.fn(() => vi.fn()),
405
+ ensureAccessibleLoaded: vi.fn().mockResolvedValue([]),
406
+ isAccessibleLoaded: vi.fn(() => true),
407
+ getRouteBySchemaUid: vi.fn((pageUid: string) =>
408
+ pageUid === '1'
409
+ ? {
410
+ schemaUid: '1',
411
+ type: NocoBaseDesktopRouteType.flowPage,
412
+ }
413
+ : undefined,
414
+ ),
415
+ getRouteById: vi.fn((routeId: string) =>
416
+ routeId === '1'
417
+ ? {
418
+ id: 1,
419
+ type: NocoBaseDesktopRouteType.group,
420
+ children: [
421
+ {
422
+ schemaUid: 'flow-page-1',
423
+ type: NocoBaseDesktopRouteType.flowPage,
424
+ },
425
+ ],
426
+ }
427
+ : undefined,
428
+ ),
429
+ listAccessible: vi.fn(() => []),
430
+ };
431
+ engine.context.defineProperty('routeRepository', {
432
+ value: routeRepository,
433
+ });
434
+ engine.context.defineProperty('app', {
435
+ value: {
436
+ getPublicPath: () => '/apps/demo/v2/',
437
+ router: {
438
+ getBasename: () => '/apps/demo/v2',
439
+ },
440
+ },
441
+ });
442
+
443
+ const model = {
444
+ layout: {
445
+ routeName: 'admin',
446
+ routePath: '/admin',
447
+ uid: 'admin-layout-model',
448
+ },
449
+ } as AdminLayoutModel;
450
+ const router = createMemoryRouter(
451
+ [
452
+ {
453
+ path: '/admin',
454
+ id: 'admin',
455
+ element: (
456
+ <FlowEngineProvider engine={engine}>
457
+ <AdminLayoutEntryGuard model={model}>
458
+ <Outlet />
459
+ </AdminLayoutEntryGuard>
460
+ </FlowEngineProvider>
461
+ ),
462
+ children: [
463
+ {
464
+ path: ':name',
465
+ id: 'admin.__page',
466
+ element: <div>Admin page shell</div>,
467
+ },
468
+ ],
469
+ },
470
+ ],
471
+ {
472
+ initialEntries: ['/admin/1'],
473
+ },
474
+ );
475
+
476
+ try {
477
+ render(<RouterProvider router={router} />);
478
+
479
+ await screen.findByText('Admin page shell');
480
+ expect(router.state.location.pathname).toBe('/admin/1');
481
+ expect(routeRepository.getRouteById).not.toHaveBeenCalled();
482
+ } finally {
483
+ delete modernWindow.__nocobase_modern_client_prefix__;
484
+ }
485
+ });
486
+ });
@@ -13,13 +13,23 @@ import { useLocation, useMatches, useNavigate } from 'react-router-dom';
13
13
  import { NocoBaseDesktopRouteType } from '../../../flow-compat';
14
14
  import { useApp } from '../../../hooks/useApp';
15
15
  import { isLayoutContentRouteName } from '../../../layout-manager/utils';
16
+ import type { LayoutDefinition } from '../../../layout-manager/types';
16
17
  import {
17
18
  findFirstV2LandingRoute,
19
+ getAdminLayoutRoutePath,
18
20
  resolveAdminRouteRuntimeTarget,
21
+ type AdminLayoutRoutePathLike,
19
22
  toRouterNavigationPath,
20
23
  } from './resolveAdminRouteRuntimeTarget';
24
+ import type { AdminLayoutModel } from './AdminLayoutModel';
21
25
 
22
- export const AdminLayoutEntryGuard: FC<{ children: React.ReactNode }> = ({ children }) => {
26
+ type StableAdminEntryLayout = AdminLayoutRoutePathLike &
27
+ Partial<Pick<LayoutDefinition, 'authCheck' | 'routeName' | 'uid'>>;
28
+
29
+ export const AdminLayoutEntryGuard: FC<{ children: React.ReactNode; model?: AdminLayoutModel }> = ({
30
+ children,
31
+ model,
32
+ }) => {
23
33
  const flowEngine = useFlowEngine();
24
34
  const app = useApp();
25
35
  const navigate = useNavigate();
@@ -28,24 +38,44 @@ export const AdminLayoutEntryGuard: FC<{ children: React.ReactNode }> = ({ child
28
38
  const [ready, setReady] = useState(false);
29
39
  const replaceTriggeredRef = useRef(false);
30
40
  const routeRepository = flowEngine.context.routeRepository;
41
+ const rawLayout = model?.layout;
42
+ const layoutUid = rawLayout?.uid;
43
+ const layoutRouteNameValue = rawLayout?.routeName;
44
+ const layoutRoutePathValue = rawLayout?.routePath;
45
+ const layoutAuthCheck = rawLayout?.authCheck;
46
+ const layout = useMemo<StableAdminEntryLayout | undefined>(() => {
47
+ if (!layoutUid && !layoutRouteNameValue && !layoutRoutePathValue && typeof layoutAuthCheck === 'undefined') {
48
+ return undefined;
49
+ }
50
+
51
+ return {
52
+ authCheck: layoutAuthCheck,
53
+ routeName: layoutRouteNameValue,
54
+ routePath: layoutRoutePathValue || '',
55
+ uid: layoutUid,
56
+ };
57
+ }, [layoutAuthCheck, layoutRouteNameValue, layoutRoutePathValue, layoutUid]);
58
+ const layoutRoutePath = getAdminLayoutRoutePath(layout);
59
+ const layoutRouteName = layout?.routeName || 'admin';
60
+ const layoutRuntime = useMemo(() => ({ routePath: layoutRoutePath }), [layoutRoutePath]);
31
61
  const isAdminRoot = useMemo(() => {
32
62
  const pathname = toRouterNavigationPath(location.pathname, app.router.getBasename());
33
- return pathname === '/admin';
34
- }, [app, location.pathname]);
63
+ return pathname === layoutRoutePath;
64
+ }, [app, layoutRoutePath, location.pathname]);
35
65
  const pageUid = useMemo(() => {
36
66
  const lastMatch = matches[matches.length - 1];
37
- if (!isLayoutContentRouteName('admin', lastMatch?.id)) {
67
+ if (!isLayoutContentRouteName(layoutRouteName, lastMatch?.id)) {
38
68
  return '';
39
69
  }
40
- const adminMatch = matches.find((match) => match.id === 'admin');
70
+ const adminMatch = matches.find((match) => match.id === layoutRouteName);
41
71
  return (
42
72
  (lastMatch.params?.name as string) ||
43
73
  parsePathnameToViewParams(location.pathname, {
44
- basePath: adminMatch?.pathname || '/admin',
74
+ basePath: adminMatch?.pathname || layoutRoutePath,
45
75
  })[0]?.viewUid ||
46
76
  ''
47
77
  );
48
- }, [location.pathname, matches]);
78
+ }, [layoutRouteName, layoutRoutePath, location.pathname, matches]);
49
79
 
50
80
  useEffect(() => {
51
81
  replaceTriggeredRef.current = false;
@@ -53,6 +83,7 @@ export const AdminLayoutEntryGuard: FC<{ children: React.ReactNode }> = ({ child
53
83
 
54
84
  useEffect(() => {
55
85
  let active = true;
86
+ const deactivateLayout = routeRepository?.activateLayout?.(layout);
56
87
 
57
88
  const run = async () => {
58
89
  setReady(false);
@@ -85,6 +116,7 @@ export const AdminLayoutEntryGuard: FC<{ children: React.ReactNode }> = ({ child
85
116
  const target = resolveAdminRouteRuntimeTarget({
86
117
  app,
87
118
  route: currentRoute,
119
+ layout: layoutRuntime,
88
120
  location: {
89
121
  pathname: window.location.pathname,
90
122
  search: window.location.search,
@@ -100,6 +132,26 @@ export const AdminLayoutEntryGuard: FC<{ children: React.ReactNode }> = ({ child
100
132
  }
101
133
  }
102
134
 
135
+ const groupRoute = currentRoute ? undefined : routeRepository?.getRouteById?.(pageUid);
136
+ if (!currentRoute && groupRoute?.type === NocoBaseDesktopRouteType.group) {
137
+ const target = resolveAdminRouteRuntimeTarget({
138
+ app,
139
+ route: groupRoute,
140
+ layout: layoutRuntime,
141
+ });
142
+
143
+ if (target.runtimePath) {
144
+ replaceTriggeredRef.current = true;
145
+ if (target.navigationMode === 'document') {
146
+ window.location.replace(target.runtimePath);
147
+ return;
148
+ }
149
+
150
+ navigate(toRouterNavigationPath(target.runtimePath, app.router.getBasename()), { replace: true });
151
+ return;
152
+ }
153
+ }
154
+
103
155
  if (active) {
104
156
  setReady(true);
105
157
  }
@@ -117,6 +169,7 @@ export const AdminLayoutEntryGuard: FC<{ children: React.ReactNode }> = ({ child
117
169
  const target = resolveAdminRouteRuntimeTarget({
118
170
  app,
119
171
  route: firstAccessibleRoute,
172
+ layout: layoutRuntime,
120
173
  });
121
174
 
122
175
  if (!target.runtimePath) {
@@ -135,15 +188,18 @@ export const AdminLayoutEntryGuard: FC<{ children: React.ReactNode }> = ({ child
135
188
  navigate(toRouterNavigationPath(target.runtimePath, app.router.getBasename()), { replace: true });
136
189
  };
137
190
 
138
- void run();
191
+ run();
139
192
 
140
193
  return () => {
141
194
  active = false;
195
+ deactivateLayout?.();
142
196
  };
143
197
  }, [
144
198
  app,
145
199
  flowEngine,
146
200
  isAdminRoot,
201
+ layout,
202
+ layoutRuntime,
147
203
  location.hash,
148
204
  location.pathname,
149
205
  location.search,
@@ -17,6 +17,7 @@ import {
17
17
  getAdminLayoutMenuMovePositionOptions,
18
18
  } from './AdminLayoutMenuUtils';
19
19
  import { getFlowPageMenuSchema, getPageMenuSchema, isVariable } from './AdminLayoutCompat';
20
+ import { joinAdminLayoutRoutePath, type AdminLayoutRoutePathLike } from './resolveAdminRouteRuntimeTarget';
20
21
 
21
22
  const buildLinkSettingSchema = (t: (title: any) => any) => ({
22
23
  href: {
@@ -298,6 +299,7 @@ export const matchesRoutePath = (
298
299
  route: NocoBaseDesktopRoute | undefined,
299
300
  pathname: string,
300
301
  basename = '/',
302
+ layout?: AdminLayoutRoutePathLike | null,
301
303
  ): boolean => {
302
304
  if (!route) {
303
305
  return false;
@@ -310,8 +312,8 @@ export const matchesRoutePath = (
310
312
  : pathname;
311
313
 
312
314
  const candidates = [
313
- route.id != null ? `/admin/${route.id}` : null,
314
- route.schemaUid ? `/admin/${route.schemaUid}` : null,
315
+ route.id != null ? joinAdminLayoutRoutePath(layout, route.id) : null,
316
+ route.schemaUid ? joinAdminLayoutRoutePath(layout, route.schemaUid) : null,
315
317
  ].filter(Boolean) as string[];
316
318
 
317
319
  if (
@@ -321,7 +323,7 @@ export const matchesRoutePath = (
321
323
  }
322
324
 
323
325
  return Array.isArray(route.children)
324
- ? route.children.some((child) => matchesRoutePath(child, normalizedPathname, '/'))
326
+ ? route.children.some((child) => matchesRoutePath(child, normalizedPathname, '/', layout))
325
327
  : false;
326
328
  };
327
329