Skip to content

Commit

Permalink
chore(router): Move useMatch to its own file (redwoodjs#9770)
Browse files Browse the repository at this point in the history
  • Loading branch information
Tobbe authored Dec 28, 2023
1 parent ce96c02 commit 4263f46
Show file tree
Hide file tree
Showing 6 changed files with 169 additions and 141 deletions.
77 changes: 1 addition & 76 deletions packages/router/src/__tests__/links.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@ import React from 'react'

import { render } from '@testing-library/react'

import { NavLink, useMatch, Link } from '../links'
import { NavLink } from '../links'
import { LocationProvider } from '../location'
import { flattenSearchParams } from '../util'

function createDummyLocation(pathname: string, search = '') {
return {
Expand Down Expand Up @@ -279,77 +278,3 @@ describe('<NavLink />', () => {
expect(getByText(/Dunder Mifflin/)).not.toHaveClass('activeTest')
})
})

describe('useMatch', () => {
const MyLink = ({
to,
...rest
}: React.ComponentPropsWithoutRef<typeof Link>) => {
const [pathname, queryString] = to.split('?')
const matchInfo = useMatch(pathname, {
searchParams: flattenSearchParams(queryString),
})

return (
<Link
to={to}
style={{ color: matchInfo.match ? 'green' : 'red' }}
{...rest}
/>
)
}

it('returns a match on the same pathname', () => {
const mockLocation = createDummyLocation('/dunder-mifflin')

const { getByText } = render(
<LocationProvider location={mockLocation}>
<MyLink to="/dunder-mifflin">Dunder Mifflin</MyLink>
</LocationProvider>
)

expect(getByText(/Dunder Mifflin/)).toHaveStyle('color: green')
})

it('returns a match on the same pathname with search parameters', () => {
const mockLocation = createDummyLocation(
'/search-params',
'?page=1&tab=main'
)

const { getByText } = render(
<LocationProvider location={mockLocation}>
<MyLink to={`/search-params?tab=main&page=1`}>Dunder Mifflin</MyLink>
</LocationProvider>
)

expect(getByText(/Dunder Mifflin/)).toHaveStyle('color: green')
})

it('does NOT receive active class on different path', () => {
const mockLocation = createDummyLocation('/staples')

const { getByText } = render(
<LocationProvider location={mockLocation}>
<MyLink to="/dunder-mifflin">Dunder Mifflin</MyLink>
</LocationProvider>
)

expect(getByText(/Dunder Mifflin/)).toHaveStyle('color: red')
})

it('does NOT receive active class on the same pathname with different parameters', () => {
const mockLocation = createDummyLocation(
'/search-params',
'?tab=main&page=1'
)

const { getByText } = render(
<LocationProvider location={mockLocation}>
<MyLink to={`/search-params?page=2&tab=main`}>Dunder Mifflin</MyLink>
</LocationProvider>
)

expect(getByText(/Dunder Mifflin/)).toHaveStyle('color: red')
})
})
100 changes: 100 additions & 0 deletions packages/router/src/__tests__/useMatch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import React from 'react'

import { render } from '@testing-library/react'

import { Link } from '../links'
import { LocationProvider } from '../location'
import { useMatch } from '../useMatch'
import { flattenSearchParams } from '../util'

function createDummyLocation(pathname: string, search = '') {
return {
pathname,
hash: '',
host: '',
hostname: '',
href: '',
ancestorOrigins: null,
assign: () => null,
reload: () => null,
replace: () => null,
origin: '',
port: '',
protocol: '',
search,
}
}

describe('useMatch', () => {
const MyLink = ({
to,
...rest
}: React.ComponentPropsWithoutRef<typeof Link>) => {
const [pathname, queryString] = to.split('?')
const matchInfo = useMatch(pathname, {
searchParams: flattenSearchParams(queryString),
})

return (
<Link
to={to}
style={{ color: matchInfo.match ? 'green' : 'red' }}
{...rest}
/>
)
}

it('returns a match on the same pathname', () => {
const mockLocation = createDummyLocation('/dunder-mifflin')

const { getByText } = render(
<LocationProvider location={mockLocation}>
<MyLink to="/dunder-mifflin">Dunder Mifflin</MyLink>
</LocationProvider>
)

expect(getByText(/Dunder Mifflin/)).toHaveStyle('color: green')
})

it('returns a match on the same pathname with search parameters', () => {
const mockLocation = createDummyLocation(
'/search-params',
'?page=1&tab=main'
)

const { getByText } = render(
<LocationProvider location={mockLocation}>
<MyLink to={`/search-params?tab=main&page=1`}>Dunder Mifflin</MyLink>
</LocationProvider>
)

expect(getByText(/Dunder Mifflin/)).toHaveStyle('color: green')
})

it('does NOT receive active class on different path', () => {
const mockLocation = createDummyLocation('/staples')

const { getByText } = render(
<LocationProvider location={mockLocation}>
<MyLink to="/dunder-mifflin">Dunder Mifflin</MyLink>
</LocationProvider>
)

expect(getByText(/Dunder Mifflin/)).toHaveStyle('color: red')
})

it('does NOT receive active class on the same pathname with different parameters', () => {
const mockLocation = createDummyLocation(
'/search-params',
'?tab=main&page=1'
)

const { getByText } = render(
<LocationProvider location={mockLocation}>
<MyLink to={`/search-params?page=2&tab=main`}>Dunder Mifflin</MyLink>
</LocationProvider>
)

expect(getByText(/Dunder Mifflin/)).toHaveStyle('color: red')
})
})
3 changes: 2 additions & 1 deletion packages/router/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// latter of which has closely inspired some of this code).

export { navigate, back } from './history'
export { Link, NavLink, useMatch, Redirect } from './links'
export { Link, NavLink, Redirect } from './links'
export { useLocation, LocationProvider } from './location'
export {
usePageLoadingContext,
Expand All @@ -20,6 +20,7 @@ export { default as RouteFocus } from './route-focus'
export * from './route-focus'
export * from './useRouteName'
export * from './useRoutePaths'
export * from './useMatch'

export { parseSearch, getRouteRegexAndParams, matchPath } from './util'

Expand Down
66 changes: 4 additions & 62 deletions packages/router/src/links.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,67 +2,9 @@ import { forwardRef, useEffect } from 'react'

import type { NavigateOptions } from './history'
import { navigate } from './history'
import { useLocation } from './location'
import { flattenSearchParams, matchPath } from './util'

type FlattenSearchParams = ReturnType<typeof flattenSearchParams>
type UseMatchOptions = {
searchParams?: FlattenSearchParams
matchSubPaths?: boolean
}

/**
* Returns an object of { match: boolean; params: Record<string, unknown>; }
* 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 searchParams options to match the current location.search
*
* This is useful for components that need to know "active" state, e.g.
* <NavLink>.
*
* Examples:
*
* Match search params key existence
* const match = useMatch('/about', { searchParams: ['category', 'page'] })
*
* Match search params key and value
* const match = useMatch('/items', { searchParams: [{page: 2}, {category: 'book'}] })
*
* Mix match
* const match = useMatch('/list', { searchParams: [{page: 2}, 'gtm'] })
*
* Match sub paths
* const match = useMatch('/product', { matchSubPaths: true })
*
*/
const useMatch = (pathname: string, options?: UseMatchOptions) => {
const location = useLocation()
if (!location) {
return { match: false }
}

if (options?.searchParams) {
const locationParams = new URLSearchParams(location.search)
const hasUnmatched = options.searchParams.some((param) => {
if (typeof param === 'string') {
return !locationParams.has(param)
} else {
return Object.keys(param).some(
(key) => param[key] != locationParams.get(key)
)
}
})

if (hasUnmatched) {
return { match: false }
}
}

return matchPath(pathname, location.pathname, {
matchSubPaths: options?.matchSubPaths,
})
}
import { useMatch } from './useMatch'
import type { FlattenSearchParams } from './util'
import { flattenSearchParams } from './util'

interface LinkProps {
to: string
Expand Down Expand Up @@ -187,4 +129,4 @@ const Redirect = ({ to, options }: RedirectProps) => {
return null
}

export { Link, NavLink, useMatch, Redirect }
export { Link, NavLink, Redirect }
60 changes: 60 additions & 0 deletions packages/router/src/useMatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useLocation } from './location'
import { matchPath } from './util'
import type { FlattenSearchParams } from './util'

type UseMatchOptions = {
searchParams?: FlattenSearchParams
matchSubPaths?: boolean
}

/**
* Returns an object of { match: boolean; params: Record<string, unknown>; }
* 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 searchParams options to match the current location.search
*
* This is useful for components that need to know "active" state, e.g.
* <NavLink>.
*
* Examples:
*
* Match search params key existence
* const match = useMatch('/about', { searchParams: ['category', 'page'] })
*
* Match search params key and value
* const match = useMatch('/items', { searchParams: [{page: 2}, {category: 'book'}] })
*
* Mix match
* const match = useMatch('/list', { searchParams: [{page: 2}, 'gtm'] })
*
* Match sub paths
* const match = useMatch('/product', { matchSubPaths: true })
*/
export const useMatch = (pathname: string, options?: UseMatchOptions) => {
const location = useLocation()
if (!location) {
return { match: false }
}

if (options?.searchParams) {
const locationParams = new URLSearchParams(location.search)
const hasUnmatched = options.searchParams.some((param) => {
if (typeof param === 'string') {
return !locationParams.has(param)
} else {
return Object.keys(param).some(
(key) => param[key] != locationParams.get(key)
)
}
})

if (hasUnmatched) {
return { match: false }
}
}

return matchPath(pathname, location.pathname, {
matchSubPaths: options?.matchSubPaths,
})
}
4 changes: 2 additions & 2 deletions packages/router/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,8 +348,9 @@ export function replaceParams(
return path
}

export type FlattenSearchParams = ReturnType<typeof flattenSearchParams>

/**
*
* @param {string} queryString
* @returns {Array<string | Record<string, any>>} A flat array of search params
*
Expand All @@ -362,7 +363,6 @@ export function replaceParams(
*
* flattenSearchParams(parseSearch('?key1=val1&key2=val2'))
* => [ { key1: 'val1' }, { key2: 'val2' } ]
*
*/
export function flattenSearchParams(
queryString: string
Expand Down

0 comments on commit 4263f46

Please sign in to comment.