@nocobase/client-v2 2.2.0-beta.5 → 2.2.0-beta.6

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 (109) hide show
  1. package/es/RouteRepository.d.ts +21 -6
  2. package/es/components/form/JsonTextArea.d.ts +2 -1
  3. package/es/components/form/VariableJsonTextArea.d.ts +19 -0
  4. package/es/components/form/index.d.ts +1 -0
  5. package/es/components/index.d.ts +1 -0
  6. package/es/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.d.ts +2 -0
  7. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.d.ts +2 -1
  8. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +2 -0
  9. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.d.ts +3 -2
  10. package/es/flow/admin-shell/admin-layout/AdminLayoutSlotModels.d.ts +4 -0
  11. package/es/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.d.ts +6 -0
  12. package/es/flow/components/FieldAssignRulesEditor.d.ts +3 -1
  13. package/es/flow/models/base/GridModel.d.ts +1 -1
  14. package/es/flow/models/blocks/filter-form/FilterFormBlockModel.d.ts +5 -0
  15. package/es/flow/models/blocks/form/FormBlockModel.d.ts +4 -0
  16. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +21 -0
  17. package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +21 -0
  18. package/es/flow/models/blocks/form/value-runtime/types.d.ts +2 -2
  19. package/es/flow-compat/FieldValidation.d.ts +2 -1
  20. package/es/index.d.ts +2 -1
  21. package/es/index.mjs +143 -117
  22. package/lib/index.js +148 -122
  23. package/package.json +7 -7
  24. package/src/RouteRepository.ts +126 -24
  25. package/src/__tests__/RouteRepository.test.ts +216 -0
  26. package/src/__tests__/exports.test.ts +16 -0
  27. package/src/__tests__/settings-center.test.tsx +33 -0
  28. package/src/components/form/JsonTextArea.tsx +21 -11
  29. package/src/components/form/VariableJsonTextArea.tsx +175 -0
  30. package/src/components/form/__tests__/JsonTextArea.test.tsx +43 -0
  31. package/src/components/form/__tests__/VariableJsonTextArea.test.tsx +86 -0
  32. package/src/components/form/index.tsx +1 -0
  33. package/src/components/index.ts +1 -0
  34. package/src/flow/__tests__/FlowRoute.test.tsx +443 -5
  35. package/src/flow/actions/__tests__/fieldLinkageRules.scopeDepth.test.ts +150 -1
  36. package/src/flow/actions/__tests__/linkageAssignField.legacy.test.ts +135 -0
  37. package/src/flow/actions/__tests__/linkageRules.hiddenSync.restore.test.ts +19 -5
  38. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +92 -0
  39. package/src/flow/actions/customVariable.tsx +1 -2
  40. package/src/flow/actions/linkageRules.tsx +122 -34
  41. package/src/flow/actions/runjs.tsx +2 -14
  42. package/src/flow/actions/validation.tsx +62 -30
  43. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +33 -10
  44. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.test.tsx +310 -0
  45. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +44 -8
  46. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.ts +5 -3
  47. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +61 -9
  48. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +71 -8
  49. package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +1 -1
  50. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +21 -8
  51. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +188 -0
  52. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +99 -0
  53. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +166 -2
  54. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +20 -0
  55. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +33 -5
  56. package/src/flow/components/DefaultValue.tsx +1 -2
  57. package/src/flow/components/FieldAssignRulesEditor.tsx +62 -12
  58. package/src/flow/components/FlowRoute.tsx +56 -11
  59. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +81 -4
  60. package/src/flow/components/code-editor/__tests__/useCodeRunner.test.tsx +2 -17
  61. package/src/flow/components/code-editor/hooks/useCodeRunner.ts +2 -14
  62. package/src/flow/components/code-editor/runjsDiagnostics.ts +8 -45
  63. package/src/flow/models/actions/JSActionModel.tsx +2 -7
  64. package/src/flow/models/actions/JSCollectionActionModel.tsx +2 -7
  65. package/src/flow/models/actions/JSItemActionModel.tsx +2 -7
  66. package/src/flow/models/actions/JSRecordActionModel.tsx +2 -7
  67. package/src/flow/models/base/ActionModel.tsx +12 -1
  68. package/src/flow/models/base/GridModel.tsx +38 -7
  69. package/src/flow/models/base/PageModel/PageModel.tsx +4 -1
  70. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +28 -4
  71. package/src/flow/models/base/__tests__/ActionModel.test.ts +83 -0
  72. package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +239 -1
  73. package/src/flow/models/base/__tests__/transformRowsToSingleColumn.test.ts +48 -0
  74. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +31 -3
  75. package/src/flow/models/blocks/filter-form/FilterFormJSActionModel.tsx +2 -7
  76. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +77 -0
  77. package/src/flow/models/blocks/form/EditFormModel.tsx +1 -0
  78. package/src/flow/models/blocks/form/FormBlockModel.tsx +7 -0
  79. package/src/flow/models/blocks/form/JSFormActionModel.tsx +2 -7
  80. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +17 -0
  81. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +880 -102
  82. package/src/flow/models/blocks/form/value-runtime/rules.ts +445 -13
  83. package/src/flow/models/blocks/form/value-runtime/runtime.ts +257 -13
  84. package/src/flow/models/blocks/form/value-runtime/types.ts +2 -2
  85. package/src/flow/models/blocks/js-block/JSBlock.tsx +2 -7
  86. package/src/flow/models/blocks/table/JSColumnModel.tsx +1 -9
  87. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +1 -1
  88. package/src/flow/models/blocks/table/TableBlockModel.tsx +13 -3
  89. package/src/flow/models/blocks/table/__tests__/TableActionsColumnModel.test.tsx +54 -0
  90. package/src/flow/models/blocks/table/__tests__/TableBlockModel.mobileSettingsButtons.test.tsx +78 -0
  91. package/src/flow/models/fields/AssociationFieldModel/AssociationFieldModel.tsx +1 -1
  92. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +29 -6
  93. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +13 -1
  94. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableFieldModel.reset.test.ts +180 -0
  95. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +6 -4
  96. package/src/flow/models/fields/AssociationFieldModel/__tests__/AssociationFieldModel.updateAssociation.test.ts +72 -0
  97. package/src/flow/models/fields/InputFieldModel.tsx +14 -22
  98. package/src/flow/models/fields/JSEditableFieldModel.tsx +2 -11
  99. package/src/flow/models/fields/JSFieldModel.tsx +2 -7
  100. package/src/flow/models/fields/JSItemModel.tsx +2 -14
  101. package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +17 -0
  102. package/src/flow/models/topbar/TopbarActionModel.tsx +93 -10
  103. package/src/flow-compat/FieldValidation.tsx +122 -60
  104. package/src/flow-compat/Popover.tsx +43 -4
  105. package/src/flow-compat/__tests__/Popover.test.tsx +34 -0
  106. package/src/index.ts +8 -1
  107. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +41 -1
  108. package/src/settings-center/AdminSettingsLayout.tsx +3 -0
  109. package/src/settings-center/plugin-manager/index.tsx +3 -0
@@ -10,6 +10,56 @@
10
10
  import { defineAction, jioToJoiSchema, tExpr } from '@nocobase/flow-engine';
11
11
  import { FieldValidation } from '../../flow-compat';
12
12
 
13
+ type ValidationType = 'string' | 'number' | 'array' | 'boolean' | 'any';
14
+
15
+ interface ValidationRule {
16
+ name: string;
17
+ args?: any;
18
+ }
19
+
20
+ interface ValidationData {
21
+ type: ValidationType;
22
+ rules?: ValidationRule[];
23
+ }
24
+
25
+ interface ValidationContext {
26
+ model: {
27
+ props: {
28
+ label?: string;
29
+ };
30
+ };
31
+ t: (key: string, options?: Record<string, unknown>) => string;
32
+ }
33
+
34
+ function buildValidationRules(ctx: ValidationContext, validation: ValidationData) {
35
+ const rules = [];
36
+ const schema = jioToJoiSchema(validation);
37
+ const label = ctx.model.props.label;
38
+ rules.push({
39
+ validator: (_: unknown, value: unknown) => {
40
+ const { error } = schema.validate(value, {
41
+ abortEarly: false,
42
+ });
43
+
44
+ if (error) {
45
+ const messages = error.details.map((d: { type: string; context?: Record<string, unknown> }) => {
46
+ return ctx.t(`${d.type}`, {
47
+ ...d.context,
48
+ ns: 'data-source-main',
49
+ label,
50
+ });
51
+ });
52
+ const div = document.createElement('div');
53
+ div.innerHTML = messages.join('; ');
54
+ return Promise.reject(div.textContent);
55
+ }
56
+
57
+ return Promise.resolve();
58
+ },
59
+ });
60
+ return rules;
61
+ }
62
+
13
63
  export const validation = defineAction({
14
64
  title: tExpr('Validation'),
15
65
  name: 'validation',
@@ -29,6 +79,7 @@ export const validation = defineAction({
29
79
  type: targetInterface.validationType,
30
80
  availableValidationOptions: [...new Set(targetInterface.availableValidationOptions)],
31
81
  excludeValidationOptions: [...new Set(targetInterface.excludeValidationOptions)],
82
+ inheritedValue: ctx.model.collectionField.validation,
32
83
  isAssociation: targetInterface.isAssociation,
33
84
  },
34
85
  },
@@ -36,40 +87,21 @@ export const validation = defineAction({
36
87
  },
37
88
  handler(ctx, params) {
38
89
  if (params.validation) {
39
- const rules = [];
40
- const schema = jioToJoiSchema(params.validation);
41
- const label = ctx.model.props.label;
42
- rules.push({
43
- validator: (_, value) => {
44
- const { error } = schema.validate(value, {
45
- abortEarly: false,
46
- });
47
-
48
- if (error) {
49
- const messages = error.details.map((d) => {
50
- return ctx.t(`${d.type}`, {
51
- ...d.context,
52
- ns: 'data-source-main',
53
- label,
54
- });
55
- });
56
- const div = document.createElement('div');
57
- div.innerHTML = messages.join('; ');
58
- return Promise.reject(div.textContent);
59
- }
60
-
61
- return Promise.resolve();
62
- },
63
- });
64
- const hasRequiredInCollection = params.validation.rules.some((rule) => rule.name === 'required');
65
- if (hasRequiredInCollection) {
90
+ const collectionValidation = ctx.model.collectionField?.validation;
91
+ const collectionRules = ctx.model.collectionField?.getComponentProps?.().rules || [];
92
+ const uiRules = params.validation.rules?.length
93
+ ? buildValidationRules(ctx as unknown as ValidationContext, params.validation)
94
+ : [];
95
+ const hasRequiredInValidation = [collectionValidation, params.validation].some(
96
+ (validation) => validation?.rules?.some((rule) => rule.name === 'required'),
97
+ );
98
+ if (hasRequiredInValidation) {
66
99
  ctx.model.setProps({
67
- required: hasRequiredInCollection,
100
+ required: hasRequiredInValidation,
68
101
  });
69
102
  }
70
- console.log(rules);
71
103
  ctx.model.setProps({
72
- rules,
104
+ rules: [...collectionRules, ...uiRules],
73
105
  validation: params.validation,
74
106
  });
75
107
  }
@@ -42,6 +42,7 @@ import {
42
42
  FLOW_SETTINGS_PREFERENCE_STORAGE_KEY,
43
43
  readFlowSettingsPreference,
44
44
  } from './flowSettingsPreference';
45
+ import { joinAdminLayoutRoutePath, type AdminLayoutRoutePathLike } from './resolveAdminRouteRuntimeTarget';
45
46
  import { useAppListRender } from './AppListRender';
46
47
 
47
48
  const className1 = css`
@@ -314,25 +315,37 @@ const DesignerButtonMenuItem: FC<{ item: AdminLayoutMenuNode; fallbackParentRout
314
315
  );
315
316
  };
316
317
 
317
- const matchesRoutePath = (route: NocoBaseDesktopRoute | undefined, pathname: string): boolean => {
318
+ const matchesRoutePath = (
319
+ route: NocoBaseDesktopRoute | undefined,
320
+ pathname: string,
321
+ layout?: AdminLayoutRoutePathLike | null,
322
+ ): boolean => {
318
323
  if (!route) {
319
324
  return false;
320
325
  }
321
326
 
322
327
  const candidates = [
323
- route.id != null ? `/admin/${route.id}` : null,
324
- route.schemaUid ? `/admin/${route.schemaUid}` : null,
328
+ route.id != null ? joinAdminLayoutRoutePath(layout, route.id) : null,
329
+ route.schemaUid ? joinAdminLayoutRoutePath(layout, route.schemaUid) : null,
325
330
  ].filter(Boolean) as string[];
326
331
 
327
332
  if (candidates.some((candidate) => pathname === candidate || pathname.startsWith(`${candidate}/`))) {
328
333
  return true;
329
334
  }
330
335
 
331
- return Array.isArray(route.children) ? route.children.some((child) => matchesRoutePath(child, pathname)) : false;
336
+ return Array.isArray(route.children)
337
+ ? route.children.some((child) => matchesRoutePath(child, pathname, layout))
338
+ : false;
332
339
  };
333
340
 
334
- const findSelectedTopGroupRoute = (routes: NocoBaseDesktopRoute[], pathname: string) => {
335
- return routes.find((route) => route.type === NocoBaseDesktopRouteType.group && matchesRoutePath(route, pathname));
341
+ const findSelectedTopGroupRoute = (
342
+ routes: NocoBaseDesktopRoute[],
343
+ pathname: string,
344
+ layout?: AdminLayoutRoutePathLike | null,
345
+ ) => {
346
+ return routes.find(
347
+ (route) => route.type === NocoBaseDesktopRouteType.group && matchesRoutePath(route, pathname, layout),
348
+ );
336
349
  };
337
350
 
338
351
  const renderMenuNodeWithModel = (
@@ -376,6 +389,11 @@ export const AdminLayoutComponent = observer((props: any) => {
376
389
  const { token } = antdTheme.useToken();
377
390
  const customToken = token as CustomToken;
378
391
  const isMobileLayout = !!adminLayoutModel?.isMobileLayout;
392
+ const adminLayoutRoutePath = adminLayoutModel?.layout?.routePath;
393
+ const adminLayoutRoutePathLike = useMemo<AdminLayoutRoutePathLike | undefined>(
394
+ () => (adminLayoutRoutePath ? { routePath: adminLayoutRoutePath } : undefined),
395
+ [adminLayoutRoutePath],
396
+ );
379
397
  const menuRouteRefreshVersion = adminLayoutModel?.menuRouteRefreshVersion || 0;
380
398
  const isMobileSider = isMobileLayout;
381
399
  const [collapsed, setCollapsed] = useState(isMobileSider);
@@ -403,8 +421,8 @@ export const AdminLayoutComponent = observer((props: any) => {
403
421
  [adminLayoutModel],
404
422
  );
405
423
  const selectedTopGroupRoute = useMemo(
406
- () => findSelectedTopGroupRoute(allAccessRoutes, location.pathname),
407
- [allAccessRoutes, location.pathname],
424
+ () => findSelectedTopGroupRoute(allAccessRoutes, location.pathname, adminLayoutRoutePathLike),
425
+ [adminLayoutRoutePathLike, allAccessRoutes, location.pathname],
408
426
  );
409
427
 
410
428
  const handleMenuDragEnd = useCallback(
@@ -458,6 +476,7 @@ export const AdminLayoutComponent = observer((props: any) => {
458
476
 
459
477
  useEffect(() => {
460
478
  const routeRepository = flowEngine.context.routeRepository;
479
+ const deactivateLayout = routeRepository?.activateLayout?.(adminLayoutModel?.layout);
461
480
  const subscriber = () => {
462
481
  const updatedRoutes = routeRepository?.listAccessible() || [];
463
482
  setAllAccessRoutes(updatedRoutes);
@@ -470,8 +489,9 @@ export const AdminLayoutComponent = observer((props: any) => {
470
489
 
471
490
  return () => {
472
491
  routeRepository?.unsubscribe(subscriber);
492
+ deactivateLayout?.();
473
493
  };
474
- }, [flowEngine]);
494
+ }, [adminLayoutModel, adminLayoutModel?.layout?.uid, flowEngine]);
475
495
 
476
496
  useEffect(() => {
477
497
  if (typeof window === 'undefined') {
@@ -697,7 +717,10 @@ export const AdminLayoutComponent = observer((props: any) => {
697
717
  <SetIsMobileLayout isMobile={isMobile} model={adminLayoutModel}>
698
718
  <ConfigProvider theme={isMobile ? mobileTheme : theme}>
699
719
  <GlobalStyle />
700
- <AdminLayoutContent onContentElementChange={handleLayoutContentElementChange} />
720
+ <AdminLayoutContent
721
+ layout={adminLayoutModel?.layout}
722
+ onContentElementChange={handleLayoutContentElementChange}
723
+ />
701
724
  </ConfigProvider>
702
725
  </SetIsMobileLayout>
703
726
  );
@@ -0,0 +1,310 @@
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, RouterProvider } from 'react-router-dom';
14
+ import { describe, expect, it, vi } from 'vitest';
15
+ import { RouteRepository } from '../../../RouteRepository';
16
+ import { AdminLayoutEntryGuard } from './AdminLayoutEntryGuard';
17
+ import type { AdminLayoutModel } from './AdminLayoutModel';
18
+
19
+ describe('AdminLayoutEntryGuard', () => {
20
+ it('should ensure accessible routes for the active layout before resolving the landing route', async () => {
21
+ const engine = new FlowEngine();
22
+ const request = vi.fn().mockResolvedValue({
23
+ data: {
24
+ data: [],
25
+ },
26
+ });
27
+ const routeRepository = new RouteRepository({
28
+ api: {
29
+ request,
30
+ resource: vi.fn(),
31
+ },
32
+ } as never);
33
+ routeRepository.setRoutes([], 'admin-layout-model');
34
+ const deactivateLayout = routeRepository.activateLayout({
35
+ uid: 'mobile-layout-model',
36
+ });
37
+ engine.context.defineProperty('routeRepository', {
38
+ value: routeRepository,
39
+ });
40
+ engine.context.defineProperty('app', {
41
+ value: {
42
+ router: {
43
+ getBasename: () => '',
44
+ },
45
+ },
46
+ });
47
+
48
+ const model = {
49
+ layout: {
50
+ routeName: 'mobile',
51
+ routePath: '/mobile',
52
+ uid: 'mobile-layout-model',
53
+ },
54
+ } as AdminLayoutModel;
55
+ const router = createMemoryRouter(
56
+ [
57
+ {
58
+ path: '/mobile',
59
+ id: 'mobile',
60
+ element: (
61
+ <FlowEngineProvider engine={engine}>
62
+ <AdminLayoutEntryGuard model={model}>
63
+ <div>Mobile layout shell</div>
64
+ </AdminLayoutEntryGuard>
65
+ </FlowEngineProvider>
66
+ ),
67
+ },
68
+ ],
69
+ {
70
+ initialEntries: ['/mobile'],
71
+ },
72
+ );
73
+
74
+ render(<RouterProvider router={router} />);
75
+
76
+ await waitFor(() => {
77
+ expect(request).toHaveBeenCalledWith({
78
+ url: '/desktopRoutes:listAccessible',
79
+ params: {
80
+ tree: true,
81
+ sort: 'sort',
82
+ layout: 'mobile-layout-model',
83
+ },
84
+ });
85
+ });
86
+ expect(screen.getByText('Mobile layout shell')).toBeInTheDocument();
87
+ deactivateLayout();
88
+ });
89
+
90
+ it('should activate the guard layout before loading accessible routes', async () => {
91
+ const engine = new FlowEngine();
92
+ const request = vi.fn().mockResolvedValue({
93
+ data: {
94
+ data: [],
95
+ },
96
+ });
97
+ const routeRepository = new RouteRepository({
98
+ api: {
99
+ request,
100
+ resource: vi.fn(),
101
+ },
102
+ } as never);
103
+ engine.context.defineProperty('routeRepository', {
104
+ value: routeRepository,
105
+ });
106
+ engine.context.defineProperty('app', {
107
+ value: {
108
+ router: {
109
+ getBasename: () => '',
110
+ },
111
+ },
112
+ });
113
+
114
+ const model = {
115
+ layout: {
116
+ routeName: 'mobile',
117
+ routePath: '/mobile',
118
+ uid: 'mobile-layout-model',
119
+ },
120
+ } as AdminLayoutModel;
121
+ const router = createMemoryRouter(
122
+ [
123
+ {
124
+ path: '/mobile',
125
+ id: 'mobile',
126
+ element: (
127
+ <FlowEngineProvider engine={engine}>
128
+ <AdminLayoutEntryGuard model={model}>
129
+ <div>Mobile layout shell</div>
130
+ </AdminLayoutEntryGuard>
131
+ </FlowEngineProvider>
132
+ ),
133
+ },
134
+ ],
135
+ {
136
+ initialEntries: ['/mobile'],
137
+ },
138
+ );
139
+
140
+ render(<RouterProvider router={router} />);
141
+
142
+ await waitFor(() => {
143
+ expect(request).toHaveBeenCalledWith({
144
+ url: '/desktopRoutes:listAccessible',
145
+ params: {
146
+ tree: true,
147
+ sort: 'sort',
148
+ layout: 'mobile-layout-model',
149
+ },
150
+ });
151
+ });
152
+ expect(screen.getByText('Mobile layout shell')).toBeInTheDocument();
153
+ });
154
+
155
+ it('should keep guard stable when layout getter returns a new object with the same fields', async () => {
156
+ const engine = new FlowEngine();
157
+ const request = vi.fn().mockResolvedValue({
158
+ data: {
159
+ data: [],
160
+ },
161
+ });
162
+ const routeRepository = new RouteRepository({
163
+ api: {
164
+ request,
165
+ resource: vi.fn(),
166
+ },
167
+ } as never);
168
+ const activateLayout = vi.spyOn(routeRepository, 'activateLayout');
169
+ engine.context.defineProperty('routeRepository', {
170
+ value: routeRepository,
171
+ });
172
+ engine.context.defineProperty('app', {
173
+ value: {
174
+ router: {
175
+ getBasename: () => '',
176
+ },
177
+ },
178
+ });
179
+
180
+ const model = {
181
+ get layout() {
182
+ return {
183
+ authCheck: true,
184
+ routeName: 'admin',
185
+ routePath: '/admin',
186
+ uid: 'admin-layout-model',
187
+ };
188
+ },
189
+ } as AdminLayoutModel;
190
+ let forceRerender = () => {};
191
+ const GuardShell = () => {
192
+ const [, setVersion] = React.useState(0);
193
+ forceRerender = () => setVersion((version) => version + 1);
194
+ return (
195
+ <AdminLayoutEntryGuard model={model}>
196
+ <div>Admin layout shell</div>
197
+ </AdminLayoutEntryGuard>
198
+ );
199
+ };
200
+ const router = createMemoryRouter(
201
+ [
202
+ {
203
+ path: '/admin',
204
+ id: 'admin',
205
+ element: (
206
+ <FlowEngineProvider engine={engine}>
207
+ <GuardShell />
208
+ </FlowEngineProvider>
209
+ ),
210
+ },
211
+ ],
212
+ {
213
+ initialEntries: ['/admin'],
214
+ },
215
+ );
216
+
217
+ render(<RouterProvider router={router} />);
218
+
219
+ await waitFor(() => {
220
+ expect(screen.getByText('Admin layout shell')).toBeInTheDocument();
221
+ });
222
+ expect(activateLayout).toHaveBeenCalledTimes(1);
223
+
224
+ await act(async () => {
225
+ forceRerender();
226
+ });
227
+
228
+ expect(activateLayout).toHaveBeenCalledTimes(1);
229
+ expect(request).toHaveBeenCalledTimes(1);
230
+ });
231
+
232
+ it('should reactivate the guard when stable layout fields change', async () => {
233
+ const engine = new FlowEngine();
234
+ const request = vi.fn().mockResolvedValue({
235
+ data: {
236
+ data: [],
237
+ },
238
+ });
239
+ const routeRepository = new RouteRepository({
240
+ api: {
241
+ request,
242
+ resource: vi.fn(),
243
+ },
244
+ } as never);
245
+ const activateLayout = vi.spyOn(routeRepository, 'activateLayout');
246
+ engine.context.defineProperty('routeRepository', {
247
+ value: routeRepository,
248
+ });
249
+ engine.context.defineProperty('app', {
250
+ value: {
251
+ router: {
252
+ getBasename: () => '',
253
+ },
254
+ },
255
+ });
256
+
257
+ let layoutUid = 'admin-layout-model';
258
+ const model = {
259
+ get layout() {
260
+ return {
261
+ authCheck: true,
262
+ routeName: 'admin',
263
+ routePath: '/admin',
264
+ uid: layoutUid,
265
+ };
266
+ },
267
+ } as AdminLayoutModel;
268
+ let forceRerender = () => {};
269
+ const GuardShell = () => {
270
+ const [, setVersion] = React.useState(0);
271
+ forceRerender = () => setVersion((version) => version + 1);
272
+ return (
273
+ <AdminLayoutEntryGuard model={model}>
274
+ <div>Admin layout shell</div>
275
+ </AdminLayoutEntryGuard>
276
+ );
277
+ };
278
+ const router = createMemoryRouter(
279
+ [
280
+ {
281
+ path: '/admin',
282
+ id: 'admin',
283
+ element: (
284
+ <FlowEngineProvider engine={engine}>
285
+ <GuardShell />
286
+ </FlowEngineProvider>
287
+ ),
288
+ },
289
+ ],
290
+ {
291
+ initialEntries: ['/admin'],
292
+ },
293
+ );
294
+
295
+ render(<RouterProvider router={router} />);
296
+
297
+ await waitFor(() => {
298
+ expect(screen.getByText('Admin layout shell')).toBeInTheDocument();
299
+ });
300
+ expect(activateLayout).toHaveBeenCalledWith(expect.objectContaining({ uid: 'admin-layout-model' }));
301
+
302
+ await act(async () => {
303
+ layoutUid = 'admin-layout-next';
304
+ forceRerender();
305
+ });
306
+
307
+ expect(activateLayout).toHaveBeenCalledTimes(2);
308
+ expect(activateLayout).toHaveBeenLastCalledWith(expect.objectContaining({ uid: 'admin-layout-next' }));
309
+ });
310
+ });
@@ -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,
@@ -117,6 +149,7 @@ export const AdminLayoutEntryGuard: FC<{ children: React.ReactNode }> = ({ child
117
149
  const target = resolveAdminRouteRuntimeTarget({
118
150
  app,
119
151
  route: firstAccessibleRoute,
152
+ layout: layoutRuntime,
120
153
  });
121
154
 
122
155
  if (!target.runtimePath) {
@@ -135,15 +168,18 @@ export const AdminLayoutEntryGuard: FC<{ children: React.ReactNode }> = ({ child
135
168
  navigate(toRouterNavigationPath(target.runtimePath, app.router.getBasename()), { replace: true });
136
169
  };
137
170
 
138
- void run();
171
+ run();
139
172
 
140
173
  return () => {
141
174
  active = false;
175
+ deactivateLayout?.();
142
176
  };
143
177
  }, [
144
178
  app,
145
179
  flowEngine,
146
180
  isAdminRoot,
181
+ layout,
182
+ layoutRuntime,
147
183
  location.hash,
148
184
  location.pathname,
149
185
  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