Skip to content

Commit

Permalink
Add support for route groups
Browse files Browse the repository at this point in the history
  • Loading branch information
taion committed Jul 27, 2017
1 parent 5531258 commit f3765d5
Show file tree
Hide file tree
Showing 10 changed files with 572 additions and 38 deletions.
91 changes: 90 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ A route object under the default matching algorithm and route element resolver c
- `Component` or `getComponent`: the component for the route, or a method that returns the component for the route
- `data` or `getData`: additional data for the route, or a method that returns additional data for the route
- `render`: a method that returns the element for the route
- `children`: an array of child route objects; if using JSX configuration components, this comes from the JSX children
- `children`: an array of child route objects, or an object of those arrays; if using JSX configuration components, this comes from the JSX children

A route configuration consists of an array of route objects. You can generate such an array of route objects from JSX with `<Route>` elements using `makeRouteConfig`.

Expand Down Expand Up @@ -353,6 +353,95 @@ function render({ Component, props }) {

If any matched routes have unresolved asynchronous component or data dependencies, the router will initially attempt to render all such routes in their loading state. If those routes all implement `render` methods and return non-`undefined` values from their `render` methods, the router will render the matched routes in their loading states. Otherwise, the router will continue to render the previous set of routes until all asynchronous dependencies resolve.

#### Named child routes

Specify an object for the `children` property on a route to set up named child routes. A route with named child routes will match only if every route group matches. The elements corresponding to the child routes will be available on their parent as props with the same name as the route groups.

```js
function AppPage({ nav, main }) {
return (
<div className="app">
<div className="nav">
{nav}
</div>
<div className="main">
{main}
</div>
</div>
);
}

const route = {
path: '/',
Component: AppPage,
children: [
{
path: 'foo',
children: {
nav: [
{
path: '(.*)?',
Component: FooNav,
},
],
main: [
{
path: 'a',
Component: FooA,
},
{
path: 'b',
Component: FooB,
},
],
},
},
{
path: 'bar',
children: {
nav: [
{
path: '(.*)?',
Component: BarNav,
},
],
main: [
{
Component: BarMain,
},
],
},
},
],
};

const jsxRoute = (
<Route path="/" Component={AppPage}>
<Route path="foo">
{{
nav: (
<Route path="(.*)?" Component={FooNav} />
),
main: [
<Route path="a" Component={FooA} />,
<Route path="b" Component={FooB} />,
],
}}
</Route>
<Route path="bar">
{{
nav: (
<Route path="(.*)?" Component={BarNav} />
),
main: (
<Route Component={BarMain} />
),
}}
</Route>
</Route>
);
```

#### Redirects

The `Redirect` route class sets up static redirect routes. You can also use it to create JSX `<Redirect>` elements for use with `makeRouteConfig`. This class takes `from` and `to` properties. `from` should be a path pattern as for normal routes above. `to` can be either a path pattern or a function. If it is a path pattern, the router will populate path parameters appropriately. If it is a function, it will receive the same routing state object as `getComponent` and `getData`, as described above.
Expand Down
23 changes: 21 additions & 2 deletions src/ElementsRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,32 @@ import React from 'react';

const propTypes = {
elements: PropTypes.arrayOf(
// This should be an object of this same type, but recursive checks would
// probably be too messy.
PropTypes.object,
PropTypes.element,
).isRequired,
};

function accumulateElement(children, element) {
if (!element || !children) {
return element || children;
if (!children) {
return element;
}

if (!element) {
return children;
}

if (!React.isValidElement(children)) {
// Children come from named child routes.
const groups = {};
Object.entries(children).forEach(([groupName, groupElements]) => {
groups[groupName] = groupElements.reduceRight(
accumulateElement, null,
);
});

return React.cloneElement(element, groups);
}

return React.cloneElement(element, { children });
Expand Down
137 changes: 114 additions & 23 deletions src/Matcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,31 +22,15 @@ export default class Matcher {
return null;
}

const routeIndices = new Array(matches.length);
const routeParams = new Array(matches.length);
const params = {};

matches.forEach((routeMatch, i) => {
routeIndices[i] = routeMatch.index;
routeParams[i] = routeMatch.params;
Object.assign(params, routeMatch.params);
});

return { routeIndices, routeParams, params };
return this.makePayload(matches);
}

getRoutes({ routeIndices }) {
if (!routeIndices) {
return null;
}

let lastRouteConfig = this.routeConfig;

return routeIndices.map((routeIndex) => {
const route = lastRouteConfig[routeIndex];
lastRouteConfig = route.children;
return route;
});
return this.getRoutesFromIndices(routeIndices, this.routeConfig);
}

joinPaths(basePath, path) {
Expand Down Expand Up @@ -86,12 +70,21 @@ export default class Matcher {
continue; // eslint-disable-line no-continue
}

const { children = [] } = route;
const { params, remaining } = match;

const childMatches = this.matchRoutes(children, remaining);
if (childMatches) {
return [{ index, params }, ...childMatches];
const { children } = route;

if (children) {
if (Array.isArray(children)) {
const childMatches = this.matchRoutes(children, remaining);
if (childMatches) {
return [{ index, params }, ...childMatches];
}
} else {
const groups = this.matchGroups(children, remaining);
if (groups) {
return [{ index, params }, { groups }];
}
}
}

if (!remaining) {
Expand Down Expand Up @@ -135,6 +128,104 @@ export default class Matcher {
return pattern.charAt(0) === '/' ? pattern : `/${pattern}`;
}

matchGroups(routeGroups, pathname) {
const groups = {};

for (const [groupName, routes] of Object.entries(routeGroups)) {
const groupMatch = this.matchRoutes(routes, pathname);
if (!groupMatch) {
return null;
}

groups[groupName] = groupMatch;
}

return groups;
}

makePayload(matches) {
const routeMatch = matches[0];

if (routeMatch.groups) {
warning(
matches.length === 1,
'Route match with groups %s has children, which are ignored.',
Object.keys(routeMatch.groups).join(', '),
);

const routeIndices = {};
const routeParams = [];
const params = {};

Object.entries(
routeMatch.groups,
).forEach(([groupName, groupMatches]) => {
const groupPayload = this.makePayload(groupMatches);

// Retain the nested group structure for route indices so we can
// reconstruct the element tree from flattened route elements.
routeIndices[groupName] = groupPayload.routeIndices;

// Flatten route groups for route params matching getRoutesFromIndices
// below.
routeParams.push(...groupPayload.routeParams);

// Just merge all the params depth-first; it's the easiest option.
Object.assign(params, groupPayload.params);
});

return { routeIndices, routeParams, params };
}

const { index, params } = routeMatch;

if (matches.length === 1) {
return {
routeIndices: [index],
routeParams: [params],
params,
};
}

const childPayload = this.makePayload(matches.slice(1));
return {
routeIndices: [index, ...childPayload.routeIndices],
routeParams: [params, ...childPayload.routeParams],
params: { ...params, ...childPayload.params },
};
}

getRoutesFromIndices(routeIndices, routeConfigOrGroups) {
const routeIndex = routeIndices[0];

if (typeof routeIndex === 'object') {
// Flatten route groups to save resolvers from having to explicitly
// handle them.
const groupRoutes = [];
Object.entries(routeIndex).forEach(([groupName, groupRouteIndices]) => {
groupRoutes.push(...this.getRoutesFromIndices(
groupRouteIndices, routeConfigOrGroups[groupName],
));
});

return groupRoutes;
}

const route = routeConfigOrGroups[routeIndex];

if (routeIndices.length === 1) {
return [route];
}

return [
route,
...this.getRoutesFromIndices(
routeIndices.slice(1),
route.children,
),
];
}

isPathnameActive(matchPathname, pathname, exact) {
if (pathname === matchPathname) {
return true;
Expand Down
11 changes: 10 additions & 1 deletion src/makeRouteConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,16 @@ export default function makeRouteConfig(node) {
const route = new Type(props);

if (children) {
route.children = makeRouteConfig(children);
if (React.isValidElement(children) || Array.isArray(children)) {
route.children = makeRouteConfig(children);
} else {
const routeGroups = {};
Object.entries(children).forEach(([groupName, groupRoutes]) => {
routeGroups[groupName] = makeRouteConfig(groupRoutes);
});

route.children = routeGroups;
}
}

return route;
Expand Down
30 changes: 29 additions & 1 deletion src/utils/resolveRenderArgs.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
import HttpError from '../HttpError';

function foldElements(elementsRaw, routeIndices) {
const elements = [];

for (const routeIndex of routeIndices) {
if (typeof routeIndex === 'object') {
// Reshape the next elements in the elements array to match the nested
// tree structure corresponding to the route groups.
const groupElements = {};
Object.entries(routeIndex).forEach(([groupName, groupRouteIndices]) => {
groupElements[groupName] = foldElements(
elementsRaw, groupRouteIndices,
);
});

elements.push(groupElements);
} else {
// We intentionally modify elementsRaw, to make it easier to recursively
// handle groups.
elements.push(elementsRaw.shift());
}
}

return elements;
}

export default async function* resolveRenderArgs({
router, match, matchContext, resolver,
}) {
Expand All @@ -21,7 +46,10 @@ export default async function* resolveRenderArgs({

try {
for await (const elements of resolver.resolveElements(augmentedMatch)) {
yield { ...augmentedMatch, elements };
yield {
...augmentedMatch,
elements: foldElements([...elements], match.routeIndices),
};
}
} catch (e) {
if (e instanceof HttpError) {
Expand Down
3 changes: 2 additions & 1 deletion test/.eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"rules": {
"import/no-extraneous-dependencies": [2, {
"devDependencies": true
}]
}],
"react/prop-types": "off"
}
}
2 changes: 0 additions & 2 deletions test/ElementsRenderer.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { mount } from 'enzyme';
import PropTypes from 'prop-types';
import React from 'react';

import ElementsRenderer from '../src/ElementsRenderer';
Expand All @@ -23,7 +22,6 @@ describe('<ElementsRenderer>', () => {

it('should render elements with nested structure', () => {
const Parent = ({ children }) => <div className="parent">{children}</div>;
Parent.propTypes = { children: PropTypes.element };
const Child = () => <div className="child" />;

const elements = [<Parent />, <Child />];
Expand Down

0 comments on commit f3765d5

Please sign in to comment.