@nocobase/client-v2 2.1.0-beta.37 → 2.1.0-beta.38
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/Application.d.ts +1 -0
- package/es/BaseApplication.d.ts +3 -0
- package/es/RouterManager.d.ts +1 -0
- package/es/components/KeepAlive.d.ts +22 -0
- package/es/components/RouterBridge.d.ts +9 -0
- package/es/data-source/ExtendCollectionsProvider.d.ts +28 -2
- package/es/flow/FlowPage.d.ts +2 -1
- package/es/flow/admin-shell/AdminLayoutRouteCoordinator.d.ts +8 -40
- package/es/flow/admin-shell/BaseLayoutModel.d.ts +89 -0
- package/es/flow/admin-shell/BaseLayoutRouteCoordinator.d.ts +74 -0
- package/es/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.d.ts +12 -0
- package/es/flow/admin-shell/admin-layout/AdminLayoutModel.d.ts +7 -92
- package/es/flow/admin-shell/admin-layout/index.d.ts +2 -0
- package/es/flow/admin-shell/useAdminLayoutRoutePage.d.ts +2 -2
- package/es/flow/admin-shell/useLayoutRoutePage.d.ts +23 -0
- package/es/flow/components/FlowRoute.d.ts +10 -1
- package/es/flow/index.d.ts +4 -0
- package/es/flow/models/base/PageModel/PageModel.d.ts +3 -1
- package/es/flow/models/blocks/form/FormActionGroupModel.d.ts +1 -0
- package/es/flow/models/blocks/table/TableBlockModel.d.ts +10 -0
- package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.d.ts +1 -1
- package/es/index.d.ts +1 -0
- package/es/index.mjs +484 -437
- package/es/layout-manager/LayoutContentRoute.d.ts +14 -0
- package/es/layout-manager/LayoutManager.d.ts +22 -0
- package/es/layout-manager/LayoutRoute.d.ts +14 -0
- package/es/layout-manager/index.d.ts +13 -0
- package/es/layout-manager/types.d.ts +20 -0
- package/es/layout-manager/utils.d.ts +14 -0
- package/es/settings-center/index.d.ts +1 -1
- package/es/settings-center/plugin-manager/BulkEnableButton.d.ts +15 -0
- package/es/settings-center/plugin-manager/PluginCard.d.ts +15 -0
- package/es/settings-center/plugin-manager/PluginDetail.d.ts +16 -0
- package/es/settings-center/{PluginManagerPage.d.ts → plugin-manager/index.d.ts} +1 -7
- package/es/settings-center/plugin-manager/types.d.ts +34 -0
- package/lib/index.js +484 -437
- package/package.json +8 -7
- package/src/Application.tsx +27 -12
- package/src/BaseApplication.tsx +6 -0
- package/src/PluginSettingsManager.ts +1 -1
- package/src/RouterManager.tsx +17 -1
- package/src/__tests__/PluginSettingsManager.test.ts +41 -2
- package/src/__tests__/app.test.tsx +8 -1
- package/src/__tests__/globalDeps.test.ts +1 -0
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +45 -2
- package/src/__tests__/plugin-manager.test.tsx +177 -0
- package/src/__tests__/settings-center.test.tsx +24 -2
- package/src/components/KeepAlive.tsx +131 -0
- package/src/components/RouterBridge.tsx +28 -4
- package/src/components/__tests__/KeepAlive.test.tsx +63 -0
- package/src/components/__tests__/RouterBridge.test.tsx +27 -0
- package/src/data-source/ExtendCollectionsProvider.tsx +94 -20
- package/src/data-source/__tests__/ExtendCollectionsProvider.test.tsx +264 -0
- package/src/flow/FlowPage.tsx +35 -7
- package/src/flow/__tests__/FlowPage.test.tsx +79 -0
- package/src/flow/__tests__/FlowRoute.test.tsx +529 -2
- package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +191 -0
- package/src/flow/actions/__tests__/openView.subModelKey.test.tsx +33 -0
- package/src/flow/actions/aclCheck.tsx +4 -0
- package/src/flow/actions/aclCheckRefresh.tsx +4 -0
- package/src/flow/actions/dateTimeFormat.tsx +12 -8
- package/src/flow/actions/linkageRules.tsx +122 -0
- package/src/flow/actions/openView.tsx +28 -4
- package/src/flow/admin-shell/AdminLayoutRouteCoordinator.ts +11 -329
- package/src/flow/admin-shell/BaseLayoutModel.tsx +455 -0
- package/src/flow/admin-shell/BaseLayoutRouteCoordinator.ts +502 -0
- package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +547 -3
- package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +4 -4
- package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +160 -0
- package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +0 -12
- package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +28 -201
- package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +11 -2
- package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +1 -26
- package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +149 -27
- package/src/flow/admin-shell/admin-layout/index.ts +2 -0
- package/src/flow/admin-shell/useAdminLayoutRoutePage.ts +10 -26
- package/src/flow/admin-shell/useLayoutRoutePage.ts +61 -0
- package/src/flow/components/AdminLayout.tsx +4 -154
- package/src/flow/components/FlowRoute.tsx +105 -15
- package/src/flow/index.ts +4 -0
- package/src/flow/models/base/ActionModel.tsx +8 -1
- package/src/flow/models/base/PageModel/PageModel.tsx +51 -18
- package/src/flow/models/base/PageModel/RootPageModel.tsx +6 -13
- package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +102 -1
- package/src/flow/models/base/RouteModel.tsx +1 -1
- package/src/flow/models/blocks/form/FormActionGroupModel.tsx +14 -0
- package/src/flow/models/blocks/form/FormItemModel.tsx +8 -1
- package/src/flow/models/blocks/form/__tests__/FormActionGroupModel.test.ts +46 -0
- package/src/flow/models/blocks/form/submitValues.ts +4 -1
- package/src/flow/models/blocks/table/TableBlockModel.tsx +118 -16
- package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowSelection.test.tsx +114 -0
- package/src/flow/models/fields/AssociationFieldModel/SubFormFieldModel.tsx +7 -1
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +1 -1
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +6 -5
- package/src/flow/models/fields/ClickableFieldModel.tsx +9 -1
- package/src/flow/models/fields/DisplayTimeFieldModel.tsx +1 -1
- package/src/flow/models/fields/TimeFieldModel.tsx +1 -1
- package/src/flow/models/fields/__tests__/TimeFieldModel.test.tsx +61 -0
- package/src/flow/models/fields/mobile-components/MobileDatePicker.tsx +19 -3
- package/src/flow/models/fields/mobile-components/__tests__/MobileDatePicker.test.tsx +94 -0
- package/src/flow/models/topbar/TopbarActionModel.tsx +1 -1
- package/src/flow/utils/__tests__/dateTimeFormat.test.ts +91 -0
- package/src/index.ts +1 -0
- package/src/layout-manager/LayoutContentRoute.tsx +90 -0
- package/src/layout-manager/LayoutManager.tsx +185 -0
- package/src/layout-manager/LayoutRoute.tsx +138 -0
- package/src/layout-manager/__tests__/LayoutManager.test.tsx +335 -0
- package/src/layout-manager/__tests__/LayoutRoute.test.tsx +473 -0
- package/src/layout-manager/index.ts +14 -0
- package/src/layout-manager/types.ts +22 -0
- package/src/layout-manager/utils.ts +37 -0
- package/src/nocobase-buildin-plugin/index.tsx +56 -48
- package/src/settings-center/index.ts +1 -1
- package/src/settings-center/plugin-manager/BulkEnableButton.tsx +111 -0
- package/src/settings-center/plugin-manager/PluginCard.tsx +270 -0
- package/src/settings-center/plugin-manager/PluginDetail.tsx +195 -0
- package/src/settings-center/plugin-manager/index.tsx +254 -0
- package/src/settings-center/plugin-manager/types.ts +35 -0
- package/src/settings-center/utils.tsx +8 -1
- package/src/theme/__tests__/globalStyles.test.ts +24 -0
- package/src/theme/globalStyles.ts +10 -0
- package/src/utils/globalDeps.ts +2 -0
- package/src/settings-center/PluginManagerPage.tsx +0 -162
|
@@ -0,0 +1,131 @@
|
|
|
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 { RouteContext } from '@ant-design/pro-layout';
|
|
11
|
+
import _ from 'lodash';
|
|
12
|
+
import React, { createContext, FC, memo, useContext, useRef } from 'react';
|
|
13
|
+
import {
|
|
14
|
+
UNSAFE_DataRouterContext,
|
|
15
|
+
UNSAFE_DataRouterStateContext,
|
|
16
|
+
UNSAFE_LocationContext,
|
|
17
|
+
UNSAFE_RouteContext,
|
|
18
|
+
} from 'react-router-dom';
|
|
19
|
+
|
|
20
|
+
const KeepAliveContext = createContext(true);
|
|
21
|
+
const hidden = { display: 'none' };
|
|
22
|
+
|
|
23
|
+
export const KeepAliveProvider: FC<{ active: boolean; parentActive: boolean }> = memo(
|
|
24
|
+
({ children, active, parentActive }) => {
|
|
25
|
+
const currentLocationContext = useContext(UNSAFE_LocationContext);
|
|
26
|
+
const currentRouteContext = useContext(UNSAFE_RouteContext);
|
|
27
|
+
const currentDataRouterContext = useContext(UNSAFE_DataRouterContext);
|
|
28
|
+
const currentDataRouterStateContext = useContext(UNSAFE_DataRouterStateContext);
|
|
29
|
+
const routeContextValue = useContext(RouteContext);
|
|
30
|
+
|
|
31
|
+
const prevLocationContextRef = useRef(currentLocationContext);
|
|
32
|
+
const prevRouteContextRef = useRef(currentRouteContext);
|
|
33
|
+
const prevDataRouterContextRef = useRef(currentDataRouterContext);
|
|
34
|
+
const prevDataRouterStateContextRef = useRef(currentDataRouterStateContext);
|
|
35
|
+
const prevRouteContextValueRef = useRef(routeContextValue);
|
|
36
|
+
|
|
37
|
+
if (active) {
|
|
38
|
+
prevDataRouterContextRef.current = currentDataRouterContext;
|
|
39
|
+
prevDataRouterStateContextRef.current = currentDataRouterStateContext;
|
|
40
|
+
prevRouteContextValueRef.current = routeContextValue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (
|
|
44
|
+
active &&
|
|
45
|
+
!_.isEqual(_.omit(prevLocationContextRef.current.location, 'key'), _.omit(currentLocationContext.location, 'key'))
|
|
46
|
+
) {
|
|
47
|
+
prevLocationContextRef.current = currentLocationContext;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (active && !_.isEqual(prevRouteContextRef.current, currentRouteContext)) {
|
|
51
|
+
prevRouteContextRef.current = currentRouteContext;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div style={active ? { height: '100%' } : hidden}>
|
|
56
|
+
<RouteContext.Provider value={prevRouteContextValueRef.current}>
|
|
57
|
+
<UNSAFE_DataRouterContext.Provider value={prevDataRouterContextRef.current}>
|
|
58
|
+
<UNSAFE_DataRouterStateContext.Provider value={prevDataRouterStateContextRef.current}>
|
|
59
|
+
<UNSAFE_LocationContext.Provider value={prevLocationContextRef.current}>
|
|
60
|
+
<UNSAFE_RouteContext.Provider value={prevRouteContextRef.current}>
|
|
61
|
+
<KeepAliveContext.Provider value={parentActive === false ? false : active}>
|
|
62
|
+
{children}
|
|
63
|
+
</KeepAliveContext.Provider>
|
|
64
|
+
</UNSAFE_RouteContext.Provider>
|
|
65
|
+
</UNSAFE_LocationContext.Provider>
|
|
66
|
+
</UNSAFE_DataRouterStateContext.Provider>
|
|
67
|
+
</UNSAFE_DataRouterContext.Provider>
|
|
68
|
+
</RouteContext.Provider>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
export const useKeepAlive = () => {
|
|
75
|
+
const active = useContext(KeepAliveContext);
|
|
76
|
+
return { active };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
interface KeepAliveProps {
|
|
80
|
+
uid: string;
|
|
81
|
+
children: (uid: string) => React.ReactNode;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const MINIMUM_CACHED_PAGES = 5;
|
|
85
|
+
const MAXIMUM_CACHED_PAGES = 15;
|
|
86
|
+
|
|
87
|
+
const getMaxPageCount = () => {
|
|
88
|
+
const baseCount = MINIMUM_CACHED_PAGES;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const memory = (navigator as any).deviceMemory;
|
|
92
|
+
if (memory) {
|
|
93
|
+
return Math.min(Math.max(baseCount, memory * 3), MAXIMUM_CACHED_PAGES);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const cores = navigator.hardwareConcurrency;
|
|
97
|
+
if (cores) {
|
|
98
|
+
return cores >= 8 ? MAXIMUM_CACHED_PAGES : cores >= 4 ? 7 : baseCount;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return baseCount;
|
|
102
|
+
} catch (e) {
|
|
103
|
+
return baseCount;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const MAX_RENDERED_PAGE_COUNT = getMaxPageCount();
|
|
108
|
+
|
|
109
|
+
export const KeepAlive: FC<KeepAliveProps> = React.memo(({ children, uid }) => {
|
|
110
|
+
const { active } = useKeepAlive();
|
|
111
|
+
const renderedUidListRef = useRef<string[]>([]);
|
|
112
|
+
|
|
113
|
+
if (!renderedUidListRef.current.includes(uid)) {
|
|
114
|
+
renderedUidListRef.current.push(uid);
|
|
115
|
+
if (renderedUidListRef.current.length > MAX_RENDERED_PAGE_COUNT) {
|
|
116
|
+
renderedUidListRef.current = renderedUidListRef.current.slice(-MAX_RENDERED_PAGE_COUNT);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<>
|
|
122
|
+
{renderedUidListRef.current.map((renderedUid) => (
|
|
123
|
+
<KeepAliveProvider active={renderedUid === uid} key={renderedUid} parentActive={active}>
|
|
124
|
+
{children(renderedUid)}
|
|
125
|
+
</KeepAliveProvider>
|
|
126
|
+
))}
|
|
127
|
+
</>
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
KeepAlive.displayName = 'KeepAlive';
|
|
@@ -7,11 +7,32 @@
|
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import { useLocation, useMatch, useMatches, useParams } from 'react-router-dom';
|
|
10
|
+
import { useEffect } from 'react';
|
|
11
|
+
import { useLocation, useMatches, useParams } from 'react-router-dom';
|
|
13
12
|
import { Application } from '../Application';
|
|
14
13
|
|
|
14
|
+
type LayoutMatchLike = {
|
|
15
|
+
id: string;
|
|
16
|
+
pathname: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type LayoutDefinitionLike = {
|
|
20
|
+
routeName: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function findDeepestLayoutMatch(layouts: LayoutDefinitionLike[] = [], matches: LayoutMatchLike[] = []) {
|
|
24
|
+
const layoutRouteNames = new Set(layouts.map((layout) => layout.routeName));
|
|
25
|
+
|
|
26
|
+
for (let index = matches.length - 1; index >= 0; index -= 1) {
|
|
27
|
+
const match = matches[index];
|
|
28
|
+
if (layoutRouteNames.has(match.id)) {
|
|
29
|
+
return match;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
15
36
|
export function useRouterSync(app: Application) {
|
|
16
37
|
const params = useParams();
|
|
17
38
|
const location = useLocation();
|
|
@@ -20,13 +41,16 @@ export function useRouterSync(app: Application) {
|
|
|
20
41
|
useEffect(() => {
|
|
21
42
|
const last = matches[matches.length - 1];
|
|
22
43
|
if (!last) return;
|
|
44
|
+
const layoutMatch = findDeepestLayoutMatch(app.layoutManager?.listLayouts?.(), matches);
|
|
23
45
|
engine.context['_observableCache']['route'] = {
|
|
24
46
|
name: last.id,
|
|
25
47
|
pathname: last.pathname,
|
|
26
48
|
path: last.handle?.['path'] || null,
|
|
27
49
|
params,
|
|
50
|
+
layoutRouteName: layoutMatch?.id,
|
|
51
|
+
layoutBasePathname: layoutMatch?.pathname,
|
|
28
52
|
};
|
|
29
|
-
}, [engine.context, params, matches]);
|
|
53
|
+
}, [app, engine.context, params, matches]);
|
|
30
54
|
useEffect(() => {
|
|
31
55
|
engine.context['_observableCache']['location'] = location;
|
|
32
56
|
}, [engine.context, location]);
|
|
@@ -0,0 +1,63 @@
|
|
|
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 { render, screen, waitFor } from '@testing-library/react';
|
|
11
|
+
import React, { useEffect } from 'react';
|
|
12
|
+
import { createMemoryRouter, Outlet, RouterProvider, useParams } from 'react-router-dom';
|
|
13
|
+
import { describe, expect, it } from 'vitest';
|
|
14
|
+
import { KeepAlive } from '../KeepAlive';
|
|
15
|
+
|
|
16
|
+
describe('KeepAlive', () => {
|
|
17
|
+
it('keeps inactive outlet pages mounted while switching route params', async () => {
|
|
18
|
+
const events: string[] = [];
|
|
19
|
+
|
|
20
|
+
const Page = () => {
|
|
21
|
+
const { name } = useParams();
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
events.push(`mount:${name}`);
|
|
24
|
+
return () => {
|
|
25
|
+
events.push(`unmount:${name}`);
|
|
26
|
+
};
|
|
27
|
+
}, [name]);
|
|
28
|
+
|
|
29
|
+
return <div>page {name}</div>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const Layout = () => {
|
|
33
|
+
const { name } = useParams();
|
|
34
|
+
return <KeepAlive uid={name || ''}>{() => <Outlet />}</KeepAlive>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const router = createMemoryRouter(
|
|
38
|
+
[
|
|
39
|
+
{
|
|
40
|
+
path: '/:name',
|
|
41
|
+
element: <Layout />,
|
|
42
|
+
children: [{ index: true, element: <Page /> }],
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
{
|
|
46
|
+
initialEntries: ['/page-a'],
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
render(<RouterProvider router={router} />);
|
|
51
|
+
|
|
52
|
+
expect(await screen.findByText('page page-a')).toBeInTheDocument();
|
|
53
|
+
|
|
54
|
+
await router.navigate('/page-b');
|
|
55
|
+
|
|
56
|
+
expect(await screen.findByText('page page-b')).toBeInTheDocument();
|
|
57
|
+
expect(screen.getByText('page page-a')).toBeInTheDocument();
|
|
58
|
+
|
|
59
|
+
await waitFor(() => {
|
|
60
|
+
expect(events).toEqual(['mount:page-a', 'mount:page-b']);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
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 { describe, expect, it } from 'vitest';
|
|
11
|
+
import { findDeepestLayoutMatch } from '../RouterBridge';
|
|
12
|
+
|
|
13
|
+
describe('RouterBridge', () => {
|
|
14
|
+
it('uses the deepest matched layout route as layout base pathname', () => {
|
|
15
|
+
const match = findDeepestLayoutMatch(
|
|
16
|
+
[{ routeName: 'admin' }, { routeName: 'admin.settings.publicForms' }],
|
|
17
|
+
[
|
|
18
|
+
{ id: 'admin', pathname: '/admin' },
|
|
19
|
+
{ id: 'admin.settings', pathname: '/admin/settings' },
|
|
20
|
+
{ id: 'admin.settings.publicForms', pathname: '/admin/settings/public-forms' },
|
|
21
|
+
{ id: 'admin.settings.publicForms.page.view', pathname: '/admin/settings/public-forms/form-1/view/popup' },
|
|
22
|
+
],
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
expect(match?.pathname).toBe('/admin/settings/public-forms');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -16,53 +16,127 @@ export interface ExtendCollectionsProviderProps {
|
|
|
16
16
|
dataSource?: string;
|
|
17
17
|
/** Collections to surface for the lifetime of this provider's subtree. */
|
|
18
18
|
collections: CollectionOptions[];
|
|
19
|
+
/**
|
|
20
|
+
* When `true`, re-sync the data source whenever the `collections` prop
|
|
21
|
+
* reference changes after mount: add entries newly present in the prop and
|
|
22
|
+
* remove entries no longer present (only those this provider registered).
|
|
23
|
+
* The diff runs in the same render as the prop change so children see the
|
|
24
|
+
* new state on their first render — at the cost of one observable mutation
|
|
25
|
+
* per change.
|
|
26
|
+
*
|
|
27
|
+
* Defaults to `false`. Most pages pass a stable (often module-level)
|
|
28
|
+
* `collections` list and don't need this; leaving it off avoids accidental
|
|
29
|
+
* re-registration when callers forget to memoize. Enable only when your
|
|
30
|
+
* collection list legitimately varies during the provider's lifetime.
|
|
31
|
+
*/
|
|
32
|
+
syncOnChange?: boolean;
|
|
19
33
|
children?: ReactNode;
|
|
20
34
|
}
|
|
21
35
|
|
|
22
36
|
/**
|
|
23
|
-
* Mount-scoped collection injector. Adds the given `collections` to the target
|
|
37
|
+
* Mount-scoped collection injector. Adds the given `collections` to the target
|
|
38
|
+
* data source on first render — synchronously, so children can read
|
|
39
|
+
* `getCollection(name)` on their own first render — and removes them on
|
|
40
|
+
* unmount. Survives mid-session data-source reloads via the
|
|
41
|
+
* `dataSource:loaded` event by re-registering only the names this provider
|
|
42
|
+
* owns.
|
|
24
43
|
*
|
|
25
|
-
* Use this for client-only collections — e.g. a `schema-only` server
|
|
44
|
+
* Use this for client-only collections — e.g. a `schema-only` server
|
|
45
|
+
* collection that isn't auto-published to the v2 data source, or a pure
|
|
46
|
+
* UI-side mirror — so downstream components (like `<CollectionFilter>`) can
|
|
47
|
+
* resolve the collection by name.
|
|
48
|
+
*
|
|
49
|
+
* Default behavior is "static-at-mount": subsequent changes to the
|
|
50
|
+
* `collections` prop are ignored. Pass `syncOnChange` to opt into diffing on
|
|
51
|
+
* prop change.
|
|
26
52
|
*/
|
|
27
53
|
export const ExtendCollectionsProvider: FC<ExtendCollectionsProviderProps> = ({
|
|
28
54
|
dataSource = 'main',
|
|
29
55
|
collections,
|
|
56
|
+
syncOnChange = false,
|
|
30
57
|
children,
|
|
31
58
|
}) => {
|
|
32
59
|
const app = useApp();
|
|
33
|
-
|
|
60
|
+
// Lazy-ref init guard. `ownedRef.current === null` only on the first render;
|
|
61
|
+
// once populated, StrictMode dev's second render and any subsequent
|
|
62
|
+
// re-render see a non-null ref and skip re-registering. React docs bless
|
|
63
|
+
// this idiom for "init exactly once on mount" — see "Avoiding recreating
|
|
64
|
+
// the ref contents".
|
|
65
|
+
const ownedRef = useRef<CollectionOptions[] | null>(null);
|
|
66
|
+
// Identity of the `collections` reference we last reacted to; gates the
|
|
67
|
+
// opt-in diff so StrictMode's double-render doesn't diff twice.
|
|
68
|
+
const lastCollectionsRef = useRef<CollectionOptions[] | null>(null);
|
|
34
69
|
|
|
35
|
-
|
|
70
|
+
if (ownedRef.current === null) {
|
|
36
71
|
const ds = app.dataSourceManager?.getDataSource?.(dataSource);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
72
|
+
const owned: CollectionOptions[] = [];
|
|
73
|
+
if (ds) {
|
|
74
|
+
for (const c of collections) {
|
|
75
|
+
if (ds.getCollection?.(c.name)) continue;
|
|
76
|
+
ds.addCollection?.(c);
|
|
77
|
+
owned.push(c);
|
|
78
|
+
}
|
|
42
79
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
80
|
+
ownedRef.current = owned;
|
|
81
|
+
lastCollectionsRef.current = collections;
|
|
82
|
+
} else if (syncOnChange && lastCollectionsRef.current !== collections) {
|
|
83
|
+
const ds = app.dataSourceManager?.getDataSource?.(dataSource);
|
|
84
|
+
if (ds) {
|
|
85
|
+
const nextNames = new Set(collections.map((c) => c.name));
|
|
86
|
+
const prevOwned = new Map(ownedRef.current.map((c) => [c.name, c]));
|
|
87
|
+
for (const name of prevOwned.keys()) {
|
|
88
|
+
if (!nextNames.has(name)) ds.removeCollection?.(name);
|
|
89
|
+
}
|
|
90
|
+
const nextOwned: CollectionOptions[] = [];
|
|
91
|
+
for (const c of collections) {
|
|
92
|
+
const previous = prevOwned.get(c.name);
|
|
93
|
+
if (previous) {
|
|
94
|
+
// First-registered wins: keep the existing options object, mirroring
|
|
95
|
+
// the original behavior where re-adding a present name was a no-op.
|
|
96
|
+
// Callers who need to update a collection should remount the
|
|
97
|
+
// provider (e.g. `key={signature}`).
|
|
98
|
+
nextOwned.push(previous);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (ds.getCollection?.(c.name)) continue;
|
|
102
|
+
ds.addCollection?.(c);
|
|
103
|
+
nextOwned.push(c);
|
|
104
|
+
}
|
|
105
|
+
ownedRef.current = nextOwned;
|
|
106
|
+
}
|
|
107
|
+
lastCollectionsRef.current = collections;
|
|
108
|
+
}
|
|
46
109
|
|
|
47
110
|
useEffect(() => {
|
|
48
111
|
const onLoaded = (event: Event) => {
|
|
49
112
|
const key = (event as CustomEvent<{ dataSourceKey: string }>).detail?.dataSourceKey;
|
|
50
|
-
if (key
|
|
113
|
+
if (key !== dataSource && key !== '*') return;
|
|
114
|
+
const ds = app.dataSourceManager?.getDataSource?.(dataSource);
|
|
115
|
+
if (!ds || !ownedRef.current) return;
|
|
116
|
+
// dataSource was just reloaded from the server — our owned client-only
|
|
117
|
+
// entries got wiped. Re-add only the ones we own, using the snapshot in
|
|
118
|
+
// the ref so we don't accidentally seize names this provider never
|
|
119
|
+
// registered.
|
|
120
|
+
for (const c of ownedRef.current) {
|
|
121
|
+
if (ds.getCollection?.(c.name)) continue;
|
|
122
|
+
ds.addCollection?.(c);
|
|
123
|
+
}
|
|
51
124
|
};
|
|
52
125
|
app.eventBus?.addEventListener('dataSource:loaded', onLoaded);
|
|
53
|
-
|
|
54
126
|
return () => {
|
|
55
127
|
app.eventBus?.removeEventListener('dataSource:loaded', onLoaded);
|
|
56
128
|
const ds = app.dataSourceManager?.getDataSource?.(dataSource);
|
|
57
|
-
const owned = ownedRef.current;
|
|
58
|
-
ownedRef.current =
|
|
129
|
+
const owned = ownedRef.current ?? [];
|
|
130
|
+
ownedRef.current = null;
|
|
131
|
+
lastCollectionsRef.current = null;
|
|
59
132
|
if (!ds) return;
|
|
60
|
-
for (const
|
|
61
|
-
ds.removeCollection?.(name);
|
|
62
|
-
}
|
|
133
|
+
for (const c of owned) ds.removeCollection?.(c.name);
|
|
63
134
|
};
|
|
135
|
+
// `collections` / `syncOnChange` intentionally excluded — they drive the
|
|
136
|
+
// render-phase init/diff above, not this effect. The listener reads
|
|
137
|
+
// `ownedRef` each time it fires (async, never during render).
|
|
64
138
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
65
|
-
}, [app, dataSource
|
|
139
|
+
}, [app, dataSource]);
|
|
66
140
|
|
|
67
141
|
return <>{children}</>;
|
|
68
142
|
};
|
|
@@ -0,0 +1,264 @@
|
|
|
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 type { CollectionOptions } from '@nocobase/flow-engine';
|
|
11
|
+
import { FlowEngineProvider } from '@nocobase/flow-engine';
|
|
12
|
+
import { act, render } from '@testing-library/react';
|
|
13
|
+
import React, { useState } from 'react';
|
|
14
|
+
import { describe, expect, it } from 'vitest';
|
|
15
|
+
import { createMockClient } from '../../MockApplication';
|
|
16
|
+
import { ExtendCollectionsProvider } from '../ExtendCollectionsProvider';
|
|
17
|
+
|
|
18
|
+
// `MockApplication` isn't exported as a named type, so infer from the factory.
|
|
19
|
+
type AppInstance = ReturnType<typeof createMockClient>;
|
|
20
|
+
|
|
21
|
+
function makeApp(): AppInstance {
|
|
22
|
+
const app = createMockClient();
|
|
23
|
+
// The mock client wires a lazy `appInfo` getter to `GET app:getInfo`; any
|
|
24
|
+
// proxy iteration of the FlowEngineContext can trip it. Pre-stub so a 404
|
|
25
|
+
// doesn't surface as an unhandled axios rejection during the test run.
|
|
26
|
+
app.apiMock.onGet('app:getInfo').reply(200, { data: { version: 'test' } });
|
|
27
|
+
return app;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const LOCKED: CollectionOptions = {
|
|
31
|
+
name: 'lockedUsers',
|
|
32
|
+
fields: [{ name: 'id', type: 'integer', interface: 'integer' }],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const USERS: CollectionOptions = {
|
|
36
|
+
name: 'users',
|
|
37
|
+
fields: [{ name: 'username', type: 'string', interface: 'input' }],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const POSTS: CollectionOptions = {
|
|
41
|
+
name: 'posts',
|
|
42
|
+
fields: [{ name: 'title', type: 'string', interface: 'input' }],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function mountWith(app: AppInstance, node: React.ReactNode) {
|
|
46
|
+
return render(<FlowEngineProvider engine={app.flowEngine}>{node}</FlowEngineProvider>);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getMain(app: AppInstance) {
|
|
50
|
+
return app.dataSourceManager.getDataSource('main');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('ExtendCollectionsProvider', () => {
|
|
54
|
+
// The defining contract: a page-inner that reads `getCollection(name)` in its
|
|
55
|
+
// own render body (the LockedUsersPage pattern at LockedUsersPage.tsx:156-157)
|
|
56
|
+
// must see the registered collection on its FIRST render, with no extra tick.
|
|
57
|
+
it('lets children read the registered collection during their first render', () => {
|
|
58
|
+
const app = makeApp();
|
|
59
|
+
let firstRenderResult: { name?: string } | undefined;
|
|
60
|
+
|
|
61
|
+
const Child: React.FC = () => {
|
|
62
|
+
// Read synchronously during render — this is the contract that forces
|
|
63
|
+
// the provider to register collections in render-phase rather than in
|
|
64
|
+
// an effect.
|
|
65
|
+
const found = getMain(app)?.getCollection?.(LOCKED.name);
|
|
66
|
+
firstRenderResult = found ? { name: found.name } : undefined;
|
|
67
|
+
return <div>child</div>;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
mountWith(
|
|
71
|
+
app,
|
|
72
|
+
<ExtendCollectionsProvider collections={[LOCKED]}>
|
|
73
|
+
<Child />
|
|
74
|
+
</ExtendCollectionsProvider>,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
expect(firstRenderResult).toEqual({ name: LOCKED.name });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('removes only the collections it added when unmounted', () => {
|
|
81
|
+
const app = makeApp();
|
|
82
|
+
// Pre-existing collection in the data source — provider must not touch it.
|
|
83
|
+
getMain(app).addCollection(USERS);
|
|
84
|
+
|
|
85
|
+
const { unmount } = mountWith(
|
|
86
|
+
app,
|
|
87
|
+
<ExtendCollectionsProvider collections={[LOCKED]}>
|
|
88
|
+
<span>inside</span>
|
|
89
|
+
</ExtendCollectionsProvider>,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
expect(getMain(app).getCollection(LOCKED.name)?.name).toBe(LOCKED.name);
|
|
93
|
+
expect(getMain(app).getCollection(USERS.name)?.name).toBe(USERS.name);
|
|
94
|
+
|
|
95
|
+
unmount();
|
|
96
|
+
|
|
97
|
+
expect(getMain(app).getCollection(LOCKED.name)).toBeUndefined();
|
|
98
|
+
// The provider didn't add USERS, so it must leave it alone.
|
|
99
|
+
expect(getMain(app).getCollection(USERS.name)?.name).toBe(USERS.name);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Direct regression for the lazy-ref idempotency: even when the provider
|
|
103
|
+
// re-renders many times (which is what StrictMode dev's double-render and
|
|
104
|
+
// any caller-driven re-render look like to the provider), it must not
|
|
105
|
+
// re-register the same collection or accumulate duplicate ownership.
|
|
106
|
+
it('only calls addCollection once across many re-renders of the same mount', () => {
|
|
107
|
+
const app = makeApp();
|
|
108
|
+
let addCount = 0;
|
|
109
|
+
const origAdd = getMain(app).addCollection.bind(getMain(app));
|
|
110
|
+
getMain(app).addCollection = (c: CollectionOptions) => {
|
|
111
|
+
addCount += 1;
|
|
112
|
+
origAdd(c);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const Host: React.FC = () => {
|
|
116
|
+
const [, setTick] = useState(0);
|
|
117
|
+
// Force a render burst on mount — covers StrictMode dev's second render
|
|
118
|
+
// and any other parent-driven re-renders. The lazy-ref guard in the
|
|
119
|
+
// provider must hold across all of them.
|
|
120
|
+
React.useEffect(() => {
|
|
121
|
+
setTick((n) => n + 1);
|
|
122
|
+
setTick((n) => n + 1);
|
|
123
|
+
}, []);
|
|
124
|
+
return (
|
|
125
|
+
<ExtendCollectionsProvider collections={[LOCKED]}>
|
|
126
|
+
<span>inside</span>
|
|
127
|
+
</ExtendCollectionsProvider>
|
|
128
|
+
);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const { unmount } = mountWith(app, <Host />);
|
|
132
|
+
|
|
133
|
+
expect(addCount).toBe(1);
|
|
134
|
+
expect(getMain(app).getCollection(LOCKED.name)?.name).toBe(LOCKED.name);
|
|
135
|
+
|
|
136
|
+
unmount();
|
|
137
|
+
expect(getMain(app).getCollection(LOCKED.name)).toBeUndefined();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Default semantics: "static-at-mount". Caller's prop changes are ignored.
|
|
141
|
+
describe('with syncOnChange={false} (default)', () => {
|
|
142
|
+
it('ignores a `collections` prop reference change after mount', () => {
|
|
143
|
+
const app = makeApp();
|
|
144
|
+
|
|
145
|
+
const Host: React.FC = () => {
|
|
146
|
+
const [list, setList] = useState<CollectionOptions[]>([LOCKED]);
|
|
147
|
+
return (
|
|
148
|
+
<>
|
|
149
|
+
<button type="button" onClick={() => setList([LOCKED, POSTS])}>
|
|
150
|
+
add posts
|
|
151
|
+
</button>
|
|
152
|
+
<ExtendCollectionsProvider collections={list}>
|
|
153
|
+
<span>inside</span>
|
|
154
|
+
</ExtendCollectionsProvider>
|
|
155
|
+
</>
|
|
156
|
+
);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const { getByText } = mountWith(app, <Host />);
|
|
160
|
+
expect(getMain(app).getCollection(LOCKED.name)?.name).toBe(LOCKED.name);
|
|
161
|
+
expect(getMain(app).getCollection(POSTS.name)).toBeUndefined();
|
|
162
|
+
|
|
163
|
+
act(() => {
|
|
164
|
+
getByText('add posts').click();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// POSTS must NOT have been registered — syncOnChange is off.
|
|
168
|
+
expect(getMain(app).getCollection(POSTS.name)).toBeUndefined();
|
|
169
|
+
expect(getMain(app).getCollection(LOCKED.name)?.name).toBe(LOCKED.name);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('with syncOnChange={true}', () => {
|
|
174
|
+
it('adds collections newly added to the prop and removes ones dropped from it', () => {
|
|
175
|
+
const app = makeApp();
|
|
176
|
+
|
|
177
|
+
const Host: React.FC = () => {
|
|
178
|
+
const [list, setList] = useState<CollectionOptions[]>([LOCKED, POSTS]);
|
|
179
|
+
return (
|
|
180
|
+
<>
|
|
181
|
+
<button type="button" onClick={() => setList([POSTS, USERS])}>
|
|
182
|
+
swap
|
|
183
|
+
</button>
|
|
184
|
+
<ExtendCollectionsProvider collections={list} syncOnChange>
|
|
185
|
+
<span>inside</span>
|
|
186
|
+
</ExtendCollectionsProvider>
|
|
187
|
+
</>
|
|
188
|
+
);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const { getByText } = mountWith(app, <Host />);
|
|
192
|
+
expect(getMain(app).getCollection(LOCKED.name)?.name).toBe(LOCKED.name);
|
|
193
|
+
expect(getMain(app).getCollection(POSTS.name)?.name).toBe(POSTS.name);
|
|
194
|
+
expect(getMain(app).getCollection(USERS.name)).toBeUndefined();
|
|
195
|
+
|
|
196
|
+
act(() => {
|
|
197
|
+
getByText('swap').click();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// LOCKED was dropped from the prop → removed.
|
|
201
|
+
expect(getMain(app).getCollection(LOCKED.name)).toBeUndefined();
|
|
202
|
+
// POSTS was in both lists → kept.
|
|
203
|
+
expect(getMain(app).getCollection(POSTS.name)?.name).toBe(POSTS.name);
|
|
204
|
+
// USERS is new → added.
|
|
205
|
+
expect(getMain(app).getCollection(USERS.name)?.name).toBe(USERS.name);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('still leaves pre-existing collections it never owned alone after diff', () => {
|
|
209
|
+
const app = makeApp();
|
|
210
|
+
// Pre-existing in the data source before the provider mounts.
|
|
211
|
+
getMain(app).addCollection(USERS);
|
|
212
|
+
|
|
213
|
+
const Host: React.FC = () => {
|
|
214
|
+
const [list, setList] = useState<CollectionOptions[]>([LOCKED, USERS]);
|
|
215
|
+
return (
|
|
216
|
+
<>
|
|
217
|
+
<button type="button" onClick={() => setList([LOCKED])}>
|
|
218
|
+
drop users
|
|
219
|
+
</button>
|
|
220
|
+
<ExtendCollectionsProvider collections={list} syncOnChange>
|
|
221
|
+
<span>inside</span>
|
|
222
|
+
</ExtendCollectionsProvider>
|
|
223
|
+
</>
|
|
224
|
+
);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const { getByText } = mountWith(app, <Host />);
|
|
228
|
+
// USERS was already there, so the provider never owned it.
|
|
229
|
+
expect(getMain(app).getCollection(USERS.name)?.name).toBe(USERS.name);
|
|
230
|
+
|
|
231
|
+
act(() => {
|
|
232
|
+
getByText('drop users').click();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// The prop dropped USERS, but the provider doesn't own it — must not
|
|
236
|
+
// accidentally remove it.
|
|
237
|
+
expect(getMain(app).getCollection(USERS.name)?.name).toBe(USERS.name);
|
|
238
|
+
expect(getMain(app).getCollection(LOCKED.name)?.name).toBe(LOCKED.name);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Mid-session reload: the data source manager wipes everything and reloads
|
|
243
|
+
// from the server; client-only entries this provider registered would be
|
|
244
|
+
// gone. The `dataSource:loaded` event handler re-adds owned entries.
|
|
245
|
+
it('re-registers owned collections after a dataSource:loaded event', () => {
|
|
246
|
+
const app = makeApp();
|
|
247
|
+
|
|
248
|
+
mountWith(
|
|
249
|
+
app,
|
|
250
|
+
<ExtendCollectionsProvider collections={[LOCKED]}>
|
|
251
|
+
<span>inside</span>
|
|
252
|
+
</ExtendCollectionsProvider>,
|
|
253
|
+
);
|
|
254
|
+
expect(getMain(app).getCollection(LOCKED.name)?.name).toBe(LOCKED.name);
|
|
255
|
+
|
|
256
|
+
// Simulate the data source manager wiping then re-broadcasting the event.
|
|
257
|
+
act(() => {
|
|
258
|
+
getMain(app).removeCollection(LOCKED.name);
|
|
259
|
+
app.eventBus.dispatchEvent(new CustomEvent('dataSource:loaded', { detail: { dataSourceKey: 'main' } }));
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
expect(getMain(app).getCollection(LOCKED.name)?.name).toBe(LOCKED.name);
|
|
263
|
+
});
|
|
264
|
+
});
|