@etsoo/toolpad 1.0.10 → 1.0.11
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/build/AppProvider/AppProvider.d.ts +1 -0
- package/build/DashboardLayout/DashboardSidebarSubNavigation.js +4 -6
- package/build/PageContainer/PageContainer.d.ts +11 -13
- package/build/PageContainer/PageContainer.js +43 -4
- package/build/PageContainer/PageContainer.test.js +40 -30
- package/build/shared/navigation.js +15 -7
- package/build/useActivePage/useActivePage.d.ts +1 -4
- package/build/useActivePage/useActivePage.js +2 -3
- package/package.json +1 -1
- package/src/AppProvider/AppProvider.tsx +1 -0
- package/src/DashboardLayout/DashboardSidebarSubNavigation.tsx +4 -15
- package/src/PageContainer/PageContainer.test.tsx +88 -59
- package/src/PageContainer/PageContainer.tsx +73 -26
- package/src/shared/navigation.tsx +15 -8
- package/src/useActivePage/useActivePage.ts +3 -7
|
@@ -97,12 +97,10 @@ function DashboardSidebarSubNavigation({ subNavigation, basePath = "", depth = 0
|
|
|
97
97
|
const isNestedNavigationExpanded = expandedSidebarItemIds.includes(navigationItemId);
|
|
98
98
|
const nestedNavigationCollapseIcon = isNestedNavigationExpanded ? (_jsx(ExpandLessIcon, {})) : (_jsx(ExpandMoreIcon, {}));
|
|
99
99
|
const listItemIconSize = 34;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
console.warn(`Duplicate selected path in navigation: ${navigationItemFullPath}`);
|
|
105
|
-
}
|
|
100
|
+
// If the item is selected, we don't want to select more
|
|
101
|
+
const isSelected = selectedItemId
|
|
102
|
+
? false
|
|
103
|
+
: isPageItemSelected(navigationItem, basePath, pathname);
|
|
106
104
|
if (isSelected && !selectedItemId) {
|
|
107
105
|
selectedItemId = navigationItemId;
|
|
108
106
|
}
|
|
@@ -21,20 +21,18 @@ export interface Breadcrumb {
|
|
|
21
21
|
*/
|
|
22
22
|
path: string;
|
|
23
23
|
}
|
|
24
|
-
|
|
25
|
-
* @deprecated Use `Breadcrumb` instead.
|
|
26
|
-
*/
|
|
27
|
-
export type BreadCrumb = Breadcrumb;
|
|
28
|
-
export interface PageContainerProps extends ContainerProps {
|
|
29
|
-
children?: React.ReactNode;
|
|
30
|
-
/**
|
|
31
|
-
* The title of the page. Leave blank to use the active page title.
|
|
32
|
-
*/
|
|
24
|
+
export type PageData = {
|
|
33
25
|
title?: string;
|
|
34
|
-
|
|
35
|
-
* The breadcrumbs of the page. Leave blank to use the active page breadcrumbs.
|
|
36
|
-
*/
|
|
26
|
+
page?: string;
|
|
37
27
|
breadcrumbs?: Breadcrumb[];
|
|
28
|
+
};
|
|
29
|
+
type PageDataAction = PageData | true;
|
|
30
|
+
export declare const PageDataContext: React.Context<{
|
|
31
|
+
state: PageData;
|
|
32
|
+
dispatch: React.Dispatch<PageDataAction>;
|
|
33
|
+
}>;
|
|
34
|
+
export declare function PageDataContextProvider(props: React.PropsWithChildren<PageData>): import("react/jsx-runtime").JSX.Element;
|
|
35
|
+
export type PageContainerProps = React.PropsWithChildren<ContainerProps & {
|
|
38
36
|
/**
|
|
39
37
|
* The components used for each slot inside.
|
|
40
38
|
*/
|
|
@@ -43,7 +41,7 @@ export interface PageContainerProps extends ContainerProps {
|
|
|
43
41
|
* The props used for each slot inside.
|
|
44
42
|
*/
|
|
45
43
|
slotProps?: PageContainerSlotProps;
|
|
46
|
-
}
|
|
44
|
+
}>;
|
|
47
45
|
/**
|
|
48
46
|
* A container component to provide a title and breadcrumbs for your pages.
|
|
49
47
|
*
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import * as React from "react";
|
|
3
4
|
import Breadcrumbs from "@mui/material/Breadcrumbs";
|
|
4
5
|
import Container from "@mui/material/Container";
|
|
5
6
|
import Link from "@mui/material/Link";
|
|
@@ -17,6 +18,29 @@ const PageContentHeader = styled("div")(({ theme }) => ({
|
|
|
17
18
|
justifyContent: "space-between",
|
|
18
19
|
gap: theme.spacing(2)
|
|
19
20
|
}));
|
|
21
|
+
export const PageDataContext = React.createContext({ state: {}, dispatch: (value) => value });
|
|
22
|
+
function reducer(state, action) {
|
|
23
|
+
if (action === true) {
|
|
24
|
+
// Reset the state
|
|
25
|
+
if (state.breadcrumbs == null &&
|
|
26
|
+
state.title == null &&
|
|
27
|
+
state.page == null) {
|
|
28
|
+
return state;
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { ...state, ...action };
|
|
35
|
+
}
|
|
36
|
+
export function PageDataContextProvider(props) {
|
|
37
|
+
// Destruct
|
|
38
|
+
const { title, breadcrumbs, ...rest } = props;
|
|
39
|
+
// useReducer hook to manage state with our reducer function and initial state
|
|
40
|
+
const [state, dispatch] = React.useReducer(reducer, { title, breadcrumbs });
|
|
41
|
+
// Provide the state and dispatch function to the context value
|
|
42
|
+
return _jsx(PageDataContext.Provider, { value: { state, dispatch }, ...rest });
|
|
43
|
+
}
|
|
20
44
|
/**
|
|
21
45
|
* A container component to provide a title and breadcrumbs for your pages.
|
|
22
46
|
*
|
|
@@ -29,11 +53,26 @@ const PageContentHeader = styled("div")(({ theme }) => ({
|
|
|
29
53
|
* - [PageContainer API](https://mui.com/toolpad/core/api/page-container)
|
|
30
54
|
*/
|
|
31
55
|
function PageContainer(props) {
|
|
32
|
-
const { children, slots, slotProps,
|
|
56
|
+
const { children, slots, slotProps, ...rest } = props;
|
|
57
|
+
const loaded = React.useRef(false);
|
|
58
|
+
const { state, dispatch } = React.useContext(PageDataContext);
|
|
33
59
|
const activePage = useActivePage();
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
60
|
+
React.useLayoutEffect(() => {
|
|
61
|
+
if (loaded.current) {
|
|
62
|
+
dispatch(true);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
loaded.current = true;
|
|
66
|
+
}
|
|
67
|
+
}, [activePage?.sourcePath]);
|
|
68
|
+
let resolvedBreadcrumbs = state.breadcrumbs ?? activePage?.breadcrumbs ?? [];
|
|
69
|
+
const title = state.title ?? activePage?.title ?? "";
|
|
70
|
+
if (state.page) {
|
|
71
|
+
resolvedBreadcrumbs = [
|
|
72
|
+
...resolvedBreadcrumbs,
|
|
73
|
+
{ title: state.page, path: "#" }
|
|
74
|
+
];
|
|
75
|
+
}
|
|
37
76
|
const ToolbarComponent = props?.slots?.toolbar ?? PageContainerToolbar;
|
|
38
77
|
const toolbarSlotProps = useSlotProps({
|
|
39
78
|
elementType: ToolbarComponent,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { expect, describe, test, vi } from "vitest";
|
|
3
|
-
import { render, within, screen } from "@testing-library/react";
|
|
3
|
+
import { render, within, screen, act } from "@testing-library/react";
|
|
4
4
|
import { userEvent } from "@testing-library/user-event";
|
|
5
|
-
import { PageContainer } from "./PageContainer";
|
|
5
|
+
import { PageContainer, PageDataContextProvider } from "./PageContainer";
|
|
6
6
|
import describeConformance from "../utils/describeConformance";
|
|
7
7
|
import { AppProvider } from "../AppProvider/AppProviderComponent";
|
|
8
8
|
describe("PageContainer", () => {
|
|
@@ -13,16 +13,18 @@ describe("PageContainer", () => {
|
|
|
13
13
|
}
|
|
14
14
|
}));
|
|
15
15
|
test("renders page container correctly", async () => {
|
|
16
|
-
const user =
|
|
16
|
+
const user = userEvent.setup();
|
|
17
17
|
const router = {
|
|
18
18
|
pathname: "/orders",
|
|
19
19
|
searchParams: new URLSearchParams(),
|
|
20
20
|
navigate: vi.fn()
|
|
21
21
|
};
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
act(() => {
|
|
23
|
+
render(_jsx(AppProvider, { navigation: [
|
|
24
|
+
{ segment: "", title: "Home" },
|
|
25
|
+
{ segment: "orders", title: "Orders" }
|
|
26
|
+
], router: router, children: _jsx(PageDataContextProvider, { children: _jsx(PageContainer, {}) }) }));
|
|
27
|
+
});
|
|
26
28
|
const breadcrumbs = screen.getByRole("navigation", { name: "breadcrumb" });
|
|
27
29
|
const homeLink = within(breadcrumbs).getByRole("link", { name: "Home" });
|
|
28
30
|
await user.click(homeLink);
|
|
@@ -51,23 +53,27 @@ describe("PageContainer", () => {
|
|
|
51
53
|
navigate: vi.fn()
|
|
52
54
|
};
|
|
53
55
|
const branding = { title: "ACME" };
|
|
54
|
-
|
|
56
|
+
act(() => {
|
|
57
|
+
render(_jsx(AppProvider, { branding: branding, navigation: navigation, router: router, children: _jsx(PageDataContextProvider, { children: _jsx(PageContainer, {}) }) }));
|
|
58
|
+
});
|
|
55
59
|
const breadcrumbs = screen.getByRole("navigation", { name: "breadcrumb" });
|
|
56
60
|
expect(within(breadcrumbs).getByText("ACME")).toBeTruthy();
|
|
57
61
|
expect(within(breadcrumbs).getByText("Home")).toBeTruthy();
|
|
58
62
|
expect(within(breadcrumbs).getByText("Orders")).toBeTruthy();
|
|
59
63
|
});
|
|
60
64
|
test("renders dynamic correctly", async () => {
|
|
61
|
-
const user =
|
|
65
|
+
const user = userEvent.setup();
|
|
62
66
|
const router = {
|
|
63
67
|
pathname: "/orders/123",
|
|
64
68
|
searchParams: new URLSearchParams(),
|
|
65
69
|
navigate: vi.fn()
|
|
66
70
|
};
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
+
act(() => {
|
|
72
|
+
render(_jsx(AppProvider, { navigation: [
|
|
73
|
+
{ segment: "", title: "Home" },
|
|
74
|
+
{ segment: "orders", title: "Orders", pattern: "orders/:id" }
|
|
75
|
+
], router: router, children: _jsx(PageDataContextProvider, { children: _jsx(PageContainer, {}) }) }));
|
|
76
|
+
});
|
|
71
77
|
const breadcrumbs = screen.getByRole("navigation", { name: "breadcrumb" });
|
|
72
78
|
const homeLink = within(breadcrumbs).getByRole("link", { name: "Home" });
|
|
73
79
|
await user.click(homeLink);
|
|
@@ -82,29 +88,33 @@ describe("PageContainer", () => {
|
|
|
82
88
|
searchParams: new URLSearchParams(),
|
|
83
89
|
navigate: vi.fn()
|
|
84
90
|
};
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
91
|
+
act(() => {
|
|
92
|
+
render(_jsx(AppProvider, { navigation: [
|
|
93
|
+
{
|
|
94
|
+
segment: "users",
|
|
95
|
+
title: "Users",
|
|
96
|
+
children: [
|
|
97
|
+
{
|
|
98
|
+
segment: "invoices",
|
|
99
|
+
title: "Invoices",
|
|
100
|
+
pattern: "invoices/:id"
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
}
|
|
104
|
+
], router: router, children: _jsx(PageDataContextProvider, { children: _jsx(PageContainer, {}) }) }));
|
|
105
|
+
});
|
|
98
106
|
const breadcrumbs = screen.getByRole("navigation", { name: "breadcrumb" });
|
|
99
107
|
const homeLink = within(breadcrumbs).getByRole("link", { name: "Users" });
|
|
100
108
|
expect(homeLink.getAttribute("href")).toBe("/users");
|
|
101
109
|
expect(within(breadcrumbs).getByText("Invoices")).toBeTruthy();
|
|
102
110
|
});
|
|
103
111
|
test("renders custom breadcrumbs", async () => {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
112
|
+
act(() => {
|
|
113
|
+
render(_jsx(PageDataContextProvider, { breadcrumbs: [
|
|
114
|
+
{ title: "Hello", path: "/hello" },
|
|
115
|
+
{ title: "World", path: "/world" }
|
|
116
|
+
], children: _jsx(PageContainer, {}) }));
|
|
117
|
+
});
|
|
108
118
|
const breadcrumbs = screen.getByRole("navigation", { name: "breadcrumb" });
|
|
109
119
|
const helloLink = within(breadcrumbs).getByRole("link", { name: "Hello" });
|
|
110
120
|
expect(helloLink.getAttribute("href")).toBe("/hello");
|
|
@@ -9,9 +9,13 @@ export function getPageItemFullPath(basePath, navigationItem) {
|
|
|
9
9
|
return `${basePath}${basePath && !navigationItem.segment ? "" : "/"}${navigationItem.segment ?? ""}`;
|
|
10
10
|
}
|
|
11
11
|
export function isPageItemSelected(navigationItem, basePath, pathname) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
if (navigationItem.pattern) {
|
|
13
|
+
return pathToRegexp(`${basePath}/${navigationItem.pattern}`).test(pathname);
|
|
14
|
+
}
|
|
15
|
+
if (navigationItem.subs) {
|
|
16
|
+
return navigationItem.subs.some((sub) => new RegExp(sub).test(pathname));
|
|
17
|
+
}
|
|
18
|
+
return getPageItemFullPath(basePath, navigationItem) === pathname;
|
|
15
19
|
}
|
|
16
20
|
export function hasSelectedNavigationChildren(navigationItem, basePath, pathname) {
|
|
17
21
|
if (isPageItem(navigationItem) && navigationItem.children) {
|
|
@@ -109,11 +113,15 @@ function getItemLookup(navigation) {
|
|
|
109
113
|
export function matchPath(navigation, path) {
|
|
110
114
|
const lookup = getItemLookup(navigation);
|
|
111
115
|
for (const [key, item] of lookup.entries()) {
|
|
112
|
-
if (typeof key === "string"
|
|
113
|
-
|
|
116
|
+
if (typeof key === "string") {
|
|
117
|
+
if (key === path)
|
|
118
|
+
return item;
|
|
119
|
+
else if (item.subs?.some((sub) => new RegExp(sub).test(path)))
|
|
120
|
+
return item;
|
|
114
121
|
}
|
|
115
|
-
if (key instanceof RegExp
|
|
116
|
-
|
|
122
|
+
else if (key instanceof RegExp) {
|
|
123
|
+
if (key.test(path))
|
|
124
|
+
return item;
|
|
117
125
|
}
|
|
118
126
|
}
|
|
119
127
|
return null;
|
|
@@ -2,10 +2,7 @@ import type { Breadcrumb } from "../PageContainer";
|
|
|
2
2
|
export interface ActivePage {
|
|
3
3
|
title: string;
|
|
4
4
|
path: string;
|
|
5
|
-
|
|
6
|
-
* @deprecated Use `breadcrumbs` instead.
|
|
7
|
-
*/
|
|
8
|
-
breadCrumbs: Breadcrumb[];
|
|
5
|
+
sourcePath: string;
|
|
9
6
|
breadcrumbs: Breadcrumb[];
|
|
10
7
|
}
|
|
11
8
|
export declare function useActivePage(): ActivePage | null;
|
|
@@ -40,9 +40,8 @@ export function useActivePage() {
|
|
|
40
40
|
return {
|
|
41
41
|
title: getItemTitle(activeItem),
|
|
42
42
|
path: getItemPath(navigationContext, activeItem),
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
breadCrumbs: breadcrumbs
|
|
43
|
+
sourcePath: pathname,
|
|
44
|
+
breadcrumbs
|
|
46
45
|
};
|
|
47
46
|
}, [activeItem, rootItem, pathname, navigationContext]);
|
|
48
47
|
}
|
package/package.json
CHANGED
|
@@ -175,21 +175,10 @@ function DashboardSidebarSubNavigation({
|
|
|
175
175
|
|
|
176
176
|
const listItemIconSize = 34;
|
|
177
177
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
pathname
|
|
182
|
-
);
|
|
183
|
-
|
|
184
|
-
if (
|
|
185
|
-
process.env.NODE_ENV !== "production" &&
|
|
186
|
-
isSelected &&
|
|
187
|
-
selectedItemId
|
|
188
|
-
) {
|
|
189
|
-
console.warn(
|
|
190
|
-
`Duplicate selected path in navigation: ${navigationItemFullPath}`
|
|
191
|
-
);
|
|
192
|
-
}
|
|
178
|
+
// If the item is selected, we don't want to select more
|
|
179
|
+
const isSelected = selectedItemId
|
|
180
|
+
? false
|
|
181
|
+
: isPageItemSelected(navigationItem, basePath, pathname);
|
|
193
182
|
|
|
194
183
|
if (isSelected && !selectedItemId) {
|
|
195
184
|
selectedItemId = navigationItemId;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { expect, describe, test, vi } from "vitest";
|
|
2
|
-
import { render, within, screen } from "@testing-library/react";
|
|
2
|
+
import { render, within, screen, act } from "@testing-library/react";
|
|
3
3
|
import { userEvent } from "@testing-library/user-event";
|
|
4
|
-
import { PageContainer } from "./PageContainer";
|
|
4
|
+
import { PageContainer, PageDataContextProvider } from "./PageContainer";
|
|
5
5
|
import describeConformance from "../utils/describeConformance";
|
|
6
6
|
import { AppProvider } from "../AppProvider/AppProviderComponent";
|
|
7
7
|
|
|
@@ -14,23 +14,28 @@ describe("PageContainer", () => {
|
|
|
14
14
|
}));
|
|
15
15
|
|
|
16
16
|
test("renders page container correctly", async () => {
|
|
17
|
-
const user =
|
|
17
|
+
const user = userEvent.setup();
|
|
18
18
|
const router = {
|
|
19
19
|
pathname: "/orders",
|
|
20
20
|
searchParams: new URLSearchParams(),
|
|
21
21
|
navigate: vi.fn()
|
|
22
22
|
};
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
{
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
23
|
+
|
|
24
|
+
act(() => {
|
|
25
|
+
render(
|
|
26
|
+
<AppProvider
|
|
27
|
+
navigation={[
|
|
28
|
+
{ segment: "", title: "Home" },
|
|
29
|
+
{ segment: "orders", title: "Orders" }
|
|
30
|
+
]}
|
|
31
|
+
router={router}
|
|
32
|
+
>
|
|
33
|
+
<PageDataContextProvider>
|
|
34
|
+
<PageContainer />
|
|
35
|
+
</PageDataContextProvider>
|
|
36
|
+
</AppProvider>
|
|
37
|
+
);
|
|
38
|
+
});
|
|
34
39
|
|
|
35
40
|
const breadcrumbs = screen.getByRole("navigation", { name: "breadcrumb" });
|
|
36
41
|
|
|
@@ -70,11 +75,20 @@ describe("PageContainer", () => {
|
|
|
70
75
|
};
|
|
71
76
|
|
|
72
77
|
const branding = { title: "ACME" };
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
+
|
|
79
|
+
act(() => {
|
|
80
|
+
render(
|
|
81
|
+
<AppProvider
|
|
82
|
+
branding={branding}
|
|
83
|
+
navigation={navigation}
|
|
84
|
+
router={router}
|
|
85
|
+
>
|
|
86
|
+
<PageDataContextProvider>
|
|
87
|
+
<PageContainer />
|
|
88
|
+
</PageDataContextProvider>
|
|
89
|
+
</AppProvider>
|
|
90
|
+
);
|
|
91
|
+
});
|
|
78
92
|
|
|
79
93
|
const breadcrumbs = screen.getByRole("navigation", { name: "breadcrumb" });
|
|
80
94
|
|
|
@@ -84,27 +98,33 @@ describe("PageContainer", () => {
|
|
|
84
98
|
});
|
|
85
99
|
|
|
86
100
|
test("renders dynamic correctly", async () => {
|
|
87
|
-
const user =
|
|
101
|
+
const user = userEvent.setup();
|
|
88
102
|
const router = {
|
|
89
103
|
pathname: "/orders/123",
|
|
90
104
|
searchParams: new URLSearchParams(),
|
|
91
105
|
navigate: vi.fn()
|
|
92
106
|
};
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
{
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
107
|
+
|
|
108
|
+
act(() => {
|
|
109
|
+
render(
|
|
110
|
+
<AppProvider
|
|
111
|
+
navigation={[
|
|
112
|
+
{ segment: "", title: "Home" },
|
|
113
|
+
{ segment: "orders", title: "Orders", pattern: "orders/:id" }
|
|
114
|
+
]}
|
|
115
|
+
router={router}
|
|
116
|
+
>
|
|
117
|
+
<PageDataContextProvider>
|
|
118
|
+
<PageContainer />
|
|
119
|
+
</PageDataContextProvider>
|
|
120
|
+
</AppProvider>
|
|
121
|
+
);
|
|
122
|
+
});
|
|
104
123
|
|
|
105
124
|
const breadcrumbs = screen.getByRole("navigation", { name: "breadcrumb" });
|
|
106
125
|
|
|
107
126
|
const homeLink = within(breadcrumbs).getByRole("link", { name: "Home" });
|
|
127
|
+
|
|
108
128
|
await user.click(homeLink);
|
|
109
129
|
|
|
110
130
|
expect(router.navigate).toHaveBeenCalledWith(
|
|
@@ -124,26 +144,31 @@ describe("PageContainer", () => {
|
|
|
124
144
|
searchParams: new URLSearchParams(),
|
|
125
145
|
navigate: vi.fn()
|
|
126
146
|
};
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
+
|
|
148
|
+
act(() => {
|
|
149
|
+
render(
|
|
150
|
+
<AppProvider
|
|
151
|
+
navigation={[
|
|
152
|
+
{
|
|
153
|
+
segment: "users",
|
|
154
|
+
title: "Users",
|
|
155
|
+
children: [
|
|
156
|
+
{
|
|
157
|
+
segment: "invoices",
|
|
158
|
+
title: "Invoices",
|
|
159
|
+
pattern: "invoices/:id"
|
|
160
|
+
}
|
|
161
|
+
]
|
|
162
|
+
}
|
|
163
|
+
]}
|
|
164
|
+
router={router}
|
|
165
|
+
>
|
|
166
|
+
<PageDataContextProvider>
|
|
167
|
+
<PageContainer />
|
|
168
|
+
</PageDataContextProvider>
|
|
169
|
+
</AppProvider>
|
|
170
|
+
);
|
|
171
|
+
});
|
|
147
172
|
|
|
148
173
|
const breadcrumbs = screen.getByRole("navigation", { name: "breadcrumb" });
|
|
149
174
|
|
|
@@ -153,14 +178,18 @@ describe("PageContainer", () => {
|
|
|
153
178
|
});
|
|
154
179
|
|
|
155
180
|
test("renders custom breadcrumbs", async () => {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
{
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
181
|
+
act(() => {
|
|
182
|
+
render(
|
|
183
|
+
<PageDataContextProvider
|
|
184
|
+
breadcrumbs={[
|
|
185
|
+
{ title: "Hello", path: "/hello" },
|
|
186
|
+
{ title: "World", path: "/world" }
|
|
187
|
+
]}
|
|
188
|
+
>
|
|
189
|
+
<PageContainer />
|
|
190
|
+
</PageDataContextProvider>
|
|
191
|
+
);
|
|
192
|
+
});
|
|
164
193
|
|
|
165
194
|
const breadcrumbs = screen.getByRole("navigation", { name: "breadcrumb" });
|
|
166
195
|
|
|
@@ -45,32 +45,62 @@ export interface Breadcrumb {
|
|
|
45
45
|
path: string;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* @deprecated Use `Breadcrumb` instead.
|
|
51
|
-
*/
|
|
52
|
-
export type BreadCrumb = Breadcrumb;
|
|
53
|
-
|
|
54
|
-
export interface PageContainerProps extends ContainerProps {
|
|
55
|
-
children?: React.ReactNode;
|
|
56
|
-
/**
|
|
57
|
-
* The title of the page. Leave blank to use the active page title.
|
|
58
|
-
*/
|
|
48
|
+
export type PageData = {
|
|
59
49
|
title?: string;
|
|
60
|
-
|
|
61
|
-
* The breadcrumbs of the page. Leave blank to use the active page breadcrumbs.
|
|
62
|
-
*/
|
|
50
|
+
page?: string;
|
|
63
51
|
breadcrumbs?: Breadcrumb[];
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type PageDataAction = PageData | true;
|
|
55
|
+
|
|
56
|
+
export const PageDataContext = React.createContext<{
|
|
57
|
+
state: PageData;
|
|
58
|
+
dispatch: React.Dispatch<PageDataAction>;
|
|
59
|
+
}>({ state: {}, dispatch: (value) => value });
|
|
60
|
+
|
|
61
|
+
function reducer(state: PageData, action: PageDataAction) {
|
|
62
|
+
if (action === true) {
|
|
63
|
+
// Reset the state
|
|
64
|
+
if (
|
|
65
|
+
state.breadcrumbs == null &&
|
|
66
|
+
state.title == null &&
|
|
67
|
+
state.page == null
|
|
68
|
+
) {
|
|
69
|
+
return state;
|
|
70
|
+
} else {
|
|
71
|
+
return {};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { ...state, ...action };
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
export function PageDataContextProvider(
|
|
79
|
+
props: React.PropsWithChildren<PageData>
|
|
80
|
+
) {
|
|
81
|
+
// Destruct
|
|
82
|
+
const { title, breadcrumbs, ...rest } = props;
|
|
83
|
+
|
|
84
|
+
// useReducer hook to manage state with our reducer function and initial state
|
|
85
|
+
const [state, dispatch] = React.useReducer(reducer, { title, breadcrumbs });
|
|
86
|
+
|
|
87
|
+
// Provide the state and dispatch function to the context value
|
|
88
|
+
return <PageDataContext.Provider value={{ state, dispatch }} {...rest} />;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type PageContainerProps = React.PropsWithChildren<
|
|
92
|
+
ContainerProps & {
|
|
93
|
+
/**
|
|
94
|
+
* The components used for each slot inside.
|
|
95
|
+
*/
|
|
96
|
+
slots?: PageContainerSlots;
|
|
97
|
+
/**
|
|
98
|
+
* The props used for each slot inside.
|
|
99
|
+
*/
|
|
100
|
+
slotProps?: PageContainerSlotProps;
|
|
101
|
+
}
|
|
102
|
+
>;
|
|
103
|
+
|
|
74
104
|
/**
|
|
75
105
|
* A container component to provide a title and breadcrumbs for your pages.
|
|
76
106
|
*
|
|
@@ -83,13 +113,30 @@ export interface PageContainerProps extends ContainerProps {
|
|
|
83
113
|
* - [PageContainer API](https://mui.com/toolpad/core/api/page-container)
|
|
84
114
|
*/
|
|
85
115
|
function PageContainer(props: PageContainerProps) {
|
|
86
|
-
const { children, slots, slotProps,
|
|
116
|
+
const { children, slots, slotProps, ...rest } = props;
|
|
117
|
+
|
|
118
|
+
const loaded = React.useRef(false);
|
|
119
|
+
const { state, dispatch } = React.useContext(PageDataContext);
|
|
87
120
|
|
|
88
121
|
const activePage = useActivePage();
|
|
89
122
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
123
|
+
React.useLayoutEffect(() => {
|
|
124
|
+
if (loaded.current) {
|
|
125
|
+
dispatch(true);
|
|
126
|
+
} else {
|
|
127
|
+
loaded.current = true;
|
|
128
|
+
}
|
|
129
|
+
}, [activePage?.sourcePath]);
|
|
130
|
+
|
|
131
|
+
let resolvedBreadcrumbs = state.breadcrumbs ?? activePage?.breadcrumbs ?? [];
|
|
132
|
+
const title = state.title ?? activePage?.title ?? "";
|
|
133
|
+
|
|
134
|
+
if (state.page) {
|
|
135
|
+
resolvedBreadcrumbs = [
|
|
136
|
+
...resolvedBreadcrumbs,
|
|
137
|
+
{ title: state.page, path: "#" }
|
|
138
|
+
];
|
|
139
|
+
}
|
|
93
140
|
|
|
94
141
|
const ToolbarComponent = props?.slots?.toolbar ?? PageContainerToolbar;
|
|
95
142
|
const toolbarSlotProps = useSlotProps({
|
|
@@ -32,9 +32,15 @@ export function isPageItemSelected(
|
|
|
32
32
|
basePath: string,
|
|
33
33
|
pathname: string
|
|
34
34
|
) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
if (navigationItem.pattern) {
|
|
36
|
+
return pathToRegexp(`${basePath}/${navigationItem.pattern}`).test(pathname);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (navigationItem.subs) {
|
|
40
|
+
return navigationItem.subs.some((sub) => new RegExp(sub).test(pathname));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return getPageItemFullPath(basePath, navigationItem) === pathname;
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
export function hasSelectedNavigationChildren(
|
|
@@ -172,11 +178,12 @@ export function matchPath(
|
|
|
172
178
|
const lookup = getItemLookup(navigation);
|
|
173
179
|
|
|
174
180
|
for (const [key, item] of lookup.entries()) {
|
|
175
|
-
if (typeof key === "string"
|
|
176
|
-
return item;
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
181
|
+
if (typeof key === "string") {
|
|
182
|
+
if (key === path) return item;
|
|
183
|
+
else if (item.subs?.some((sub) => new RegExp(sub).test(path)))
|
|
184
|
+
return item;
|
|
185
|
+
} else if (key instanceof RegExp) {
|
|
186
|
+
if (key.test(path)) return item;
|
|
180
187
|
}
|
|
181
188
|
}
|
|
182
189
|
|
|
@@ -7,10 +7,7 @@ import type { Breadcrumb } from "../PageContainer";
|
|
|
7
7
|
export interface ActivePage {
|
|
8
8
|
title: string;
|
|
9
9
|
path: string;
|
|
10
|
-
|
|
11
|
-
* @deprecated Use `breadcrumbs` instead.
|
|
12
|
-
*/
|
|
13
|
-
breadCrumbs: Breadcrumb[];
|
|
10
|
+
sourcePath: string;
|
|
14
11
|
breadcrumbs: Breadcrumb[];
|
|
15
12
|
}
|
|
16
13
|
|
|
@@ -58,9 +55,8 @@ export function useActivePage(): ActivePage | null {
|
|
|
58
55
|
return {
|
|
59
56
|
title: getItemTitle(activeItem),
|
|
60
57
|
path: getItemPath(navigationContext, activeItem),
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
breadCrumbs: breadcrumbs
|
|
58
|
+
sourcePath: pathname,
|
|
59
|
+
breadcrumbs
|
|
64
60
|
};
|
|
65
61
|
}, [activeItem, rootItem, pathname, navigationContext]);
|
|
66
62
|
}
|