Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/en/routing/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,53 @@ export default [
];
```

## Wildcard Routes

The `*` character can be used to indicate a wildcard route. The route will be matched normally up until the `*` and will match
any path at that point. A wildcard route will never be preferred over another matching route without a wildcard. The `*` implicitly indicates the end of the
match, and any segments specified after the `*` in the route config will be ignored. Any additional segments in the actual URL will be passed with
the `matchDetails` in an array property called `wildcardSegments`.

```ts
export default [
{
id: 'catchall',
// Anything after the asterisk will be ignored in this config
path: '*',
outlet: 'catchall'
},
// This path will be preferred to the wildcard as long as it matches
{
id: 'home',
path: 'home',
outlet: 'home'
}
];
```

All segments after and including the matched `*` will be injected into the matching `Route`'s `renderer` property as `wildcardSegments`.

> src/App.tsx

```tsx
import { create, tsx } from '@dojo/framework/core/vdom';
import Route from '@dojo/framework/routing/Route';

const factory = create();

export default factory(function App() {
return (
<div>
<Route id="home" renderer={(matchDetails) => <div>{`Home ${matchDetails.params.page}`}</div>} />
<Route
id="catchall"
renderer={(matchDetails) => <div>{`Matched Route ${matchDetails.wildcardSegments.join(', ')}`}</div>}
/>
</div>
);
});
```

## Using link widgets

The `Link` widget is a wrapper around an anchor tag that enables consumers to specify a route `id` to create a link to. If the generated link requires specific path or query parameters that are not in the route, they can be passed via the `params` property.
Expand Down
4 changes: 2 additions & 2 deletions src/routing/Route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ export const Route = factory(function Route({
if (router) {
const routeContext = router.getRoute(id);
if (routeContext) {
const { queryParams, params, type, isError, isExact } = routeContext;
const result = renderer({ queryParams, params, type, isError, isExact, router });
const { queryParams, params, type, isError, isExact, wildcardSegments } = routeContext;
const result = renderer({ queryParams, params, type, isError, isExact, router, wildcardSegments });
if (result) {
return result;
}
Expand Down
42 changes: 38 additions & 4 deletions src/routing/Router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import { HashHistory } from './history/HashHistory';
import { EventObject } from '../core/Evented';

const PARAM = '__PARAM__';
const WILDCARD = '__WILDCARD__';

const paramRegExp = new RegExp(/^{.+}$/);
const wildCardRegExp = new RegExp(/^\*$/);

interface RouteWrapper {
route: Route;
Expand All @@ -42,6 +44,7 @@ export interface OutletEvent extends EventObject<string> {

const ROUTE_SEGMENT_SCORE = 7;
const DYNAMIC_SEGMENT_PENALTY = 2;
const WILDCARD_SEGMENT_PENALTY = 3;

function matchingParams({ params: previousParams }: RouteContext, { params }: RouteContext) {
const matching = Object.keys(previousParams).every((key) => previousParams[key] === params[key]);
Expand All @@ -51,6 +54,10 @@ function matchingParams({ params: previousParams }: RouteContext, { params }: Ro
return Object.keys(params).every((key) => previousParams[key] === params[key]);
}

function matchingSegments({ wildcardSegments: previousSegments }: RouteContext, { wildcardSegments }: RouteContext) {
return wildcardSegments.join('') === previousSegments.join('');
}

export class Router extends Evented<{ nav: NavEvent; route: RouteEvent; outlet: OutletEvent }>
implements RouterInterface {
private _routes: Route[] = [];
Expand Down Expand Up @@ -211,6 +218,13 @@ export class Router extends Evented<{ nav: NavEvent; route: RouteEvent; outlet:
route.params.push(segment.replace('{', '').replace('}', ''));
segments[i] = PARAM;
}

if (wildCardRegExp.test(segment)) {
route.score -= WILDCARD_SEGMENT_PENALTY;
segments[i] = WILDCARD;
segments.splice(i + 1);
break;
}
}
if (queryParamString) {
queryParams = queryParamString.split('&').map((queryParam) => {
Expand Down Expand Up @@ -287,6 +301,10 @@ export class Router extends Evented<{ nav: NavEvent; route: RouteEvent; outlet:
if (route.segments[segmentIndex] === PARAM) {
params[route.params[paramIndex++]] = segment;
this._currentParams = { ...this._currentParams, ...params };
} else if (route.segments[segmentIndex] === WILDCARD) {
type = 'wildcard';
segments.unshift(segment);
break;
} else if (route.segments[segmentIndex] !== segment) {
routeMatch = false;
break;
Expand All @@ -297,7 +315,13 @@ export class Router extends Evented<{ nav: NavEvent; route: RouteEvent; outlet:

if (routeMatch) {
routeConfig.type = type;
matchedRoutes.push({ route, parent, type, params, segments: [] });
matchedRoutes.push({
route,
parent,
type,
params,
segments: type === 'wildcard' ? segments.splice(0) : []
});
if (segments.length) {
routeConfigs = [
...routeConfigs,
Expand Down Expand Up @@ -340,13 +364,14 @@ export class Router extends Evented<{ nav: NavEvent; route: RouteEvent; outlet:
global.document.title = title;
}
while (matchedRoute) {
let { type, params, route } = matchedRoute;
let { type, params, route, segments } = matchedRoute;
let parent: RouteWrapper | undefined = matchedRoute.parent;
const matchedRouteContext: RouteContext = {
id: route.id,
outlet: route.outlet,
queryParams: this._currentQueryParams,
params,
wildcardSegments: type === 'wildcard' ? segments : [],
type,
isError: () => type === 'error',
isExact: () => type === 'index'
Expand All @@ -356,7 +381,11 @@ export class Router extends Evented<{ nav: NavEvent; route: RouteEvent; outlet:
routeMap.set(route.id, matchedRouteContext);
this._matchedOutletMap.set(route.outlet, routeMap);
this._matchedRoutes[route.id] = matchedRouteContext;
if (!previousMatchedOutlet || !matchingParams(previousMatchedOutlet, matchedRouteContext)) {
if (
!previousMatchedOutlet ||
!matchingParams(previousMatchedOutlet, matchedRouteContext) ||
(type === 'wildcard' && !matchingSegments(previousMatchedOutlet, matchedRouteContext))
) {
this.emit({ type: 'route', route: matchedRouteContext, action: 'enter' });
this.emit({ type: 'outlet', outlet: matchedRouteContext, action: 'enter' });
}
Expand All @@ -368,6 +397,7 @@ export class Router extends Evented<{ nav: NavEvent; route: RouteEvent; outlet:
outlet: 'errorRoute',
queryParams: {},
params: {},
wildcardSegments: [],
isError: () => true,
isExact: () => false,
type: 'error'
Expand All @@ -378,7 +408,11 @@ export class Router extends Evented<{ nav: NavEvent; route: RouteEvent; outlet:
for (let i = 0; i < previousMatchedOutletKeys.length; i++) {
const key = previousMatchedOutletKeys[i];
const matchedRoute = this._matchedRoutes[key];
if (!matchedRoute || !matchingParams(previousMatchedRoutes[key], matchedRoute)) {
if (
!matchedRoute ||
!matchingParams(previousMatchedRoutes[key], matchedRoute) ||
(matchedRoute.type === 'wildcard' && !matchingSegments(previousMatchedRoutes[key], matchedRoute))
) {
this.emit({ type: 'route', route: previousMatchedRoutes[key], action: 'exit' });
this.emit({ type: 'outlet', outlet: previousMatchedRoutes[key], action: 'exit' });
}
Expand Down
14 changes: 13 additions & 1 deletion src/routing/interfaces.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export interface Params {
/**
* Type of outlet matches
*/
export type MatchType = 'error' | 'index' | 'partial';
export type MatchType = 'error' | 'index' | 'partial' | 'wildcard';

/**
* Context stored for matched outlets
Expand Down Expand Up @@ -79,6 +79,12 @@ export interface RouteContext {
*/
queryParams: Params;

/**
* If this route is a wildcard route, any segments that are part of the "wild" section
* of the route
*/
wildcardSegments: string[];

/**
* Returns `true` when the route is an error match
*/
Expand Down Expand Up @@ -138,6 +144,12 @@ export interface MatchDetails {
*/
type: MatchType;

/**
* If this route is a wildcard route, any segments that are part of the "wild" section
* of the route
*/
wildcardSegments: string[];

/**
* The router instance
*/
Expand Down
51 changes: 47 additions & 4 deletions tests/routing/unit/Route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const { beforeEach, describe, it } = intern.getInterface('bdd');
const { assert } = intern.getPlugin('chai');

import { MatchDetails } from '../../../src/routing/interfaces';
import { WidgetBase } from '../../../src/core/WidgetBase';
import { MemoryHistory as HistoryManager } from '../../../src/routing/history/MemoryHistory';
import { Route } from '../../../src/routing/Route';
Expand All @@ -18,6 +19,11 @@ class Widget extends WidgetBase {
let registry: Registry;

const routeConfig = [
{
path: '*',
id: 'catch-all',
outlet: 'catch-all'
},
{
path: '/foo',
outlet: 'foo',
Expand Down Expand Up @@ -50,6 +56,21 @@ describe('Route', () => {
registry = new Registry();
});

it('returns null if rendered without an available router', () => {
const r = renderer(
() =>
w(Route, {
id: 'foo',
renderer() {
return w(Widget, {});
},
routerKey: 'Does not exist'
}),
{ middleware: [[getRegistry, mockGetRegistry]] }
);
r.expect(assertion(() => null));
});

it('Should render the result of the renderer when the Route matches', () => {
const router = registerRouterInjector(routeConfig, registry, { HistoryManager });

Expand All @@ -67,15 +88,15 @@ describe('Route', () => {
r.expect(assertion(() => w(Widget, {}, [])));
});

it('Should set the type as index for exact matches', () => {
it('Should set the type as index for exact matches and capture wildcard segments', () => {
let matchType: string | undefined;
const router = registerRouterInjector(routeConfig, registry, { HistoryManager });
router.setPath('/foo');
const r = renderer(
() =>
w(Route, {
id: 'foo',
renderer(details: any) {
renderer(details: MatchDetails) {
matchType = details.type;
return null;
}
Expand All @@ -86,6 +107,28 @@ describe('Route', () => {
assert.strictEqual(matchType, 'index');
});

it('Should set the type as wildcard for wildcard matches', () => {
let matchType: string | undefined;
let wildcardSegments: string[] | undefined;
const router = registerRouterInjector(routeConfig, registry, { HistoryManager });
router.setPath('/match/me/if/you/can');
const r = renderer(
() =>
w(Route, {
id: 'catch-all',
renderer(details: MatchDetails) {
matchType = details.type;
wildcardSegments = details.wildcardSegments;
return null;
}
}),
{ middleware: [[getRegistry, mockGetRegistry]] }
);
r.expect(assertion(() => null));
assert.strictEqual(matchType, 'wildcard');
assert.deepEqual(wildcardSegments, ['match', 'me', 'if', 'you', 'can']);
});

it('Should set the type as error for error matches', () => {
let matchType: string | undefined;
const router = registerRouterInjector(routeConfig, registry, { HistoryManager });
Expand All @@ -94,7 +137,7 @@ describe('Route', () => {
() =>
w(Route, {
id: 'foo',
renderer(details: any) {
renderer(details: MatchDetails) {
matchType = details.type;
return null;
}
Expand All @@ -120,7 +163,7 @@ describe('Route', () => {
() =>
w(Route, {
id: 'foo',
renderer(details: any) {
renderer(details: MatchDetails) {
if (details.type === 'index') {
return w(Widget, {});
}
Expand Down
35 changes: 35 additions & 0 deletions tests/routing/unit/Router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ const routeConfig = [
]
}
]
},
{
path: '/bar/*',
outlet: 'bar',
id: 'bar'
}
];

Expand Down Expand Up @@ -434,6 +439,36 @@ describe('Router', () => {
router.setPath('/foo/baaz/baz');
});

it('should emit route event when wildcard paths change', () => {
const router = new Router(routeConfig, { HistoryManager });
let handle = router.on('route', () => {});
handle.destroy();
handle = router.on('route', ({ route, action }) => {
if (action === 'exit') {
assert.strictEqual(route.id, 'home');
} else {
assert.strictEqual(route.id, 'bar');
}
});
router.setPath('/bar/baz');
handle.destroy();
let count = 0;
handle = router.on('route', ({ route, action }) => {
if (!count) {
assert.strictEqual(action, 'enter');
assert.deepEqual(route.wildcardSegments, ['baz', 'buzz']);
} else {
assert.strictEqual(action, 'exit');
assert.deepEqual(route.wildcardSegments, ['baz']);
}
assert.strictEqual(route.id, 'bar');
count++;
});
router.setPath('/bar/baz/buzz');
handle.destroy();
assert.strictEqual(count, 2);
});

it('Should return all params for a route', () => {
const router = new Router(routeWithChildrenAndMultipleParams, { HistoryManager });
router.setPath('/foo/foo/bar/bar/baz/baz');
Expand Down