Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
[core] Add searchParams support to navigation items
This change adds the ability to specify search parameters for navigation items,
addressing user requests to include query strings in navigation links.

Key changes:
- Add optional searchParams property to NavigationPageItem interface
- Update Link component to preserve search params and hash during navigation
- Implement hierarchical searchParams merging in navigation path building
- Child items inherit parent searchParams by default
- Child items can override specific parent parameters
- Empty URLSearchParams clears all inherited parameters
- matchPath now matches on pathname only, ignoring search params for correct active page detection

The implementation provides explicit control over search parameters while
supporting inheritance patterns. This allows developers to define search
parameters at parent levels that cascade to children, with children able
to override or clear those parameters as needed.

Fixes #4537
  • Loading branch information
jayenashar committed Dec 18, 2025
commit d94129f41597756442b926e204e101c36cb4dcdc
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]);
});
});
});
49 changes: 36 additions & 13 deletions packages/toolpad-core/src/shared/navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,42 @@ export const getItemTitle = (item: NavigationPageItem | NavigationSubheaderItem)
function buildItemToPathMap(navigation: Navigation): Map<NavigationPageItem, string> {
const map = new Map<NavigationPageItem, string>();

const visit = (item: NavigationItem, base: string) => {
const visit = (item: NavigationItem, base: string, parentSearchParams?: URLSearchParams) => {
if (isPageItem(item)) {
// Append segment to base path. Make sure to always have an initial slash, and slashes between segments.
const path =
const pathname =
`${base.startsWith('/') ? base : `/${base}`}${base && base !== '/' && item.segment ? '/' : ''}${item.segment || ''}` ||
'/';

// Merge parent searchParams with item's searchParams
// If item has searchParams defined (even if empty), it replaces parent params
let searchParams = parentSearchParams;
if (item.searchParams !== undefined) {
searchParams = new URLSearchParams(item.searchParams);
// If parent params exist and item params is not empty, merge them
if (parentSearchParams && item.searchParams.size > 0) {
for (const [key, value] of parentSearchParams.entries()) {
if (!searchParams.has(key)) {
searchParams.set(key, value);
}
}
}
}

const searchString = searchParams && searchParams.size > 0 ? `?${searchParams.toString()}` : '';
const path = pathname + searchString;

map.set(item, path);
if (item.children) {
for (const child of item.children) {
visit(child, path);
visit(child, pathname, searchParams);
}
}
}
};

for (const item of navigation) {
visit(item, '');
visit(item, '', undefined);
}

return map;
Expand All @@ -61,20 +80,22 @@ function getItemToPathMap(navigation: Navigation) {

/**
* Build a lookup map of paths to navigation items. This map is used to match paths against
* to find the active page.
* to find the active page. Only pathname is used for matching, searchParams are ignored.
*/
function buildItemLookup(navigation: Navigation) {
const map = new Map<string | RegExp, NavigationPageItem>();
const visit = (item: NavigationItem) => {
if (isPageItem(item)) {
const path = getItemPath(navigation, item);
if (map.has(path)) {
console.warn(`Duplicate path in navigation: ${path}`);
const fullPath = getItemPath(navigation, item);
// Extract pathname without search params for matching
const pathname = fullPath.split('?')[0];
if (map.has(pathname)) {
console.warn(`Duplicate path in navigation: ${pathname}`);
}

map.set(path, item);
map.set(pathname, item);
if (item.pattern) {
const basePath = item.segment ? path.slice(0, -item.segment.length) : path;
const basePath = item.segment ? pathname.slice(0, -item.segment.length) : pathname;
map.set(pathToRegexp(basePath + item.pattern), item);
}
if (item.children) {
Expand All @@ -101,16 +122,18 @@ function getItemLookup(navigation: Navigation) {

/**
* Matches a path against the navigation to find the active page. i.e. the page that should be
* marked as selected in the navigation.
* marked as selected in the navigation. Only the pathname is matched, search params are ignored.
*/
export function matchPath(navigation: Navigation, path: string): NavigationPageItem | null {
const lookup = getItemLookup(navigation);
// Strip search params and hash from the path for matching
const pathname = path.split('?')[0].split('#')[0];

for (const [key, item] of lookup.entries()) {
if (typeof key === 'string' && key === path) {
if (typeof key === 'string' && key === pathname) {
return item;
}
if (key instanceof RegExp && key.test(path)) {
if (key instanceof RegExp && key.test(pathname)) {
return item;
}
}
Expand Down