@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.
- package/es/RouteRepository.d.ts +21 -6
- package/es/components/form/JsonTextArea.d.ts +2 -1
- package/es/components/form/VariableJsonTextArea.d.ts +19 -0
- package/es/components/form/index.d.ts +1 -0
- package/es/components/index.d.ts +1 -0
- package/es/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.d.ts +2 -0
- package/es/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.d.ts +2 -1
- package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +2 -0
- package/es/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.d.ts +3 -2
- package/es/flow/admin-shell/admin-layout/AdminLayoutSlotModels.d.ts +4 -0
- package/es/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.d.ts +6 -0
- package/es/flow/components/FieldAssignRulesEditor.d.ts +3 -1
- package/es/flow/models/base/GridModel.d.ts +1 -1
- package/es/flow/models/blocks/filter-form/FilterFormBlockModel.d.ts +5 -0
- package/es/flow/models/blocks/form/FormBlockModel.d.ts +4 -0
- package/es/flow/models/blocks/form/value-runtime/rules.d.ts +21 -0
- package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +21 -0
- package/es/flow/models/blocks/form/value-runtime/types.d.ts +2 -2
- package/es/flow-compat/FieldValidation.d.ts +2 -1
- package/es/index.d.ts +2 -1
- package/es/index.mjs +143 -117
- package/lib/index.js +148 -122
- package/package.json +7 -7
- package/src/RouteRepository.ts +126 -24
- package/src/__tests__/RouteRepository.test.ts +216 -0
- package/src/__tests__/exports.test.ts +16 -0
- package/src/__tests__/settings-center.test.tsx +33 -0
- package/src/components/form/JsonTextArea.tsx +21 -11
- package/src/components/form/VariableJsonTextArea.tsx +175 -0
- package/src/components/form/__tests__/JsonTextArea.test.tsx +43 -0
- package/src/components/form/__tests__/VariableJsonTextArea.test.tsx +86 -0
- package/src/components/form/index.tsx +1 -0
- package/src/components/index.ts +1 -0
- package/src/flow/__tests__/FlowRoute.test.tsx +443 -5
- package/src/flow/actions/__tests__/fieldLinkageRules.scopeDepth.test.ts +150 -1
- package/src/flow/actions/__tests__/linkageAssignField.legacy.test.ts +135 -0
- package/src/flow/actions/__tests__/linkageRules.hiddenSync.restore.test.ts +19 -5
- package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +92 -0
- package/src/flow/actions/customVariable.tsx +1 -2
- package/src/flow/actions/linkageRules.tsx +122 -34
- package/src/flow/actions/runjs.tsx +2 -14
- package/src/flow/actions/validation.tsx +62 -30
- package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +33 -10
- package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.test.tsx +310 -0
- package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +44 -8
- package/src/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.ts +5 -3
- package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +61 -9
- package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +71 -8
- package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +1 -1
- package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +21 -8
- package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +188 -0
- package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +99 -0
- package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +166 -2
- package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +20 -0
- package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +33 -5
- package/src/flow/components/DefaultValue.tsx +1 -2
- package/src/flow/components/FieldAssignRulesEditor.tsx +62 -12
- package/src/flow/components/FlowRoute.tsx +56 -11
- package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +81 -4
- package/src/flow/components/code-editor/__tests__/useCodeRunner.test.tsx +2 -17
- package/src/flow/components/code-editor/hooks/useCodeRunner.ts +2 -14
- package/src/flow/components/code-editor/runjsDiagnostics.ts +8 -45
- package/src/flow/models/actions/JSActionModel.tsx +2 -7
- package/src/flow/models/actions/JSCollectionActionModel.tsx +2 -7
- package/src/flow/models/actions/JSItemActionModel.tsx +2 -7
- package/src/flow/models/actions/JSRecordActionModel.tsx +2 -7
- package/src/flow/models/base/ActionModel.tsx +12 -1
- package/src/flow/models/base/GridModel.tsx +38 -7
- package/src/flow/models/base/PageModel/PageModel.tsx +4 -1
- package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +28 -4
- package/src/flow/models/base/__tests__/ActionModel.test.ts +83 -0
- package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +239 -1
- package/src/flow/models/base/__tests__/transformRowsToSingleColumn.test.ts +48 -0
- package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +31 -3
- package/src/flow/models/blocks/filter-form/FilterFormJSActionModel.tsx +2 -7
- package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +77 -0
- package/src/flow/models/blocks/form/EditFormModel.tsx +1 -0
- package/src/flow/models/blocks/form/FormBlockModel.tsx +7 -0
- package/src/flow/models/blocks/form/JSFormActionModel.tsx +2 -7
- package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +17 -0
- package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +880 -102
- package/src/flow/models/blocks/form/value-runtime/rules.ts +445 -13
- package/src/flow/models/blocks/form/value-runtime/runtime.ts +257 -13
- package/src/flow/models/blocks/form/value-runtime/types.ts +2 -2
- package/src/flow/models/blocks/js-block/JSBlock.tsx +2 -7
- package/src/flow/models/blocks/table/JSColumnModel.tsx +1 -9
- package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +1 -1
- package/src/flow/models/blocks/table/TableBlockModel.tsx +13 -3
- package/src/flow/models/blocks/table/__tests__/TableActionsColumnModel.test.tsx +54 -0
- package/src/flow/models/blocks/table/__tests__/TableBlockModel.mobileSettingsButtons.test.tsx +78 -0
- package/src/flow/models/fields/AssociationFieldModel/AssociationFieldModel.tsx +1 -1
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +29 -6
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +13 -1
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableFieldModel.reset.test.ts +180 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +6 -4
- package/src/flow/models/fields/AssociationFieldModel/__tests__/AssociationFieldModel.updateAssociation.test.ts +72 -0
- package/src/flow/models/fields/InputFieldModel.tsx +14 -22
- package/src/flow/models/fields/JSEditableFieldModel.tsx +2 -11
- package/src/flow/models/fields/JSFieldModel.tsx +2 -7
- package/src/flow/models/fields/JSItemModel.tsx +2 -14
- package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +17 -0
- package/src/flow/models/topbar/TopbarActionModel.tsx +93 -10
- package/src/flow-compat/FieldValidation.tsx +122 -60
- package/src/flow-compat/Popover.tsx +43 -4
- package/src/flow-compat/__tests__/Popover.test.tsx +34 -0
- package/src/index.ts +8 -1
- package/src/layout-manager/__tests__/LayoutRoute.test.tsx +41 -1
- package/src/settings-center/AdminSettingsLayout.tsx +3 -0
- 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
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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:
|
|
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 = (
|
|
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 ?
|
|
324
|
-
route.schemaUid ?
|
|
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)
|
|
336
|
+
return Array.isArray(route.children)
|
|
337
|
+
? route.children.some((child) => matchesRoutePath(child, pathname, layout))
|
|
338
|
+
: false;
|
|
332
339
|
};
|
|
333
340
|
|
|
334
|
-
const findSelectedTopGroupRoute = (
|
|
335
|
-
|
|
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
|
|
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
|
-
|
|
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 ===
|
|
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(
|
|
67
|
+
if (!isLayoutContentRouteName(layoutRouteName, lastMatch?.id)) {
|
|
38
68
|
return '';
|
|
39
69
|
}
|
|
40
|
-
const adminMatch = matches.find((match) => match.id ===
|
|
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 ||
|
|
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
|
-
|
|
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 ?
|
|
314
|
-
route.schemaUid ?
|
|
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
|
|