From 51926d1c0298fa88c481abc9837ecb108979df67 Mon Sep 17 00:00:00 2001 From: Timothy Farrell Date: Tue, 24 Jan 2017 10:02:29 -0600 Subject: [PATCH] Version 2 with new API, docs and tests - API receives array instead of object for match order - no longer supports unmatched function (just put a catchall route at the end) --- packages/router/README.md | 138 +++++++++++++----- packages/router/package.json | 2 +- packages/router/spec/router.spec.js | 218 ++++++++++++++++++++-------- packages/router/src/index.js | 47 +++--- 4 files changed, 291 insertions(+), 114 deletions(-) diff --git a/packages/router/README.md b/packages/router/README.md index 59ba52a..6151dae 100644 --- a/packages/router/README.md +++ b/packages/router/README.md @@ -1,49 +1,121 @@ -Usage: +# Router -Router(rootURL, routeSpec, onUnmatched): +The core functions of router that is designed to be slim and have an easy-to-read, yet powerful API. -rootURL (string) the url prefix of all paths. +**NOTE** This router is geared toward offline-first apps and thus does not support pushState. -routeSpec (object) +# Usage -onUnmatched (optional function(api, badPath, lastGoodRoute)) Called whenever an unmatched route is -navigated. Parameters are: api (Router instance) badpath (string) lastGoodRoute (object) a route -descriptor object for the last good route. +## Instantiation -returns an api object: goto(path) - match the path to a route and navigate there href(pathName, -vars) - build a path based on the name and supplied vars start(initialRoute) - listen to -window.onhashchange for route changes current() - get the current route descriptor object +```js +const router = Router(routeSpecArray, rootURL='#'): +``` + +### routeSpecArray + +An array of objects with the following properties: + +* `name` _string_ - an optional string that can be referred to in the `href` and `goto` instance + methods. Duplicate names are not allowed. + +* `path` _string_ - the path template for this route. Path templates are matched in the order of the + array. Example: + + `"/"` - for the root path `"/articles"` - another static path `"/article/:id"` - a path with a + variable `"/:unknownRoute"` - the last route could catch erroneous routes. Unmatched urls will + automatically route here. + +* `vars` _object_ - an optional object mapping variable names in the path template to a regular + expression for validation + +* `enter` _function_ - a function for when this route is entered. The `enter` function receives two + parameters: + + * _route instance object_ - this is a object that contains properties: + + * `name` _string_ - the route name + * `vars` _object_ - an object holding any variables parsed from the path + * `path` _string_ - the path as received + + * _router instance object_ - (see below) + + The `enter` function may return a callback that will be called instead of the `enter` function for + further navigate events that will be handled by this route (with different variables). This allows + `enter` to establish a context for the route it handles. + +* `exit` _function_ - an optional function that will be called before calling `enter` of the next + path. `exit` has the option to delay the call to `enter` by returning a promise. This is intended + for handling transition animations. If the route's `enter` function returns a callback, `exit` + will not be called if the same route receives navigation but with different variables. `exit` + receives the parameters similarly to `enter`: + + * _route instance object_ - for the route being exited + * _route instance object_ - for the route yet-to-be entered + * _router instance object_ - (see below) + +### rootURL (optional string) + +The url prefix of all paths. This should always be `#` unless you're nesting routers. + +### router (returned object) + +The returned instance provides these methods: + +* `goto(url: string)` or `goto(pathName: string, vars: object)` + + Match to a route by relative url or pathName and a vars object. Navigate there. + +* `href(pathName: string, vars: object)` + + Build a relative url based on the name and supplied vars. + +* `start(initialRoute: string)` + + Listen to `window.onhashchange` for route changes. The `initialRoute` will be passed to `goto()` + if the is no current route in `window.location`. + +* `stop()` + + Cancel subscription to `window.onhashchange` + +* `current()` + + Get the current _route instance object_ as was provided to the current routes `enter` function. Example: ```js -const router = Router( - '#', +const router = Router([ { - home: { - path: '/', - onenter: (r, route) => { - console.log('At application home'); - }, - onexit: (r, route, newRoute) => { - console.log(`Exiting from ${route.path} to ${newRoute.path}`); - } + name: 'home', + path: '/', + enter: (r, route) => { + console.log('At application home'); }, - article: { - path: '/article/:id', - vars: { id: /[a-f0-9]{6,40}/ }, - onenter: (r, route) => { - console.log('Opening Article', route.vars.id); - }, - onexit: (r, route, newRoute) => { - console.log('Closing Article', route.vars.id); - } + exit: (r, route, newRoute) => { + console.log(`Exiting from ${route.path} to ${newRoute.path}`); } }, - (r, path, lastGoodRoute) => { - alert(`Unmatched route "${path}"`); - r.goto(lastGoodRoute.path || 'home'); + { + name: 'article', + path: '/article/:id', + vars: { id: /[a-f0-9]{6,40}/ }, + enter: (route, router) => { + console.log('Opening Article', route.vars.id); + return (route, router) => { + console.log('Opening Article', route.vars.id); + }; + }, + exit: (route, newRoute, router) => { + console.log('Closing Article', route.vars.id); + } + }, + { + id: '404', + path: ':vars', + enter: r => r.goto('home') } -); +]); router.start('/'); ``` diff --git a/packages/router/package.json b/packages/router/package.json index 6e29e86..b1287c3 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -1,6 +1,6 @@ { "name": "router", - "version": "1.1.0", + "version": "2.0.0", "description": "A slim and unopinionated router", "main": "lib/index.js", "jsnext:main": "src/index.js", diff --git a/packages/router/spec/router.spec.js b/packages/router/spec/router.spec.js index c23326b..bae4217 100644 --- a/packages/router/spec/router.spec.js +++ b/packages/router/spec/router.spec.js @@ -1,86 +1,97 @@ const { Router } = require('../lib/index.js'); -describe('router builds urls', () => { - describe('for hashed routes', () => { - const router = Router( - '#', - { - home: { - path: '/', - onenter: (r, route) => {}, - onexit: (r, route, newRoute) => {} - }, - article: { - path: '/article/:id', - vars: { id: /[a-f0-9]{6,40}/ }, - onenter: (r, route) => {}, - onexit: (r, route, newRoute) => {} - } - }, - (r, path, lastGoodRoute) => {} - ); +describe('router.href builds urls', () => { + const router = Router([ + { + name: 'home', + path: '/', + enter: (route, router) => {}, + exit: (route, newRoute, router) => {} + }, + { + name: 'article', + path: '/article/:id', + vars: { id: /[a-f0-9]{6,40}/ }, + enter: (route, router) => {}, + exit: (route, newRoute, router) => {} + } + ]); - it('at the root', () => { - expect(router.href('home')).toEqual('#/'); + it('at the root', () => { + expect(router.href('home')).toEqual('#/'); + }); + + it('with variables', () => { + expect(router.href('article', { id: 156234 })).toEqual('#/article/156234'); + }); + + describe('but throws an error', () => { + it("if the route doesn't exist", () => { + expect(() => { + router.href('artcle', { id: 156 }); + }).toThrowError(Error, 'Invalid route artcle.'); }); - it('with variables', () => { - expect(router.href('article', { id: 156234 })).toEqual('#/article/156234'); - }); - - describe('but throws an error', () => { - it("if the route doesn't exist", () => { - expect(() => { - router.href('artcle', { id: 156 }); - }).toThrowError(Error, 'Invalid route artcle.'); - }); - - it("if the vars don't match", () => { - expect(() => { - router.href('article', { id: 156 }); - }).toThrowError(Error, 'Invalid value for route /article/:id var id: 156.'); - }); + it("if the vars don't match", () => { + expect(() => { + router.href('article', { id: 156 }); + }).toThrowError(Error, 'Invalid value for route /article/:id var id: 156.'); }); }); }); -describe('router goes to routes', () => { - describe('for hashed routes', () => { - const router = Router( - '#', - { - home: { +describe('router.goto()', () => { + let reEnterHooks, routeSpecArray, router; + + describe('goes to routes', () => { + beforeEach(() => { + reEnterHooks = { + home: () => {}, + article: () => {} + }; + routeSpecArray = [ + { + name: 'home', path: '/', - onenter: (r, route) => {}, - onexit: (r, route, newRoute) => {} + enter: (route, router) => reEnterHooks.home, + exit: (route, newRoute, router) => {} }, - article: { + { + name: 'article', path: '/article/:id', vars: { id: /[a-f0-9]{6,40}/ }, - onenter: (r, route) => {}, - onexit: (r, route, newRoute) => {} + enter: (route, router) => reEnterHooks.article, + exit: (route, newRoute, router) => {} } - }, - (r, path, lastGoodRoute) => {} - ); - it('at the root', done => { - router.goto('home').then(() => { - const current = router.current(); - expect(current.name).toEqual('home'); - expect(current.path).toEqual('#/'); - done(); - }); + ]; + + spyOn(routeSpecArray[0], 'enter').and.callThrough(); + spyOn(routeSpecArray[1], 'enter').and.callThrough(); + + router = Router(routeSpecArray); }); + it('with vars', done => { router.goto('article', { id: 156234 }).then(() => { + expect(routeSpecArray[1].enter).toHaveBeenCalled(); const current = router.current(); expect(current.name).toEqual('article'); expect(current.path).toEqual('#/article/156234'); done(); }); }); + it('at the root', done => { + router.goto('home').then(() => { + expect(routeSpecArray[0].enter).toHaveBeenCalled(); + const current = router.current(); + expect(current.name).toEqual('home'); + expect(current.path).toEqual('#/'); + done(); + }); + }); it('with a url', done => { router.goto('#/article/156233').then(() => { + expect(routeSpecArray[1].enter).toHaveBeenCalled(); const current = router.current(); expect(current.name).toEqual('article'); expect(current.path).toEqual('#/article/156233'); @@ -88,4 +99,95 @@ describe('router goes to routes', () => { }); }); }); + + describe('reEnters routes', () => { + it('when a route is navigated with merely different vars with a reenter hook', done => { + reEnterHooks = { + home: () => {}, + article: () => {} + }; + routeSpecArray = [ + { + name: 'home', + path: '/', + enter: (route, router) => reEnterHooks.home, + exit: (route, newRoute, router) => {} + }, + { + name: 'article', + path: '/article/:id', + vars: { id: /[a-f0-9]{6,40}/ }, + enter: (route, router) => reEnterHooks.article, + exit: (route, newRoute, router) => {} + } + ]; + + spyOn(routeSpecArray[0], 'enter').and.callThrough(); + spyOn(routeSpecArray[1], 'enter').and.callThrough(); + spyOn(routeSpecArray[1], 'exit').and.callThrough(); + spyOn(reEnterHooks, 'article').and.callThrough(); + + router = Router(routeSpecArray); + + router + .goto('article', { id: 156234 }) + .then(() => { + expect(routeSpecArray[1].enter).toHaveBeenCalled(); + expect(routeSpecArray[1].exit).toHaveBeenCalledTimes(0); + return router.goto('article', { id: 151234 }); + }) + .then(() => { + expect(reEnterHooks.article).toHaveBeenCalled(); + expect(routeSpecArray[1].exit).toHaveBeenCalledTimes(0); + return router.goto('home'); + }) + .then(() => { + expect(routeSpecArray[0].enter).toHaveBeenCalled(); + expect(routeSpecArray[1].exit).toHaveBeenCalled(); + }) + .then(done); + }); + + it('when a route is navigated with merely different vars without a reenter hook', done => { + routeSpecArray = [ + { + name: 'home', + path: '/', + enter: (route, router) => {}, + exit: (route, newRoute, router) => {} + }, + { + name: 'article', + path: '/article/:id', + vars: { id: /[a-f0-9]{6,40}/ }, + enter: (route, router) => {}, + exit: (route, newRoute, router) => {} + } + ]; + + spyOn(routeSpecArray[0], 'enter').and.callThrough(); + spyOn(routeSpecArray[1], 'enter').and.callThrough(); + spyOn(routeSpecArray[1], 'exit').and.callThrough(); + + router = Router(routeSpecArray); + + router + .goto('article', { id: 156234 }) + .then(() => { + expect(routeSpecArray[1].enter).toHaveBeenCalled(); + expect(routeSpecArray[1].exit).toHaveBeenCalledTimes(0); + return router.goto('article', { id: 151234 }); + }) + .then(() => { + expect(routeSpecArray[1].enter).toHaveBeenCalledTimes(2); + expect(routeSpecArray[1].exit).toHaveBeenCalledTimes(1); + return router.goto('home'); + }) + .then(() => { + expect(routeSpecArray[0].enter).toHaveBeenCalled(); + expect(routeSpecArray[1].exit).toHaveBeenCalled(); + }) + .then(done); + }); + }); }); diff --git a/packages/router/src/index.js b/packages/router/src/index.js index b0b39e5..36fe252 100644 --- a/packages/router/src/index.js +++ b/packages/router/src/index.js @@ -4,8 +4,7 @@ const VARMATCH_RE = /:([^\/]+)/g; const ROUTEELEMENT_RE = /^[^\/]+$/; const nop = () => 1; const digestRoutes = (routes, baseUrl) => - ObjectKeys(routes).map(name => { - const route = routes[name]; + routes.map((route, i) => { const reg = route.path.replace(VARMATCH_RE, (m, varName) => { const varDef = route.vars || {}; const regExStr = '' + (varDef[varName] || /[^\/]+/); @@ -14,19 +13,23 @@ const digestRoutes = (routes, baseUrl) => return { matcher: new RegExp(`^${baseUrl}${reg}$`), - name, + _i: i, ...route }; }); -export function Router(baseUrl, routes, unmatched) { +export function Router(routes, baseUrl = '#') { let listening = false; let currentRoute = Null; let reEnterHook = Null; let routeMatcher = digestRoutes(routes, baseUrl); + let routeByName = routeMatcher.reduce( + (obj, route) => (route.name ? Object.assign(obj, { [route.name]: route }) : obj), + {} + ); - function goto(urlOrName, vars) { + function goto(urlOrName, vars, fromLocation) { const url = urlOrName.startsWith(baseUrl) ? urlOrName : href(urlOrName, vars); if (listening && _location() !== url) { @@ -51,7 +54,7 @@ export function Router(baseUrl, routes, unmatched) { if (path.indexOf(':') !== -1) { match.shift(); path.replace(VARMATCH_RE, (_, varName) => { - // We're abusing RegExp.replace here. + // We're abusing RegExp.replace here and it's awesome! routeVars[varName] = match.shift(); }); } @@ -63,33 +66,33 @@ export function Router(baseUrl, routes, unmatched) { let newRoute = { name: routeMatch.name, vars: routeVars, - path: url + path: url, + _i: routeMatch._i }; - if (currentRoute && currentRoute.name === newRoute.name && isFunction(reEnterHook)) { - reEnterHook(newRoute); + if (currentRoute && currentRoute._i === newRoute._i && isFunction(reEnterHook)) { + const result = reEnterHook(newRoute); currentRoute = newRoute; + return Promise.resolve(result); } else { - let onexit = currentRoute && currentRoute.name ? routes[currentRoute.name].onexit || nop : nop; - return Promise.resolve(onexit(api, currentRoute, newRoute)) + let exit = currentRoute && currentRoute._i ? routes[currentRoute._i].exit || nop : nop; + return Promise.resolve(exit(api, currentRoute, newRoute)) .catch(() => {}) .then(() => { - reEnterHook = routes[routeMatch.name].onenter(api, newRoute); + reEnterHook = routes[routeMatch._i].enter(api, newRoute); currentRoute = newRoute; }); } - } else if (unmatched) { - unmatched(api, url, currentRoute); - } else { - if (currentRoute && listening) { - location.hash = currentRoute.path; - return; - } - throw new Error(`No route for "${url}"`); + } else if (currentRoute && fromLocation) { + // If we are listening and we receive an unmatched path, go back. + location.hash = currentRoute.path; + return; } + // Either we received a goto call or a start call to in invalid path. + throw new Error(`No route for "${url}"`); } function href(routeName, vars) { - const route = routes[routeName]; + const route = routeByName[routeName]; if (!route) { throw new Error(`Invalid route ${routeName}.`); } @@ -115,7 +118,7 @@ export function Router(baseUrl, routes, unmatched) { } function _handler() { - goto(_location()); + goto(_location(), Null, true); } function start(initialRoute) {