Skip to content

Commit

Permalink
Router: <Set wrap={}>
Browse files Browse the repository at this point in the history
  • Loading branch information
Tobbe committed Mar 14, 2021
1 parent 4fc0812 commit ed3dc2d
Show file tree
Hide file tree
Showing 13 changed files with 725 additions and 280 deletions.
4 changes: 2 additions & 2 deletions packages/router/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ This is the built-in router for Redwood apps. It takes inspiration from Ruby on

> **WARNING:** RedwoodJS software has not reached a stable version 1.0 and should not be considered suitable for production use. In the "make it work; make it right; make it fast" paradigm, Redwood is in the later stages of the "make it work" phase.
Redwood Router (RR from now on) is designed to list all routes in a single file, without any nesting. We prefer this design, as it makes it very easy to track which routes map to which pages.
Redwood Router (RR from now on) is designed to list all routes in a single file, with limited nesting. We prefer this design, as it makes it very easy to track which routes map to which pages.

## Router and Route

The first thing you need is a `Router`. It will contain all of your routes. RR will attempt to match the current URL to each route in turn, stopping when it finds a match, and rendering that route only. The only exception to this is the `notfound` route, which can be placed anywhere in the list and only matches when no other routes do.
The first thing you need is a `Router`. It will contain all of your routes. RR will attempt to match the current URL to each route in turn, and only render those with a matching `path`. The only exception to this is the `notfound` route, which can be placed anywhere in the list and only matches when no other routes do.

Each route is specified with a `Route`. Our first route will tell RR what to render when no other route matches:

Expand Down
52 changes: 52 additions & 0 deletions packages/router/src/Set.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React, { ReactElement, ReactNode, FunctionComponentElement } from 'react'

import { useLocation } from './location'
import { isRoute } from './router'
import { useRouterState } from './router-context'
import { flattenAll, matchPath } from './util'

interface PropsWithChildren {
children: ReactNode
}

type WrapperType = (props: { children: any }) => ReactElement | null
type ReduceType = FunctionComponentElement<PropsWithChildren> | undefined

interface Props {
wrap: WrapperType | WrapperType[]
children: ReactNode
}

export const Set: React.FC<Props> = ({ children, wrap }) => {
const routerState = useRouterState()
const location = useLocation()
const wrappers = Array.isArray(wrap) ? wrap : [wrap]

const flatChildArray = flattenAll(children)

const matchingChildRoute = flatChildArray.some((child) => {
if (isRoute(child)) {
const { path } = child.props

if (path) {
const { match } = matchPath(
path,
location.pathname,
routerState.paramTypes
)

if (match) {
return true
}
}
}

return false
})

return matchingChildRoute
? wrappers.reduceRight<ReduceType>((acc, wrapper) => {
return React.createElement(wrapper, undefined, acc ? acc : children)
}, undefined) || null
: null
}
225 changes: 218 additions & 7 deletions packages/router/src/__tests__/router.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { render, waitFor, act } from '@testing-library/react'
import React from 'react'

import { render, waitFor, act, fireEvent } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'

import { AuthContextInterface } from '@redwoodjs/auth'

import { Router, Route, Private, Redirect, navigate, routes } from '../'
import { resetNamedRoutes } from '../named-routes'
import { Router, Route, Private, Redirect, navigate, routes, Link } from '../'
import { Set } from '../Set'

function createDummyAuthContextValues(partial: Partial<AuthContextInterface>) {
const authContextValues: AuthContextInterface = {
Expand Down Expand Up @@ -34,6 +36,7 @@ const LoginPage = () => <h1>Login Page</h1>
const AboutPage = () => <h1>About Page</h1>
const PrivatePage = () => <h1>Private Page</h1>
const RedirectPage = () => <Redirect to="/about" />
const NotFoundPage = () => <h1>404</h1>
const mockAuth = (isAuthenticated = false) => {
window.__REDWOOD__USE_AUTH = () =>
createDummyAuthContextValues({
Expand All @@ -44,11 +47,12 @@ const mockAuth = (isAuthenticated = false) => {

beforeEach(() => {
window.history.pushState({}, null, '/')
resetNamedRoutes()
Object.keys(routes).forEach((key) => delete routes[key])
})

test('inits routes and navigates as expected', async () => {
mockAuth(false)

const TestRouter = () => (
<Router useAuth={window.__REDWOOD__USE_AUTH}>
<Route path="/" page={HomePage} name="home" />
Expand All @@ -58,12 +62,14 @@ test('inits routes and navigates as expected', async () => {
<Private unauthenticated="home">
<Route path="/private" page={PrivatePage} name="private" />
</Private>

<Route
path="/param-test/{value}"
page={({ value }: { value: string }) => <div>param {value}</div>}
page={({ value, q }: { value: string; q: string }) => (
<div>param {`${value}${q}`}</div>
)}
name="params"
/>
<Route notfound page={NotFoundPage} />
</Router>
)

Expand All @@ -76,6 +82,10 @@ test('inits routes and navigates as expected', async () => {
act(() => navigate(routes.about()))
await waitFor(() => screen.getByText(/About Page/i))

// passes search params to the page
act(() => navigate(routes.params({ value: 'val', q: 'q' })))
await waitFor(() => screen.getByText('param valq'))

// navigate to redirect page
// should redirect to about
act(() => navigate(routes.redirect()))
Expand All @@ -84,14 +94,23 @@ test('inits routes and navigates as expected', async () => {
expect(screen.queryByText(/About Page/)).toBeTruthy()
})

act(() => navigate('/redirect2/redirected?q=cue'))
await waitFor(() => screen.getByText(/param redirectedcue/i))

// navigate to redirect2 page
// should redirect to /param-test
act(() => navigate('/redirect2/redirected'))
await waitFor(() => screen.getByText(/param redirected/i))
await waitFor(() => screen.getByText(/param redirected/))

act(() => navigate(routes.params({ value: 'one' })))
await waitFor(() => screen.getByText(/param one/i))

act(() => navigate(routes.params({ value: 'two' })))
await waitFor(() => screen.getByText(/param two/i))

// Renders the notfound page
act(() => navigate('/no/route/defined'))
await waitFor(() => screen.getByText('404'))
})

test('unauthenticated user is redirected away from private page', async () => {
Expand Down Expand Up @@ -229,3 +248,195 @@ test('inits routes two private routes with a space in between and loads as expec
// starts on home page
await waitFor(() => screen.getByText(/Home Page/i))
})

test('supports <Set>', async () => {
mockAuth(false)
const GlobalLayout = ({ children }) => (
<div>
<h1>Global Layout</h1>
{children}
</div>
)

const TestRouter = () => (
<Router useAuth={window.__REDWOOD__USE_AUTH}>
<Set wrap={GlobalLayout}>
<Route path="/" page={HomePage} name="home" />
<Route path="/about" page={AboutPage} name="about" />
<Route path="/redirect" page={RedirectPage} name="redirect" />
<Private unauthenticated="home">
<Route path="/private" page={PrivatePage} name="private" />
<Route
path="/another-private"
page={PrivatePage}
name="anotherPrivate"
/>
</Private>

<Route
path="/param-test/:value"
page={({ value }) => <div>param {value}</div>}
name="params"
/>
</Set>
</Router>
)
const screen = render(<TestRouter />)

await waitFor(() => screen.getByText(/Global Layout/i))
await waitFor(() => screen.getByText(/Home Page/i))
})

test("Doesn't destroy <Set> when navigating inside, but does when navigating between", async () => {
interface ContextState {
contextValue: string
setContextValue: React.Dispatch<React.SetStateAction<string>>
}

const SetContext = React.createContext<ContextState | undefined>(undefined)

const SetContextProvider = ({ children }) => {
const [contextValue, setContextValue] = React.useState('initialSetValue')

return (
<SetContext.Provider value={{ contextValue, setContextValue }}>
{children}
</SetContext.Provider>
)
}

const Ctx1Page = () => {
const ctx = React.useContext(SetContext)

React.useEffect(() => {
ctx.setContextValue('updatedSetValue')
}, [ctx])

return <p>1-{ctx.contextValue}</p>
}

const Ctx2Page = () => {
const ctx = React.useContext(SetContext)

return <p>2-{ctx.contextValue}</p>
}

const Ctx3Page = () => {
const ctx = React.useContext(SetContext)

return <p>3-{ctx.contextValue}</p>
}

const TestRouter = () => {
return (
<Router useAuth={window.__REDWOOD__USE_AUTH}>
<Set wrap={SetContextProvider}>
<Route path="/" page={HomePage} name="home" />
<Route path="/ctx-1-page" page={Ctx1Page} name="ctx1" />
<Route path="/ctx-2-page" page={Ctx2Page} name="ctx2" />
</Set>
<Set wrap={SetContextProvider}>
<Route path="/ctx-3-page" page={Ctx3Page} name="ctx3" />
</Set>
</Router>
)
}

const screen = render(<TestRouter />)

await waitFor(() => screen.getByText('Home Page'))

act(() => navigate(routes.ctx1()))
await waitFor(() => screen.getByText('1-updatedSetValue'))

act(() => navigate(routes.ctx2()))
await waitFor(() => screen.getByText('2-updatedSetValue'))

act(() => navigate(routes.ctx3()))
await waitFor(() => screen.getByText('3-initialSetValue'))
})

test('can use named routes for navigating', async () => {
const MainLayout = ({ children }) => {
return (
<div>
<h1>Main Layout</h1>
<Link to={routes.home()}>Home-link</Link>
<Link to={routes.about()}>About-link</Link>
<hr />
{children}
</div>
)
}

const TestRouter = () => (
<Router useAuth={window.__REDWOOD__USE_AUTH}>
<Set wrap={MainLayout}>
<Route path="/" page={HomePage} name="home" />
<Route path="/about" page={AboutPage} name="about" />
</Set>
</Router>
)

const screen = render(<TestRouter />)

// starts on home page, with MainLayout
await waitFor(() => screen.getByText(/Home Page/))
await waitFor(() => screen.getByText(/Main Layout/))

fireEvent.click(screen.getByText('About-link'))
await waitFor(() => screen.getByText(/About Page/))
})

test('renders only active path', async () => {
const AboutLayout = ({ children }) => {
return (
<div>
<h1>About Layout</h1>
<hr />
{children}
</div>
)
}

const LoginLayout = ({ children }) => {
return (
<div>
<h1>Login Layout</h1>
<hr />
{children}
</div>
)
}

const TestRouter = () => (
<Router useAuth={window.__REDWOOD__USE_AUTH}>
<Route path="/" page={HomePage} name="home" />
<Set wrap={AboutLayout}>
<Route path="/about" page={AboutPage} name="about" />
</Set>
<Set wrap={LoginLayout}>
<Route path="/login" page={LoginPage} name="login" />
</Set>
</Router>
)

const screen = render(<TestRouter />)

// starts on home page, with no layout
await waitFor(() => screen.getByText(/Home Page/))
expect(screen.queryByText('About Layout')).not.toBeInTheDocument()
expect(screen.queryByText('Login Layout')).not.toBeInTheDocument()

// go to about page, with only about layout
act(() => navigate(routes.about()))
await waitFor(() => screen.getByText(/About Page/))
expect(screen.queryByText('About Layout')).toBeInTheDocument()
expect(screen.queryByText('Login Layout')).not.toBeInTheDocument()

// go to login page, with only login layout
act(() => navigate(routes.login()))
await waitFor(() => screen.getByText(/Login Page/))
expect(screen.queryByText('About Layout')).not.toBeInTheDocument()
expect(screen.queryByText('Login Layout')).toBeInTheDocument()
})
Loading

0 comments on commit ed3dc2d

Please sign in to comment.