Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,44 @@ Some examples:

{{"demo": "DashboardLayoutPattern.js", "height": 400, "iframe": true}}

### Navigation with search parameters

Navigation links have an optional `searchParams` prop to include URL search parameters in the navigation links.
Child navigation items inherit parent search parameters by default, and can override or clear them.

```tsx
{
segment: 'reports',
title: 'Reports',
searchParams: new URLSearchParams({ view: 'summary' }),
children: [
{
segment: 'sales',
title: 'Sales',
// Inherits parent params: /reports/sales?view=summary
},
{
segment: 'inventory',
title: 'Inventory',
searchParams: new URLSearchParams({ view: 'detailed' }),
// Overrides parent param: /reports/inventory?view=detailed
},
{
segment: 'analytics',
title: 'Analytics',
searchParams: new URLSearchParams(),
// Clears inherited params: /reports/analytics
},
],
}
```

**Key behaviors:**
- Child items inherit parent `searchParams` by default
- Child `searchParams` override parent parameters with the same key
- Empty `URLSearchParams()` clears all inherited parameters
- Hash fragments in navigation links are preserved during navigation

### Disable collapsible sidebar

The layout sidebar is collapsible to a mini-drawer (with icons only) in desktop and tablet viewports. This behavior can be disabled with the `disableCollapsibleSidebar` prop.
Expand Down
2 changes: 2 additions & 0 deletions packages/toolpad-core/src/AppProvider/AppProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export interface NavigationPageItem {
pattern?: string;
action?: React.ReactNode;
children?: Navigation;
searchParams?: URLSearchParams;
}

export interface NavigationSubheaderItem {
Expand Down Expand Up @@ -244,6 +245,7 @@ AppProvider.propTypes /* remove-proptypes */ = {
icon: PropTypes.node,
kind: PropTypes.oneOf(['page']),
pattern: PropTypes.string,
searchParams: PropTypes.instanceOf(URLSearchParams),
segment: PropTypes.string,
title: PropTypes.string,
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ DashboardSidebarPageItem.propTypes /* remove-proptypes */ = {
icon: PropTypes.node,
kind: PropTypes.oneOf(['page']),
pattern: PropTypes.string,
searchParams: PropTypes.instanceOf(URLSearchParams),
segment: PropTypes.string,
title: PropTypes.string,
}).isRequired,
Expand Down
116 changes: 116 additions & 0 deletions packages/toolpad-core/src/shared/Link.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* @vitest-environment jsdom
*/

import * as React from 'react';
import { describe, test, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DefaultLink } from './Link';
import { RouterContext } from './context';
import type { Router } from '../AppProvider';

describe('DefaultLink', () => {
test('preserves search params and hash when navigating', async () => {
const user = userEvent.setup();
const navigate = vi.fn();

const router: Router = {
pathname: '/current',
searchParams: new URLSearchParams(),
navigate,
};

render(
<RouterContext.Provider value={router}>
<DefaultLink href="/jobs?page=2&filter=active#section">Jobs</DefaultLink>
</RouterContext.Provider>,
);

const link = screen.getByText('Jobs');
await user.click(link);

expect(navigate).toHaveBeenCalledWith('/jobs?page=2&filter=active#section', {
history: undefined,
});
});

test('preserves only hash when no search params', async () => {
const user = userEvent.setup();
const navigate = vi.fn();

const router: Router = {
pathname: '/current',
searchParams: new URLSearchParams(),
navigate,
};

render(
<RouterContext.Provider value={router}>
<DefaultLink href="/about#team">About</DefaultLink>
</RouterContext.Provider>,
);

const link = screen.getByText('About');
await user.click(link);

expect(navigate).toHaveBeenCalledWith('/about#team', { history: undefined });
});

test('preserves only search params when no hash', async () => {
const user = userEvent.setup();
const navigate = vi.fn();

const router: Router = {
pathname: '/current',
searchParams: new URLSearchParams(),
navigate,
};

render(
<RouterContext.Provider value={router}>
<DefaultLink href="/products?category=electronics">Products</DefaultLink>
</RouterContext.Provider>,
);

const link = screen.getByText('Products');
await user.click(link);

expect(navigate).toHaveBeenCalledWith('/products?category=electronics', {
history: undefined,
});
});

test('works with history prop', async () => {
const user = userEvent.setup();
const navigate = vi.fn();

const router: Router = {
pathname: '/current',
searchParams: new URLSearchParams(),
navigate,
};

render(
<RouterContext.Provider value={router}>
<DefaultLink href="/jobs?page=2" history="replace">
Jobs
</DefaultLink>
</RouterContext.Provider>,
);

const link = screen.getByText('Jobs');
await user.click(link);

expect(navigate).toHaveBeenCalledWith('/jobs?page=2', { history: 'replace' });
});

test('uses default anchor behavior when no router context', async () => {
const onClick = vi.fn((event: React.MouseEvent) => event.preventDefault());

render(<DefaultLink href="/jobs?page=2" onClick={onClick} />);

const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/jobs?page=2');
});
});
2 changes: 1 addition & 1 deletion packages/toolpad-core/src/shared/Link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const DefaultLink = React.forwardRef(function Link(
return (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
const url = new URL(event.currentTarget.href);
routerContext.navigate(url.pathname, { history });
routerContext.navigate(url.pathname + url.search + url.hash, { history });
onClick?.(event);
};
}, [routerContext, onClick, history]);
Expand Down
146 changes: 146 additions & 0 deletions packages/toolpad-core/src/shared/navigation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* @vitest-environment jsdom
*/

import { describe, test, expect } from 'vitest';
import type { Navigation } from '../AppProvider';
import { getItemPath, matchPath } from './navigation';

describe('navigation', () => {
describe('getItemPath', () => {
test('returns path without searchParams when not specified', () => {
const navigation: Navigation = [
{
segment: 'dashboard',
title: 'Dashboard',
},
];

const path = getItemPath(navigation, navigation[0] as any);
expect(path).toBe('/dashboard');
});

test('includes searchParams when specified', () => {
const searchParams = new URLSearchParams({ page: '2', filter: 'active' });
const navigation: Navigation = [
{
segment: 'jobs',
title: 'Jobs',
searchParams,
},
];

const path = getItemPath(navigation, navigation[0] as any);
expect(path).toBe('/jobs?page=2&filter=active');
});

test('inherits parent searchParams in nested navigation', () => {
const parentSearchParams = new URLSearchParams({ theme: 'dark' });
const navigation: Navigation = [
{
segment: 'reports',
title: 'Reports',
searchParams: parentSearchParams,
children: [
{
segment: 'sales',
title: 'Sales',
},
],
},
];

const parent = navigation[0] as any;
const child = parent.children[0];

expect(getItemPath(navigation, parent)).toBe('/reports?theme=dark');
expect(getItemPath(navigation, child)).toBe('/reports/sales?theme=dark');
});

test('child searchParams override parent searchParams', () => {
const parentSearchParams = new URLSearchParams({ foo: 'bar', baz: 'quux' });
const childSearchParams = new URLSearchParams({ foo: 'hello' });
const navigation: Navigation = [
{
segment: 'movies',
title: 'Movies',
searchParams: parentSearchParams,
children: [
{
segment: 'lord-of-the-rings',
title: 'Lord of the Rings',
searchParams: childSearchParams,
},
{
segment: 'harry-potter',
title: 'Harry Potter',
},
],
},
];

const parent = navigation[0] as any;
const child1 = parent.children[0];
const child2 = parent.children[1];

expect(getItemPath(navigation, parent)).toBe('/movies?foo=bar&baz=quux');
expect(getItemPath(navigation, child1)).toBe('/movies/lord-of-the-rings?foo=hello&baz=quux');
expect(getItemPath(navigation, child2)).toBe('/movies/harry-potter?foo=bar&baz=quux');
});

test('empty searchParams clears inherited searchParams', () => {
const parentSearchParams = new URLSearchParams({ foo: 'bar' });
const emptySearchParams = new URLSearchParams();
const navigation: Navigation = [
{
segment: 'movies',
title: 'Movies',
searchParams: parentSearchParams,
children: [
{
segment: 'dune',
title: 'Dune',
searchParams: emptySearchParams,
},
],
},
];

const parent = navigation[0] as any;
const child = parent.children[0];

expect(getItemPath(navigation, parent)).toBe('/movies?foo=bar');
expect(getItemPath(navigation, child)).toBe('/movies/dune');
});
});

describe('matchPath', () => {
test('matches path ignoring searchParams in navigation item', () => {
const navigation: Navigation = [
{
segment: 'jobs',
title: 'Jobs',
searchParams: new URLSearchParams({ page: '2' }),
},
];

// matchPath should match the pathname, ignoring the searchParams defined in nav
const match = matchPath(navigation, '/jobs');
expect(match).toBe(navigation[0]);
});

test('matches path with different search params in URL', () => {
const navigation: Navigation = [
{
segment: 'jobs',
title: 'Jobs',
searchParams: new URLSearchParams({ page: '2' }),
},
];

// Even if URL has different params, it should still match based on pathname
const match = matchPath(navigation, '/jobs?page=1');
expect(match).toBe(navigation[0]);
});
});
});
Loading