From d94129f41597756442b926e204e101c36cb4dcdc Mon Sep 17 00:00:00 2001 From: Jayen Date: Thu, 18 Dec 2025 23:00:45 +1100 Subject: [PATCH 1/2] [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 --- .../src/AppProvider/AppProvider.tsx | 2 + .../DashboardSidebarPageItem.tsx | 1 + .../toolpad-core/src/shared/Link.test.tsx | 116 ++++++++++++++ packages/toolpad-core/src/shared/Link.tsx | 2 +- .../src/shared/navigation.test.tsx | 146 ++++++++++++++++++ .../toolpad-core/src/shared/navigation.tsx | 49 ++++-- 6 files changed, 302 insertions(+), 14 deletions(-) create mode 100644 packages/toolpad-core/src/shared/Link.test.tsx create mode 100644 packages/toolpad-core/src/shared/navigation.test.tsx diff --git a/packages/toolpad-core/src/AppProvider/AppProvider.tsx b/packages/toolpad-core/src/AppProvider/AppProvider.tsx index 2c5c4f0e5d7..759016487ec 100644 --- a/packages/toolpad-core/src/AppProvider/AppProvider.tsx +++ b/packages/toolpad-core/src/AppProvider/AppProvider.tsx @@ -46,6 +46,7 @@ export interface NavigationPageItem { pattern?: string; action?: React.ReactNode; children?: Navigation; + searchParams?: URLSearchParams; } export interface NavigationSubheaderItem { @@ -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, }), diff --git a/packages/toolpad-core/src/DashboardLayout/DashboardSidebarPageItem.tsx b/packages/toolpad-core/src/DashboardLayout/DashboardSidebarPageItem.tsx index d419d00e6e6..964eda78232 100644 --- a/packages/toolpad-core/src/DashboardLayout/DashboardSidebarPageItem.tsx +++ b/packages/toolpad-core/src/DashboardLayout/DashboardSidebarPageItem.tsx @@ -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, diff --git a/packages/toolpad-core/src/shared/Link.test.tsx b/packages/toolpad-core/src/shared/Link.test.tsx new file mode 100644 index 00000000000..498a248c733 --- /dev/null +++ b/packages/toolpad-core/src/shared/Link.test.tsx @@ -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( + + Jobs + , + ); + + 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( + + About + , + ); + + 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( + + Products + , + ); + + 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( + + + Jobs + + , + ); + + 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(); + + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/jobs?page=2'); + }); +}); diff --git a/packages/toolpad-core/src/shared/Link.tsx b/packages/toolpad-core/src/shared/Link.tsx index 9c4814bf8dc..ba29966e20b 100644 --- a/packages/toolpad-core/src/shared/Link.tsx +++ b/packages/toolpad-core/src/shared/Link.tsx @@ -29,7 +29,7 @@ export const DefaultLink = React.forwardRef(function Link( return (event: React.MouseEvent) => { 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]); diff --git a/packages/toolpad-core/src/shared/navigation.test.tsx b/packages/toolpad-core/src/shared/navigation.test.tsx new file mode 100644 index 00000000000..008464daa8f --- /dev/null +++ b/packages/toolpad-core/src/shared/navigation.test.tsx @@ -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]); + }); + }); +}); diff --git a/packages/toolpad-core/src/shared/navigation.tsx b/packages/toolpad-core/src/shared/navigation.tsx index b959e292972..f83f4e955a0 100644 --- a/packages/toolpad-core/src/shared/navigation.tsx +++ b/packages/toolpad-core/src/shared/navigation.tsx @@ -23,23 +23,42 @@ export const getItemTitle = (item: NavigationPageItem | NavigationSubheaderItem) function buildItemToPathMap(navigation: Navigation): Map { const map = new Map(); - 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; @@ -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(); 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) { @@ -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; } } From 37e28e9e40cfe40c280ce89798ad782fe207eed0 Mon Sep 17 00:00:00 2001 From: Jayen Date: Thu, 18 Dec 2025 23:06:21 +1100 Subject: [PATCH 2/2] [docs] Document searchParams property for navigation items Add documentation for the new searchParams feature in navigation items, including usage examples and key behaviors for parameter inheritance, overriding, and clearing. --- .../dashboard-layout/dashboard-layout.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/docs/data/toolpad/core/components/dashboard-layout/dashboard-layout.md b/docs/data/toolpad/core/components/dashboard-layout/dashboard-layout.md index 06e75d36773..b5eeaed5798 100644 --- a/docs/data/toolpad/core/components/dashboard-layout/dashboard-layout.md +++ b/docs/data/toolpad/core/components/dashboard-layout/dashboard-layout.md @@ -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.