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. 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; } }