Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PageContainer] Make PageContainer customizable for dynamic routes #4114

Merged
merged 15 commits into from
Sep 20, 2024
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { PageContainer } from '@toolpad/core/PageContainer';
import { AppProvider } from '@toolpad/core/AppProvider';
import { Link, useDemoRouter } from '@toolpad/core/internals';
import { useActivePage } from '@toolpad/core/useActivePage';
import { useTheme } from '@mui/material/styles';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import invariant from 'invariant';

const NAVIGATION = [
{
segment: 'inbox',
title: 'Orders',
pattern: '/inbox/:id',
},
];

function Content({ router }) {
const id = Number(router.pathname.replace('/inbox/', ''));

const activePage = useActivePage();
invariant(activePage, 'No navigation match');

const title = `Item ${id}`;
const path = `${activePage.path}/${id}`;

const breadCrumbs = [...activePage.breadCrumbs, { title, path }];

return (
// preview-start
<PageContainer title={title} breadCrumbs={breadCrumbs}>
{/* preview-end */}
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Link href={`/inbox/${id - 1}`}>previous</Link>
<Link href={`/inbox/${id + 1}`}>next</Link>
</Box>
</PageContainer>
);
}

Content.propTypes = {
router: PropTypes.shape({
navigate: PropTypes.func.isRequired,
pathname: PropTypes.string.isRequired,
searchParams: PropTypes.instanceOf(URLSearchParams).isRequired,
}).isRequired,
};

export default function CustomPageContainer() {
const router = useDemoRouter('/inbox/123');

const theme = useTheme();

let content = (
<PageContainer>
<Link href={`/inbox/123`}>Item 123</Link>
</PageContainer>
);

if (router.pathname.startsWith('/inbox/')) {
content = <Content router={router} />;
}

return (
<AppProvider navigation={NAVIGATION} router={router} theme={theme}>
<Paper sx={{ width: '100%' }}>{content}</Paper>
</AppProvider>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import * as React from 'react';
import { PageContainer } from '@toolpad/core/PageContainer';
import { AppProvider, Router } from '@toolpad/core/AppProvider';
import { Link, useDemoRouter } from '@toolpad/core/internals';
import { useActivePage } from '@toolpad/core/useActivePage';
import { useTheme } from '@mui/material/styles';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import invariant from 'invariant';

const NAVIGATION = [
{
segment: 'inbox',
title: 'Orders',
pattern: '/inbox/:id',
},
];

interface ContentProps {
router: Router;
}

function Content({ router }: ContentProps) {
const id = Number(router.pathname.replace('/inbox/', ''));

const activePage = useActivePage();
invariant(activePage, 'No navigation match');

const title = `Item ${id}`;
const path = `${activePage.path}/${id}`;

const breadCrumbs = [...activePage.breadCrumbs, { title, path }];

return (
// preview-start
<PageContainer title={title} breadCrumbs={breadCrumbs}>
{/* preview-end */}
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Link href={`/inbox/${id - 1}`}>previous</Link>
<Link href={`/inbox/${id + 1}`}>next</Link>
</Box>
</PageContainer>
);
}

export default function CustomPageContainer() {
const router = useDemoRouter('/inbox/123');

const theme = useTheme();

let content = (
<PageContainer>
<Link href={`/inbox/123`}>Item 123</Link>
</PageContainer>
);

if (router.pathname.startsWith('/inbox/')) {
content = <Content router={router} />;
}

return (
<AppProvider navigation={NAVIGATION} router={router} theme={theme}>
<Paper sx={{ width: '100%' }}>{content}</Paper>
</AppProvider>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<PageContainer title={title} breadCrumbs={breadCrumbs}>
Original file line number Diff line number Diff line change
@@ -6,6 +6,7 @@ import { useTheme } from '@mui/material/styles';
import Paper from '@mui/material/Paper';

const NAVIGATION = [
{ segment: '', title: 'ACME' },
{
segment: 'inbox',
title: 'Home',
@@ -24,12 +25,7 @@ export default function TitleBreadcrumbsPageContainer() {
const theme = useTheme();

return (
<AppProvider
branding={{ title: 'ACME' }}
navigation={NAVIGATION}
router={router}
theme={theme}
>
<AppProvider navigation={NAVIGATION} router={router} theme={theme}>
<Paper sx={{ width: '100%' }}>
<PageContainer />
</Paper>
Original file line number Diff line number Diff line change
@@ -6,6 +6,7 @@ import { useTheme } from '@mui/material/styles';
import Paper from '@mui/material/Paper';

const NAVIGATION = [
{ segment: '', title: 'ACME' },
{
segment: 'inbox',
title: 'Home',
@@ -24,12 +25,7 @@ export default function TitleBreadcrumbsPageContainer() {
const theme = useTheme();

return (
<AppProvider
branding={{ title: 'ACME' }}
navigation={NAVIGATION}
router={router}
theme={theme}
>
<AppProvider navigation={NAVIGATION} router={router} theme={theme}>
<Paper sx={{ width: '100%' }}>
<PageContainer />
</Paper>
Original file line number Diff line number Diff line change
@@ -22,7 +22,6 @@ For example, under the following navigation structure:

```tsx
<AppProvider
branding={{ title: 'ACME' }}
navigation={[
{
segment: 'home',
@@ -44,6 +43,59 @@ The breadcrumbs contains **ACME / Home / Orders** when you visit the path **/hom

{{"demo": "TitleBreadcrumbsPageContainer.js", "height": 300, "hideToolbar": true}}

## Dynamic Routes

When you use the `PageContainer` on a dynamic route, you'll likely want to set a title and breadcrumbs belonging to the specific path. You can achieve this with the `title` and `breadCrumbs` property of the `PageContainer`

{{"demo": "CustomPageContainer.js", "height": 300}}

You can use the `useActivePage` hook to retrieve the title and breadcrumbs of the active page. This way you can extend the existing values.

```tsx
import { useActivePage } from '@toolpad/core/useActivePage';
import { BreadCrumb } from '@toolpad/core/PageContainer';

// Pass the id from your router of choice
function useDynamicBreadCrumbs(id: string): BreadCrumb[] {
const activePage = useActivePage();
invariant(activePage, 'No navigation match');

const title = `Item ${id}`;
const path = `${activePage.path}/${id}`;

return [...activePage.breadCrumbs, { title, path }];
}
```

For example, under the Next.js app router you would be able to obtain breadcrumbs for a dynamic route as follows:

```tsx
// ./src/app/example/[id]/page.tsx
'use client';

import { useParams } from 'next/navigation';
import { PageContainer } from '@toolpad/core/PageContainer';
import invariant from 'invariant';
import { useActivePage } from '@toolpad/core/useActivePage';

export default function Example() {
const params = useParams<{ id: string }>();
const activePage = useActivePage();
invariant(activePage, 'No navigation match');

const title = `Item ${params.id}`;
const path = `${activePage.path}/${params.id}`;

const breadCrumbs = [...activePage.breadCrumbs, { title, path }];

return (
<PageContainer title={title} breadCrumbs={breadCrumbs}>
...
</PageContainer>
);
}
```

## Actions

You can configure additional actions in the area that is reserved on the right. To do so provide the `toolbar` slot to the `PageContainer` component. You can wrap the `PageContainerToolbar` component to create a custom toolbar component, as shown here:
2 changes: 1 addition & 1 deletion docs/pages/toolpad/core/api/app-provider.json
Original file line number Diff line number Diff line change
@@ -19,7 +19,7 @@
"router": {
"type": {
"name": "shape",
"description": "{ navigate: func, pathname: string, searchParams?: URLSearchParams }"
"description": "{ navigate: func, pathname: string, searchParams: URLSearchParams }"
},
"default": "null"
},
12 changes: 11 additions & 1 deletion docs/pages/toolpad/core/api/page-container.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
{
"props": {},
"props": {
"breadCrumbs": {
"type": { "name": "arrayOf", "description": "Array&lt;{ path: string, title: string }&gt;" }
},
"slotProps": { "type": { "name": "shape", "description": "{ toolbar: { children?: node } }" } },
"slots": {
"type": { "name": "shape", "description": "{ toolbar?: elementType }" },
"additionalInfo": { "slotsApi": true }
},
"title": { "type": { "name": "string" } }
},
"name": "PageContainer",
"imports": [
"import { PageContainer } from '@toolpad/core/PageContainer';",
11 changes: 9 additions & 2 deletions docs/translations/api-docs/page-container/page-container.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
{
"componentDescription": "",
"propDescriptions": {},
"componentDescription": "A container component to provide a title and breadcrumbs for your pages.",
"propDescriptions": {
"breadCrumbs": {
"description": "The breadcrumbs of the page. Leave blank to use the active page breadcrumbs."
},
"slotProps": { "description": "The props used for each slot inside." },
"slots": { "description": "The components used for each slot inside." },
"title": { "description": "The title of the page. Leave blank to use the active page title." }
},
"classDescriptions": {
"disableGutters": {
"description": "Styles applied to {{nodeName}} if {{conditions}}.",
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -56,7 +56,7 @@
"@mui/internal-babel-plugin-resolve-imports": "1.0.18",
"@mui/internal-docs-utils": "1.0.13",
"@mui/internal-markdown": "1.0.14",
"@mui/internal-scripts": "1.0.21",
"@mui/internal-scripts": "1.0.21-dev.20240919-130050-82a6448768",
"@mui/monorepo": "github:mui/material-ui#3732be30e0ae3287b55dc6a96cf1ffcfdc194906",
"@mui/x-charts": "7.17.0",
"@next/eslint-plugin-next": "14.2.11",
4 changes: 2 additions & 2 deletions packages/toolpad-core/src/AppProvider/AppProvider.tsx
Original file line number Diff line number Diff line change
@@ -239,10 +239,10 @@ AppProvider.propTypes /* remove-proptypes */ = {
* Router implementation used inside Toolpad components.
* @default null
*/
router: PropTypes /* @typescript-to-proptypes-ignore */.shape({
router: PropTypes.shape({
navigate: PropTypes.func.isRequired,
pathname: PropTypes.string.isRequired,
searchParams: PropTypes.instanceOf(URLSearchParams),
searchParams: PropTypes.instanceOf(URLSearchParams).isRequired,
}),
/**
* Session info about the current user.
33 changes: 33 additions & 0 deletions packages/toolpad-core/src/PageContainer/PageContainer.test.tsx
Original file line number Diff line number Diff line change
@@ -48,6 +48,7 @@ describe('PageContainer', () => {

test('renders nested', async () => {
const navigation = [
{ segment: '', title: 'ACME' },
{
segment: 'home',
title: 'Home',
@@ -79,4 +80,36 @@ describe('PageContainer', () => {
expect(within(breadCrumbs).getByText('Home')).toBeTruthy();
expect(within(breadCrumbs).getByText('Orders')).toBeTruthy();
});

test('renders dynamic correctly', async () => {
const user = await userEvent.setup();
const router = {
pathname: '/orders/123',
searchParams: new URLSearchParams(),
navigate: vi.fn(),
};
render(
<AppProvider
navigation={[
{ segment: '', title: 'Home' },
{ segment: 'orders', title: 'Orders', pattern: '/orders/:id' },
]}
router={router}
>
<PageContainer />
</AppProvider>,
);

const breadCrumbs = screen.getByRole('navigation', { name: 'breadcrumb' });

const homeLink = within(breadCrumbs).getByRole('link', { name: 'Home' });
await user.click(homeLink);

expect(router.navigate).toHaveBeenCalledWith('/', expect.objectContaining({}));
router.navigate.mockClear();

expect(within(breadCrumbs).getByText('Orders')).toBeTruthy();

expect(screen.getByText('Orders', { ignore: 'nav *' }));
});
});
Loading
Loading