From 6112fd2b409922953e4739b545b5aa1ea40bc357 Mon Sep 17 00:00:00 2001 From: Bradley Maier Date: Thu, 30 Apr 2020 08:52:02 -0700 Subject: [PATCH 1/4] Wildcard routing --- src/routing/Route.ts | 4 ++-- src/routing/Router.ts | 42 ++++++++++++++++++++++++++++++++---- src/routing/interfaces.d.ts | 14 +++++++++++- tests/routing/unit/Route.ts | 36 +++++++++++++++++++++++++++---- tests/routing/unit/Router.ts | 35 ++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 11 deletions(-) diff --git a/src/routing/Route.ts b/src/routing/Route.ts index f6d73829d..dba46c1cc 100644 --- a/src/routing/Route.ts +++ b/src/routing/Route.ts @@ -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; } diff --git a/src/routing/Router.ts b/src/routing/Router.ts index 636f14b9e..332832136 100644 --- a/src/routing/Router.ts +++ b/src/routing/Router.ts @@ -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; @@ -42,6 +44,7 @@ export interface OutletEvent extends EventObject { 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]); @@ -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[] = []; @@ -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) => { @@ -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; @@ -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, @@ -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' @@ -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' }); } @@ -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' @@ -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' }); } diff --git a/src/routing/interfaces.d.ts b/src/routing/interfaces.d.ts index a7619073c..37d4e6edf 100644 --- a/src/routing/interfaces.d.ts +++ b/src/routing/interfaces.d.ts @@ -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 @@ -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 */ @@ -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 */ diff --git a/tests/routing/unit/Route.ts b/tests/routing/unit/Route.ts index 931f5ec87..c2c0a8da1 100644 --- a/tests/routing/unit/Route.ts +++ b/tests/routing/unit/Route.ts @@ -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'; @@ -18,6 +19,11 @@ class Widget extends WidgetBase { let registry: Registry; const routeConfig = [ + { + path: '*', + id: 'catch-all', + outlet: 'catch-all' + }, { path: '/foo', outlet: 'foo', @@ -67,7 +73,7 @@ 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'); @@ -75,7 +81,7 @@ describe('Route', () => { () => w(Route, { id: 'foo', - renderer(details: any) { + renderer(details: MatchDetails) { matchType = details.type; return null; } @@ -86,6 +92,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 }); @@ -94,7 +122,7 @@ describe('Route', () => { () => w(Route, { id: 'foo', - renderer(details: any) { + renderer(details: MatchDetails) { matchType = details.type; return null; } @@ -120,7 +148,7 @@ describe('Route', () => { () => w(Route, { id: 'foo', - renderer(details: any) { + renderer(details: MatchDetails) { if (details.type === 'index') { return w(Widget, {}); } diff --git a/tests/routing/unit/Router.ts b/tests/routing/unit/Router.ts index 84bd242ce..314f3fc52 100644 --- a/tests/routing/unit/Router.ts +++ b/tests/routing/unit/Router.ts @@ -36,6 +36,11 @@ const routeConfig = [ ] } ] + }, + { + path: '/bar/*', + outlet: 'bar', + id: 'bar' } ]; @@ -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'); From a7ebc387499b1e02a8c84be8014718cdff111f93 Mon Sep 17 00:00:00 2001 From: Bradley Maier Date: Thu, 30 Apr 2020 11:28:25 -0700 Subject: [PATCH 2/4] Add a test for routes with no router --- tests/routing/unit/Route.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/routing/unit/Route.ts b/tests/routing/unit/Route.ts index c2c0a8da1..9be6ca65f 100644 --- a/tests/routing/unit/Route.ts +++ b/tests/routing/unit/Route.ts @@ -56,6 +56,19 @@ 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' + }) + ); + r.expect(assertion(() => null)); + }); + it('Should render the result of the renderer when the Route matches', () => { const router = registerRouterInjector(routeConfig, registry, { HistoryManager }); From 9f3aac83383c28e02957d05a3c25abf1379d2a57 Mon Sep 17 00:00:00 2001 From: Bradley Maier Date: Wed, 27 May 2020 11:16:57 -0700 Subject: [PATCH 3/4] Fix coverage --- tests/routing/unit/Route.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/routing/unit/Route.ts b/tests/routing/unit/Route.ts index 9be6ca65f..1c571e519 100644 --- a/tests/routing/unit/Route.ts +++ b/tests/routing/unit/Route.ts @@ -57,14 +57,16 @@ describe('Route', () => { }); it('returns null if rendered without an available router', () => { - const r = renderer(() => - w(Route, { - id: 'foo', - renderer() { - return w(Widget, {}); - }, - routerKey: 'Does not exist' - }) + const r = renderer( + () => + w(Route, { + id: 'foo', + renderer() { + return w(Widget, {}); + }, + routerKey: 'Does not exist' + }), + { middleware: [[getRegistry, mockGetRegistry]] } ); r.expect(assertion(() => null)); }); From d6d1e817691bee5665a9c20b2289310f413c4b62 Mon Sep 17 00:00:00 2001 From: Bradley Maier Date: Tue, 7 Jul 2020 11:32:33 -0700 Subject: [PATCH 4/4] Wildcard routing documentation --- docs/en/routing/introduction.md | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/docs/en/routing/introduction.md b/docs/en/routing/introduction.md index 213e82a66..f63f69df6 100644 --- a/docs/en/routing/introduction.md +++ b/docs/en/routing/introduction.md @@ -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 ( +
+
{`Home ${matchDetails.params.page}`}
} /> +
{`Matched Route ${matchDetails.wildcardSegments.join(', ')}`}
} + /> +
+ ); +}); +``` + ## 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.