From d27db47faa37702ca116c103427170a5b5c96894 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 3 Jan 2024 10:48:35 +0100 Subject: [PATCH 1/7] Add routeParams to useMatch --- .../router/src/__tests__/useMatch.test.tsx | 224 +++++++++++++++++- packages/router/src/useMatch.ts | 30 ++- 2 files changed, 252 insertions(+), 2 deletions(-) diff --git a/packages/router/src/__tests__/useMatch.test.tsx b/packages/router/src/__tests__/useMatch.test.tsx index 89d7bcdd8419..ae53a231b06b 100644 --- a/packages/router/src/__tests__/useMatch.test.tsx +++ b/packages/router/src/__tests__/useMatch.test.tsx @@ -1,6 +1,8 @@ import React from 'react' -import { render } from '@testing-library/react' +import '@testing-library/jest-dom' + +import { render, renderHook as tlrRenderHook } from '@testing-library/react' import { Link } from '../links' import { LocationProvider } from '../location' @@ -97,4 +99,224 @@ describe('useMatch', () => { expect(getByText(/Dunder Mifflin/)).toHaveStyle('color: red') }) + + describe('routeParams', () => { + const mockLocation = createDummyLocation('/dummy-location') + + type CallbackType = () => ReturnType + function renderHook(cb: CallbackType) { + return tlrRenderHook(cb, { + wrapper: ({ children }) => ( + + {children} + + ), + }) + } + + function setLocation(pathname: string, search = '') { + mockLocation.pathname = pathname + mockLocation.search = search + } + + afterEach(() => { + setLocation('/dummy-location') + }) + + it('matches a path with literal route param', () => { + setLocation('/test-path/foobar') + + const { result } = renderHook(() => useMatch('/test-path/{param}')) + + expect(result.current.match).toBeTruthy() + }) + + it('matches a path with given route param value', () => { + setLocation('/posts/uuid-string') + + const { result } = renderHook(() => + useMatch('/posts/{id}', { routeParams: { id: 'uuid-string' } }) + ) + + expect(result.current.match).toBeTruthy() + }) + + it("doesn't match a path with different route param value", () => { + setLocation('/posts/uuid-string') + + const { result } = renderHook(() => + useMatch('/posts/{id}', { routeParams: { id: 'other-uuid-string' } }) + ) + + expect(result.current.match).toBeFalsy() + }) + + it('matches a path with default param type', () => { + setLocation('/posts/123') + + const { result } = renderHook(() => + useMatch('/posts/{id}', { routeParams: { id: '123' } }) + ) + + expect(result.current.match).toBeTruthy() + }) + + it('matches a path with a specified param type', () => { + setLocation('/posts/123') + + const { result } = renderHook(() => + useMatch('/posts/{id:Int}', { routeParams: { id: 123 } }) + ) + + expect(result.current.match).toBeTruthy() + }) + + it("doesn't match a path with a specified param type with different value", () => { + setLocation('/posts/123') + + const { result } = renderHook(() => + useMatch('/posts/{id:Int}', { routeParams: { id: '123' } }) + ) + + expect(result.current.match).toBeFalsy() + }) + + it('matches with a subset of param values specified (year, month)', () => { + setLocation('/year/1970/month/08/day/21') + + const { result } = renderHook(() => + useMatch('/year/{year}/month/{month}/day/{day}', { + routeParams: { year: '1970', month: '08' }, + }) + ) + + expect(result.current.match).toBeTruthy() + }) + + it('matches with a subset of param values specified (month)', () => { + setLocation('/year/1970/month/08/day/21') + + const { result } = renderHook(() => + useMatch('/year/{year}/month/{month}/day/{day}', { + routeParams: { month: '08' }, + }) + ) + + expect(result.current.match).toBeTruthy() + }) + + it('matches with a subset of param values specified (day)', () => { + const useMatchHook = () => + useMatch('/year/{year}/month/{month}/day/{day}', { + routeParams: { day: '21' }, + }) + + setLocation('/year/1970/month/08/day/21') + const { result: result1970 } = renderHook(useMatchHook) + expect(result1970.current.match).toBeTruthy() + + setLocation('/year/1970/month/01/day/21') + const { result: resultJan } = renderHook(useMatchHook) + expect(resultJan.current.match).toBeTruthy() + + setLocation('/year/2024/month/08/day/21') + const { result: result2024 } = renderHook(useMatchHook) + expect(result2024.current.match).toBeTruthy() + }) + + it("doesn't match with a subset of wrong param values specified (month)", () => { + setLocation('/year/1970/month/08/day/21') + + const { result } = renderHook(() => + useMatch('/year/{year}/month/{month}/day/{day}', { + routeParams: { month: '01' }, + }) + ) + + expect(result.current.match).toBeFalsy() + }) + + it("doesn't match with a subset of wrong param values specified (day)", () => { + setLocation('/year/1970/month/08/day/21') + + const { result } = renderHook(() => + useMatch('/year/{year}/month/{month}/day/{day}', { + routeParams: { day: '31' }, + }) + ) + + expect(result.current.match).toBeFalsy() + }) + }) + + describe('routeParams + searchParams', () => { + const mockLocation = createDummyLocation('/dummy-location') + + type CallbackType = () => ReturnType + function renderHook(cb: CallbackType) { + return tlrRenderHook(cb, { + wrapper: ({ children }) => ( + + {children} + + ), + }) + } + + function setLocation(pathname: string, search = '') { + mockLocation.pathname = pathname + mockLocation.search = search + } + + afterEach(() => { + setLocation('/dummy-location') + }) + + it('matches a path with literal route param', () => { + setLocation('/test-path/foobar', '?s1=one&s2=two') + + const { result } = renderHook(() => useMatch('/test-path/{param}')) + + expect(result.current.match).toBeTruthy() + }) + + it('matches a path with literal route param and given searchParam', () => { + setLocation('/test-path/foobar', '?s1=one&s2=two') + + const { result } = renderHook(() => + useMatch('/test-path/{param}', { + searchParams: [{ s1: 'one' }], + }) + ) + + expect(result.current.match).toBeTruthy() + }) + + it("doesn't match a path with wrong route param value and given searchParam", () => { + setLocation('/test-path/foobar', '?s1=one&s2=two') + + const { result } = renderHook(() => + useMatch('/test-path/{param}', { + routeParams: { param: 'wrong' }, + searchParams: [{ s1: 'one' }], + }) + ) + + expect(result.current.match).toBeFalsy() + }) + + it('matches a deeper path with matchSubPaths', () => { + setLocation('/test-path/foobar/fizz/buzz', '?s1=one&s2=two') + + const { result } = renderHook(() => + useMatch('/test-path/{param}/{param-two}', { + routeParams: { ['param-two']: 'fizz' }, + searchParams: [{ s1: 'one' }], + matchSubPaths: true, + }) + ) + + expect(result.current.match).toBeTruthy() + }) + }) }) diff --git a/packages/router/src/useMatch.ts b/packages/router/src/useMatch.ts index 180e7a93e094..85f7af9e304b 100644 --- a/packages/router/src/useMatch.ts +++ b/packages/router/src/useMatch.ts @@ -3,6 +3,7 @@ import { matchPath } from './util' import type { FlattenSearchParams } from './util' type UseMatchOptions = { + routeParams?: Record searchParams?: FlattenSearchParams matchSubPaths?: boolean } @@ -54,7 +55,34 @@ export const useMatch = (pathname: string, options?: UseMatchOptions) => { } } - return matchPath(pathname, location.pathname, { + const matchInfo = matchPath(pathname, location.pathname, { matchSubPaths: options?.matchSubPaths, }) + + if (!matchInfo.match) { + return { match: false } + } + + const routeParams = Object.entries(options?.routeParams || {}) + + if (routeParams.length > 0) { + if (!isMatchWithParams(matchInfo) || !matchInfo.params) { + return { match: false } + } + + // If paramValues were given, they must all match + const isParamMatch = routeParams.every(([key, value]) => { + return matchInfo.params[key] === value + }) + + if (!isParamMatch) { + return { match: false } + } + } + + return matchInfo +} + +function isMatchWithParams(match: unknown): match is { params: any } { + return match !== null && typeof match === 'object' && 'params' in match } From 03f2e4874d1881d7adb55f44e633ef70f39f3475 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 3 Jan 2024 10:56:51 +0100 Subject: [PATCH 2/7] Add doc comment with routeParams example --- packages/router/src/useMatch.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/router/src/useMatch.ts b/packages/router/src/useMatch.ts index 85f7af9e304b..d1d74495a002 100644 --- a/packages/router/src/useMatch.ts +++ b/packages/router/src/useMatch.ts @@ -31,6 +31,9 @@ type UseMatchOptions = { * * Match sub paths * const match = useMatch('/product', { matchSubPaths: true }) + * + * Match only specific route param values + * const match = useMatch('/product/{category}/{id}', { routeParams: { category: 'shirts' } }) */ export const useMatch = (pathname: string, options?: UseMatchOptions) => { const location = useLocation() From b0272e55daede166349468e34085b86479e0859c Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 3 Jan 2024 10:58:36 +0100 Subject: [PATCH 3/7] More docs comments updates --- packages/router/src/useMatch.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/router/src/useMatch.ts b/packages/router/src/useMatch.ts index d1d74495a002..66e55808eb07 100644 --- a/packages/router/src/useMatch.ts +++ b/packages/router/src/useMatch.ts @@ -10,9 +10,10 @@ type UseMatchOptions = { /** * Returns an object of { match: boolean; params: Record; } - * if the path matches the current location match will be true. + * If the path matches the current location `match` will be true. * Params will be an object of the matched params, if there are any. * + * Provide routeParams options to match specific route param values * Provide searchParams options to match the current location.search * * This is useful for components that need to know "active" state, e.g. From eece6a03920782f1d0573e6c65346b798ee06f45 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 3 Jan 2024 11:04:56 +0100 Subject: [PATCH 4/7] routePath --- docs/docs/router.md | 14 ++++++++++++++ packages/router/src/useMatch.ts | 4 ++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/docs/router.md b/docs/docs/router.md index 16783e872491..86e8544f9b63 100644 --- a/docs/docs/router.md +++ b/docs/docs/router.md @@ -278,6 +278,20 @@ const CustomLink = ({ to, ...rest }) => { } ``` +Passing in `routeParams` you can make it match only on specific route parameter +values. + +```jsx +const match = useMatch('/product/{category}/{id}', { + routeParams: { category: 'shirts' } +}) +``` + +The above example will match /product/shirts/213, but not /product/pants/213 +(whereas not specifying `routeParams` at all would match both) + +See below for more info on route parameters + ## Route parameters To match variable data in a path, you can use route parameters, which are specified by a parameter name surrounded by curly braces: diff --git a/packages/router/src/useMatch.ts b/packages/router/src/useMatch.ts index 66e55808eb07..c2c5ac52e943 100644 --- a/packages/router/src/useMatch.ts +++ b/packages/router/src/useMatch.ts @@ -36,7 +36,7 @@ type UseMatchOptions = { * Match only specific route param values * const match = useMatch('/product/{category}/{id}', { routeParams: { category: 'shirts' } }) */ -export const useMatch = (pathname: string, options?: UseMatchOptions) => { +export const useMatch = (routePath: string, options?: UseMatchOptions) => { const location = useLocation() if (!location) { return { match: false } @@ -59,7 +59,7 @@ export const useMatch = (pathname: string, options?: UseMatchOptions) => { } } - const matchInfo = matchPath(pathname, location.pathname, { + const matchInfo = matchPath(routePath, location.pathname, { matchSubPaths: options?.matchSubPaths, }) From d37faf5fe4ff4833c5b8d4b35799f66014daa39f Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 3 Jan 2024 11:07:49 +0100 Subject: [PATCH 5/7] Consistent periods --- docs/docs/router.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/router.md b/docs/docs/router.md index 86e8544f9b63..36579f1348df 100644 --- a/docs/docs/router.md +++ b/docs/docs/router.md @@ -288,9 +288,9 @@ const match = useMatch('/product/{category}/{id}', { ``` The above example will match /product/shirts/213, but not /product/pants/213 -(whereas not specifying `routeParams` at all would match both) +(whereas not specifying `routeParams` at all would match both). -See below for more info on route parameters +See below for more info on route parameters. ## Route parameters From 97d458446e1ebb9e3f879b5f03b71c7dc163b105 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 3 Jan 2024 11:12:41 +0100 Subject: [PATCH 6/7] Link to useRoutePaths --- docs/docs/router.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/docs/router.md b/docs/docs/router.md index 36579f1348df..61736e16c597 100644 --- a/docs/docs/router.md +++ b/docs/docs/router.md @@ -290,6 +290,9 @@ const match = useMatch('/product/{category}/{id}', { The above example will match /product/shirts/213, but not /product/pants/213 (whereas not specifying `routeParams` at all would match both). +To get the path you need to pass to `useMatch` you can use +[`useRoutePaths`](#useroutepaths) or [`useRoutePath`](#useroutepath) + See below for more info on route parameters. ## Route parameters From ff08d1eb82f690917942cd5bfc2fcd37eace8566 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 3 Jan 2024 11:45:07 +0100 Subject: [PATCH 7/7] Add another example to the docs --- docs/docs/router.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/docs/router.md b/docs/docs/router.md index 61736e16c597..f07df829f4ff 100644 --- a/docs/docs/router.md +++ b/docs/docs/router.md @@ -293,6 +293,25 @@ The above example will match /product/shirts/213, but not /product/pants/213 To get the path you need to pass to `useMatch` you can use [`useRoutePaths`](#useroutepaths) or [`useRoutePath`](#useroutepath) +Here's an example: + +```jsx + + +const animalRoutePath = useRoutePath('animal') +// => '/{animal}/{name}' + +const matchOnlyDog = useMatch(animalRoutePath, { routeParams: { animal: 'dog' }}) +const matchFullyDynamic = useMatch(animalRoutePath) +``` + +In the above example, if the current page url was +`https://example.org/dog/fido` then both `matchOnlyDog` and `matchFullyDynamic` +would have `match: true`. + +If the current page instead was `https://example.org/cat/garfield` then only +`matchFullyDynamic` would match + See below for more info on route parameters. ## Route parameters