')
38 | this.$outlet = this.$view.find('.outlet')
39 | this.$outlet.html('Welcome to this application')
40 | $(document.body).html(this.$view)
41 | }
42 | }
43 |
44 | handlers['about'] = {
45 | activate: function () {
46 | this.parent.$outlet.html('This is about page')
47 | }
48 | }
49 |
50 | handlers['faq'] = {
51 | activate: function (params, query) {
52 | this.parent.$outlet.html('FAQ.')
53 | this.parent.$outlet.append(' Sorted By: ' + query.sortBy)
54 | }
55 | }
56 |
57 | handlers['posts'] = {
58 | activate: function () {}
59 | }
60 |
61 | handlers['posts.filter'] = {
62 | activate: function (params) {
63 | if (params.filterId === 'mine') {
64 | this.parent.parent.$outlet.html('My posts...')
65 | } else {
66 | this.parent.parent.$outlet.html('Filter not found')
67 | }
68 | }
69 | }
70 |
71 | router.use((transition) => {
72 | transition.routes.forEach((route, i) => {
73 | let handler = handlers[route.name]
74 | let parentRoute = transition.routes[i - 1]
75 | handler.parent = parentRoute ? handlers[parentRoute.name] : null
76 | handler.activate(transition.params, transition.query)
77 | })
78 | })
79 | }
80 |
81 | TestApp.prototype.start = function () {
82 | return this.router.listen()
83 | }
84 |
85 | TestApp.prototype.destroy = function () {
86 | $(document.body).empty()
87 | return this.router.destroy()
88 | }
89 |
--------------------------------------------------------------------------------
/examples/hello-world-react/app/index.js:
--------------------------------------------------------------------------------
1 | let cherrytree = require('cherrytree')
2 | let React = require('react')
3 | let components = require('./components')
4 |
5 | let Application = components.Application
6 | let Home = components.Home
7 | let Messages = components.Messages
8 | let Profile = components.Profile
9 | let ProfileIndex = components.ProfileIndex
10 |
11 | let router = cherrytree({log: true})
12 |
13 | // This is how we define the route map or app structure.
14 | // The nesting here means that all routes in that branch
15 | // of the route tree will get "resolved" and can load data or
16 | // render things on the screen. For example going to 'profile.edit'
17 | // route would load ['application', 'profile', 'profile.edit'] routes
18 | router.map(function (route) {
19 | // We can pass arbitrary options in the second argument of the route
20 | // function call. Because in this case we're using React, let's attach
21 | // the relevant components to each route.
22 | // Path is the only special option that is used to construct and
23 | // match URLs as well as extract URL parameters.
24 | route('application', {path: '/', component: Application, abstract: true}, function () {
25 | route('home', {path: '', component: Home})
26 | route('messages', {component: Messages})
27 | route('status', {path: ':user/status/:id'})
28 | route('profile', {path: ':user', component: Profile, abstract: true}, function () {
29 | route('profile.index', {component: ProfileIndex, path: ''})
30 | route('profile.lists')
31 | route('profile.edit')
32 | })
33 | })
34 | })
35 |
36 | // Middleware are used to action the transitions.
37 | // For example, here, we grab the React component that
38 | // is backing each route and render them in a nested manner.
39 | router.use(function render (transition) {
40 | let { routes, params, query } = transition
41 | let el = routes.reduceRight(function (element, route) {
42 | let Component = route.options.component
43 | if (Component) {
44 | return React.createElement(Component, {
45 | link: function () {
46 | return router.generate.apply(router, arguments)
47 | },
48 | params: params,
49 | query: query,
50 | children: element
51 | })
52 | } else {
53 | return element
54 | }
55 | }, null)
56 | React.render(el, document.querySelector('#app'))
57 | })
58 |
59 | // Finally, now that everything is set up
60 | // start listening to URL changes and transition the
61 | // app to the route that matches the current browser URL
62 | router.listen().then(function () {
63 | console.log('App started.')
64 | console.log('Try transitioning programmatically with `router.transitionTo("messages")`.')
65 | window.router = router
66 | })
67 |
--------------------------------------------------------------------------------
/examples/vanilla-blog/client/screens/app/screens/posts/screens/show/templates/show.html:
--------------------------------------------------------------------------------
1 |
2 |
<%- title %>
3 |
4 |
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec rhoncus condimentum sem a facilisis. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Maecenas lacinia fringilla tristique. Ut sollicitudin felis sem, in aliquam lectus eleifend nec. Pellentesque id nisi non arcu venenatis convallis vel id nulla. Nullam ut accumsan felis. Nunc nec tincidunt sapien, et posuere massa.
5 |
Cras sollicitudin neque ac erat fermentum, quis pulvinar dolor porta. Donec enim lacus, scelerisque ac iaculis eu, rhoncus sed urna. In hac habitasse platea dictumst. Nullam sodales, leo at congue ultricies, mauris sapien ornare ipsum, id dictum orci nulla at leo. Nullam non tristique mauris. Donec aliquam lobortis tortor in volutpat. In at sem sed felis faucibus vulputate. Duis risus leo, aliquet ut lectus vel, sollicitudin egestas dui. Morbi eget justo tristique, tincidunt turpis interdum, faucibus turpis. Sed venenatis ut augue non accumsan. Aliquam at lorem et dui accumsan sodales. Mauris fermentum, ligula imperdiet pharetra feugiat, erat tortor sollicitudin nulla, et condimentum mauris lectus ullamcorper enim. Integer volutpat mauris nisl, non congue turpis fringilla et. Sed rhoncus mollis libero, ut lacinia quam elementum in.
6 |
Pellentesque eu arcu condimentum, vulputate leo nec, tincidunt massa. Vestibulum leo libero, aliquet nec enim quis, consequat faucibus elit. Sed tristique et velit vel iaculis. Sed suscipit commodo tellus nec imperdiet. Aenean tristique at urna eget aliquet. Etiam vestibulum ligula ac nunc viverra, quis scelerisque est facilisis. Praesent fermentum eros urna, nec consequat sapien accumsan sit amet. Maecenas congue elit id lacinia scelerisque. Mauris in nibh justo.
7 |
Suspendisse potenti. Vestibulum eu molestie diam. Quisque tristique volutpat felis, eget tempor lorem ullamcorper sed. Vivamus aliquam cursus mollis. Cras accumsan justo nec augue dignissim lacinia. Mauris eu tortor lacus. Interdum et malesuada fames ac ante ipsum primis in faucibus. Quisque at lacus a elit elementum semper. Morbi dictum metus lorem, eu ornare nisi gravida ut. Interdum et malesuada fames ac ante ipsum primis in faucibus. Suspendisse velit odio, posuere sed mi quis, elementum elementum urna. Etiam mi mi, sollicitudin at ipsum sit amet, posuere gravida sapien.
8 |
Curabitur vel mauris id velit lobortis rutrum. In hac habitasse platea dictumst. Fusce in mattis ante, eget tristique ipsum. Sed lorem augue, laoreet eu viverra eget, porttitor sed massa. Quisque suscipit mauris sem, vitae varius arcu gravida sit amet. Interdum et malesuada fames ac ante ipsum primis in faucibus. Ut ipsum libero, fringilla eu nibh vitae, porta euismod lacus. Curabitur arcu nibh, tincidunt in fringilla quis, rutrum id purus.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec rhoncus condimentum sem a facilisis. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Maecenas lacinia fringilla tristique. Ut sollicitudin felis sem, in aliquam lectus eleifend nec. Pellentesque id nisi non arcu venenatis convallis vel id nulla. Nullam ut accumsan felis. Nunc nec tincidunt sapien, et posuere massa.
6 |
Cras sollicitudin neque ac erat fermentum, quis pulvinar dolor porta. Donec enim lacus, scelerisque ac iaculis eu, rhoncus sed urna. In hac habitasse platea dictumst. Nullam sodales, leo at congue ultricies, mauris sapien ornare ipsum, id dictum orci nulla at leo. Nullam non tristique mauris. Donec aliquam lobortis tortor in volutpat. In at sem sed felis faucibus vulputate. Duis risus leo, aliquet ut lectus vel, sollicitudin egestas dui. Morbi eget justo tristique, tincidunt turpis interdum, faucibus turpis. Sed venenatis ut augue non accumsan. Aliquam at lorem et dui accumsan sodales. Mauris fermentum, ligula imperdiet pharetra feugiat, erat tortor sollicitudin nulla, et condimentum mauris lectus ullamcorper enim. Integer volutpat mauris nisl, non congue turpis fringilla et. Sed rhoncus mollis libero, ut lacinia quam elementum in.
7 |
Pellentesque eu arcu condimentum, vulputate leo nec, tincidunt massa. Vestibulum leo libero, aliquet nec enim quis, consequat faucibus elit. Sed tristique et velit vel iaculis. Sed suscipit commodo tellus nec imperdiet. Aenean tristique at urna eget aliquet. Etiam vestibulum ligula ac nunc viverra, quis scelerisque est facilisis. Praesent fermentum eros urna, nec consequat sapien accumsan sit amet. Maecenas congue elit id lacinia scelerisque. Mauris in nibh justo.
8 |
Suspendisse potenti. Vestibulum eu molestie diam. Quisque tristique volutpat felis, eget tempor lorem ullamcorper sed. Vivamus aliquam cursus mollis. Cras accumsan justo nec augue dignissim lacinia. Mauris eu tortor lacus. Interdum et malesuada fames ac ante ipsum primis in faucibus. Quisque at lacus a elit elementum semper. Morbi dictum metus lorem, eu ornare nisi gravida ut. Interdum et malesuada fames ac ante ipsum primis in faucibus. Suspendisse velit odio, posuere sed mi quis, elementum elementum urna. Etiam mi mi, sollicitudin at ipsum sit amet, posuere gravida sapien.
9 |
Curabitur vel mauris id velit lobortis rutrum. In hac habitasse platea dictumst. Fusce in mattis ante, eget tristique ipsum. Sed lorem augue, laoreet eu viverra eget, porttitor sed massa. Quisque suscipit mauris sem, vitae varius arcu gravida sit amet. Interdum et malesuada fames ac ante ipsum primis in faucibus. Ut ipsum libero, fringilla eu nibh vitae, porta euismod lacus. Curabitur arcu nibh, tincidunt in fringilla quis, rutrum id purus.
10 |
--------------------------------------------------------------------------------
/examples/server-side-react/app/render.js:
--------------------------------------------------------------------------------
1 | /**
2 | * This is a generic render helper for
3 | * cherrytree + react + express
4 | *
5 | * There are more concerns in a real life app
6 | * than are addressed in this simple example,
7 | * but this should be a good starting point.
8 | *
9 | * In particular, with further tweaks, this could
10 | * be turned into an isomorphic app, since we could
11 | * render the same routes on the client.
12 | *
13 | * Data fetching is not addressed here, but see
14 | * the https://github.com/KidkArolis/cherrytree-redux-react-example
15 | * for how data management could be addressed in
16 | * a cherrytree routed app.
17 | *
18 | * Data fetching could be configured via cherrytree
19 | * middleware since the middleware would get the transition
20 | * with all required routes passed in and returning a
21 | * promise there will block the rendering.
22 | */
23 |
24 | let cherrytree = require('cherrytree')
25 | let { Router } = require('cherrytree-for-react')
26 | let React = require('react')
27 |
28 | module.exports = function (routes, options) {
29 | return function render (req, res, next) {
30 | let url = req.url
31 | let router = cherrytree(Object.assign({location: 'memory'}, options))
32 | .map(routes())
33 | // just an example of how redirects work,
34 | // you can setup various redirect strategies
35 | // in general e.g.
36 | // * define a `redirect` option in your route map
37 | // and handle it in a middleware
38 | // * redirect in a static method in your components
39 | // and call that method from within a middleware
40 | // * in this case, we just redirect straight from the
41 | // middleware
42 | .use(function adminRedirectDemo (transition) {
43 | if (transition.path === '/admin') {
44 | router.transitionTo('home')
45 | return
46 | }
47 | })
48 |
49 | // kick off routing
50 | let transition = router.listen(url)
51 |
52 | // after transitioning completes - render or redirect
53 | transition
54 | .then(function () {
55 | // the component from cherrytree-for-react
56 | // behaves differently when you pass in an already
57 | // started cherrytree - on the client, the usage
58 | // is slightly different
59 | res.send(React.renderToString())
60 | }).catch(function (err) {
61 | if (err.type === 'TransitionRedirected' && err.nextPath) {
62 | res.redirect(err.nextPath)
63 | } else {
64 | next(err)
65 | }
66 | })
67 |
68 | // after everything - clean up
69 | // this also makes sure transitions are cancelled
70 | // during redirects. I.e. a redirect will reject
71 | // this transition #1 and destroying the router
72 | // will make sure that all subsequence transitions
73 | // won't happen anymore
74 | transition.then(cleanup).catch(cleanup)
75 | function cleanup () {
76 | router.destroy()
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/examples/cherry-pick/app/screens/application/screens/repo/screens/code/index.js:
--------------------------------------------------------------------------------
1 | import './code.css'
2 | import React from 'react'
3 | import R from 'ramda'
4 | import * as github from 'github'
5 | let decode = window.atob
6 |
7 | let Breadcrumb = React.createClass({
8 | render () {
9 | var props = this.props
10 | return (
11 |
I just published “What will Datasmoothie bring to the analytics startup landscape?” https://medium.com/@afanasjevas/what-will-datasmoothie-bring-to-the-analytics-startup-landscape-f7dab70d75c3?source=tw-81c4e81fe6f8-1427630532296
I just published “What will Datasmoothie bring to the analytics startup landscape?” https://medium.com/@afanasjevas/what-will-datasmoothie-bring-to-the-analytics-startup-landscape-f7dab70d75c3?source=tw-81c4e81fe6f8-1427630532296
I just published “What will Datasmoothie bring to the analytics startup landscape?” https://medium.com/@afanasjevas/what-will-datasmoothie-bring-to-the-analytics-startup-landscape-f7dab70d75c3?source=tw-81c4e81fe6f8-1427630532296
129 | )
130 | }
131 | })
132 |
--------------------------------------------------------------------------------
/docs/intro.md:
--------------------------------------------------------------------------------
1 | # Cherrytree guide
2 |
3 | When your application starts, the router is responsible for loading data, rendering views and otherwise setting up application state. It does so by translating every URL change to a transition object and a list of matching routes. You then need to apply a middleware function to translate the transition data into the desired state of your application.
4 |
5 | First create an instance of the router.
6 |
7 | ```js
8 | var cherrytree = require("cherrytree");
9 | var router = cherrytree({
10 | pushState: true
11 | });
12 | ```
13 |
14 | Then use the `map` method to declare the route map.
15 |
16 | ```js
17 | router.map(function (route) {
18 | route('application', { path: '/', abstract: true, handler: App }, function () {
19 | route('index', { path: '', handler: Index })
20 | route('about', { handler: About })
21 | route('favorites', { path: 'favs', handler: Favorites })
22 | route('message', { path: 'message/:id', handler: Message })
23 | })
24 | });
25 | ```
26 |
27 | Next, install middleware.
28 |
29 | ```js
30 | router.use(function activate (transition) {
31 | transition.routes.forEach(function (route) {
32 | route.options.handler.activate(transition.params, transition.query)
33 | })
34 | })
35 | ```
36 |
37 | Now, when the user enters `/about` page, Cherrytree will call the middleware with the transition object and `transition.routes` will be the route descriptors of `application` and `about` routes.
38 |
39 | Note that you can leave off the path if you want to use the route name as the path. For example, these are equivalent
40 |
41 | ```js
42 | router.map(function(route) {
43 | route('about');
44 | });
45 |
46 | // or
47 |
48 | router.map(function(route) {
49 | route('about', {path: 'about'});
50 | });
51 | ```
52 |
53 | To generate links to the different routes use `generate` and pass the name of the route:
54 |
55 | ```js
56 | router.generate('favorites')
57 | // => /favs
58 | router.generate('index');
59 | // => /
60 | router.generate('messages', {id: 24});
61 | ```
62 |
63 | If you disable pushState (`pushState: false`), the generated links will start with `#`.
64 |
65 | ### Route params
66 |
67 | Routes can have dynamic urls by specifying patterns in the `path` option. For example:
68 |
69 | ```js
70 | router.map(function(route) {
71 | route('posts');
72 | route('post', { path: '/post/:postId' });
73 | });
74 |
75 | router.use(function (transition) {
76 | console.log(transition.params)
77 | // => {postId: 5}
78 | });
79 |
80 | router.transitionTo('/post/5')
81 | ```
82 |
83 | See what other types of dynamic routes is supported in the [api docs](api.md#dynamic-paths).
84 |
85 | ### Route Nesting
86 |
87 | Route nesting is one of the core features of cherrytree. It's useful to nest routes when you want to configure each route to perform a different role in rendering the page - e.g. the root `application` route can do some initial data loading/initialization, but you can avoid redoing that work on subsequent transitions by checking if the route is already in the middleware. The nested routes can then load data specific for a given page. Nesting routes is also very useful for rendering nested UIs, e.g. if you're building an email application, you might have the following route map
88 |
89 | ```js
90 | router.map(function(route) {
91 | route('gmail', {path: '/', abstract: true}, function () {
92 | route('inbox', {path: ''}, function () {
93 | route('email', {path: 'm/:emailId'}, function () {
94 | route('email.raw')
95 | })
96 | })
97 | })
98 | })
99 | ```
100 |
101 | This router creates the following routes:
102 |
103 |
104 |
105 |
106 |
107 |
URL
108 |
Route Name
109 |
Purpose
110 |
111 |
112 |
113 |
N/A
114 |
gmail
115 |
Can't route to it, it's an abstract route.
116 |
117 |
118 |
/
119 |
inbox
120 |
Load 1 page of emails and render it.
121 |
122 |
123 |
/m/:emailId/
124 |
email
125 |
Load the email contents of email with id `transition.params.emailId` and expand it in the list of emails while keeping the email list rendered.
126 |
127 |
128 |
/m/:mailId/raw
129 |
email.raw
130 |
Render the raw textual version of the email in an expanded pane.
131 |
132 |
133 |
134 |
135 | ## Examples
136 |
137 | I hope you found this brief guide useful, check out some example apps next in the [examples](../examples) dir.
138 |
--------------------------------------------------------------------------------
/lib/transition.js:
--------------------------------------------------------------------------------
1 | import { clone } from './dash'
2 | import invariant from './invariant'
3 |
4 | export default function transition (options, Promise) {
5 | options = options || {}
6 |
7 | let router = options.router
8 | let log = router.log
9 | let logError = router.logError
10 |
11 | let path = options.path
12 | let match = options.match
13 | let routes = match.routes
14 | let params = match.params
15 | let pathname = match.pathname
16 | let query = match.query
17 |
18 | let id = options.id
19 | let startTime = Date.now()
20 | log('---')
21 | log('Transition #' + id, 'to', path)
22 | log('Transition #' + id, 'routes:', routes.map(r => r.name))
23 | log('Transition #' + id, 'params:', params)
24 | log('Transition #' + id, 'query:', query)
25 |
26 | // create the transition promise
27 | let resolve, reject
28 | let promise = new Promise(function (res, rej) {
29 | resolve = res
30 | reject = rej
31 | })
32 |
33 | // 1. make transition errors loud
34 | // 2. by adding this handler we make sure
35 | // we don't trigger the default 'Potentially
36 | // unhandled rejection' for cancellations
37 | promise.then(function () {
38 | log('Transition #' + id, 'completed in', (Date.now() - startTime) + 'ms')
39 | }).catch(function (err) {
40 | if (err.type !== 'TransitionRedirected' && err.type !== 'TransitionCancelled') {
41 | log('Transition #' + id, 'FAILED')
42 | logError(err.stack)
43 | }
44 | })
45 |
46 | let cancelled = false
47 |
48 | let transition = {
49 | id: id,
50 | prev: {
51 | routes: clone(router.state.routes) || [],
52 | path: router.state.path || '',
53 | pathname: router.state.pathname || '',
54 | params: clone(router.state.params) || {},
55 | query: clone(router.state.query) || {}
56 | },
57 | routes: clone(routes),
58 | path: path,
59 | pathname: pathname,
60 | params: clone(params),
61 | query: clone(query),
62 | redirectTo: function () {
63 | return router.transitionTo.apply(router, arguments)
64 | },
65 | retry: function () {
66 | return router.transitionTo(path)
67 | },
68 | cancel: function (err) {
69 | if (router.state.activeTransition !== transition) {
70 | return
71 | }
72 |
73 | if (transition.isCancelled) {
74 | return
75 | }
76 |
77 | router.state.activeTransition = null
78 | transition.isCancelled = true
79 | cancelled = true
80 |
81 | if (!err) {
82 | err = new Error('TransitionCancelled')
83 | err.type = 'TransitionCancelled'
84 | }
85 | if (err.type === 'TransitionCancelled') {
86 | log('Transition #' + id, 'cancelled')
87 | }
88 | if (err.type === 'TransitionRedirected') {
89 | log('Transition #' + id, 'redirected')
90 | }
91 |
92 | reject(err)
93 | },
94 | followRedirects: function () {
95 | return promise['catch'](function (reason) {
96 | if (router.state.activeTransition) {
97 | return router.state.activeTransition.followRedirects()
98 | }
99 | return Promise.reject(reason)
100 | })
101 | },
102 |
103 | then: promise.then.bind(promise),
104 | catch: promise.catch.bind(promise)
105 | }
106 |
107 | // here we handle calls to all of the middlewares
108 | function callNext (i, prevResult) {
109 | let middlewareName
110 | // if transition has been cancelled - nothing left to do
111 | if (cancelled) {
112 | return
113 | }
114 | // done
115 | if (i < router.middleware.length) {
116 | middlewareName = router.middleware[i].name || 'anonymous'
117 | log('Transition #' + id, 'resolving middleware:', middlewareName)
118 | let middlewarePromise
119 | try {
120 | middlewarePromise = router.middleware[i](transition, prevResult)
121 | invariant(transition !== middlewarePromise, 'Middleware %s returned a transition which resulted in a deadlock', middlewareName)
122 | } catch (err) {
123 | router.state.activeTransition = null
124 | return reject(err)
125 | }
126 | Promise.resolve(middlewarePromise)
127 | .then(function (result) {
128 | callNext(i + 1, result)
129 | })
130 | .catch(function (err) {
131 | log('Transition #' + id, 'resolving middleware:', middlewareName, 'FAILED')
132 | router.state.activeTransition = null
133 | reject(err)
134 | })
135 | } else {
136 | router.state = {
137 | activeTransition: null,
138 | routes: routes,
139 | path: path,
140 | pathname: pathname,
141 | params: params,
142 | query: query
143 | }
144 | resolve()
145 | }
146 | }
147 |
148 | if (!options.noop) {
149 | Promise.resolve().then(() => callNext(0))
150 | } else {
151 | resolve()
152 | }
153 |
154 | if (options.noop) {
155 | transition.noop = true
156 | }
157 |
158 | return transition
159 | }
160 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ### v2.4.1
2 |
3 | * Fix a broken release, for some reason the `npm run release` failed to package correctly
4 |
5 | ### v2.4.0
6 |
7 | * Make it possible to `transitionTo('anAbstractRoute')` and `generate('anAbstractRoute')` in cases where the abstract route has a corresponding index route. This can be more intuitive in some cases.
8 |
9 | ### v2.3.2
10 |
11 | * URL encode slashes in route params
12 |
13 | ### v2.3.1
14 |
15 | * Don't intercept clicks on `mailto:` links
16 |
17 | ### v2.2.1
18 |
19 | * Fix: stop using Array.prototype.find which is not available in older browsers
20 |
21 | ### v2.2.0
22 |
23 | * Add router.isActive method for testing if a given route is currently active. See [docs](docs/api.md#routerisactivename-params-query)
24 |
25 | ### v2.1.0
26 |
27 | * Parse query params when transitioning even when no route matches
28 |
29 | ### v2.0.0
30 |
31 | Nothing changed from v2.0.0-rc4.
32 |
33 | ### v2.0.0-rc4
34 |
35 | * BrowserLocation and HistoryLocation can now be accessed at cherrytree.BrowserLocation and cherrytree.MemoryLocation again. This is to make it easier to use those modules for UMD users (#116).
36 |
37 | ### v2.0.0-rc3
38 |
39 | Breaking changes:
40 |
41 | * `HistoryLocation` has been renamed to `BrowserLocation`. Location in cherrytree is the place that stores the current location of the app. Location is updated with the new path when cherytree transitions. Location also triggers updates when someone changes the location externally (e.g. by navigating with back/forward buttons or updating the URL). `BrowserLocation` is a more apt name since this location implementation represents browser's location bar and is configurable to use pushState or hashchange. This way, the other location that ships with cherrytree, `MemoryLocation`- also makes more sense, in this case we're saying the URL is simply stored in this in memory object and not really connected to the browser (which is what makes it useful on the server, for example).
42 |
43 | ### v2.0.0-rc2
44 |
45 | * Fix: query params were stringified incorrectly when more than 2 params and when some of params were undefined. `router.generate('/a/b/c', {}, { id: 'def', foo: 'bar', baz: undefined })` results in `/a/b/c?id=def&foo=bar` now as in the older versions of cherrytree.
46 |
47 | ### v2.0.0-rc1
48 |
49 | Breaking changes:
50 |
51 | * Every route is now routable. Previously it was only possible to generate links and transition to leaf routes. This simplifies the typical usage of the router and opens up new use cases as well. For example, if you want to redirect from '/' to '/some/:id', it's now easier to implement this kind of redirect behaviour without needing to create many reduntant '.index' routes.
52 | * The special `.index` treatment has been removed. Previously, if the route name ended with `.index`, the path was automatically set to ''. Now, such path will default to 'index' as with all other routes. Set `path: ''` on your index routes when upgrading.
53 | * An exception is now thrown when multiple routes have the same URL pattern.
54 | * Given all the above changes - a new route option `abstract: true` was introduced for making non leaf routes non routable. This also solves the problem where using `path: ''` would result in multiple routes with the same path.
55 | * The `paramNames` array (e.g. ['id', 'filter']) was replaced with `params` object (e.g. {id: 1, filter: 'foo'}) in the route descriptor on the transition object.
56 | * The `ancestors` attribute was removed from the route descriptor.
57 | * Switching between using `history` and `memory` locations has been simplified. Previously, you'd need to pass `new MemoryLocation(path)` when calling `listen`. Now, specify the location to use with `location: 'memory'` when creating the router and pass the path when calling `listen`.
58 | * The `qs` module was removed from dependencies and was replaced with a tiny, simple query string parser. This can be sufficient for a lot of applications and saves a couple of kilobytes. If you want to use `qs` or any other query parsing module, pass it as `qs: require('qs')` option to the router.
59 | * params, query and route array are now immutable between transitions, i.e. modifying those directly on the transition only affects that transition
60 | * Drop out-of-the-box support for ES3 environments (IE8). To use Cherrytree in older environments - es5 polyfills for native `map`, `reduce` and `forEach` need to be used now.
61 | * An undocumented, noop function `reset` was removed from the router.
62 |
63 | New features:
64 |
65 | * Support for custom [click intercept handlers](docs/api.md#intercepting-links)
66 |
67 | Under the hood improvements:
68 |
69 | * Update all dependencies to the latest versions
70 | * Tests are being run in more browsers now
71 | * Replaced `co` with `babel-creed-async` in tests
72 | * Removed the dependency on `lodash`
73 |
74 | Documentation:
75 |
76 | * Moved docs back to a separate [`docs/api.md`](docs/api.md) file
77 | * Documented [router.matchers](docs/api.md#routermatchers)
78 | * Documented [404 handling](docs/api.md#handling-404)
79 |
80 | ### v2.0.0-alpha.12
81 |
82 | * BYOP - Cherrytree now requires a global Promise implementation to be available or a Promise constructor passed in as an option
83 |
84 | ### v2.0.0-alpha.11
85 |
86 | * Add `transition.redirectTo` so that middleware could initiate redirects without having the router
87 |
88 | ### v2.0.0-alpha.10
89 |
90 | * Log errors by default (i.e. options.logError: true by default)
91 |
92 | ### v2.0.0-alpha.9
93 |
94 | * Fix router.destroy() - DOM click events for link interception are now cleaned up when router.destroy() is called
95 | * Add server side support
96 | * events.js now exports an {} object on the server instead of crashing due to missing `window`
97 | * MemoryLocation correctly handles option flags and can be instantiated with a starting `path`
98 | * Add a [server-side-react example](../examples/server-side-react)
99 | * When transition is rejected with a `TransitionRedirected` error - the `err.nextPath` is now available)
100 |
101 | ### v2.0.0-alpha.8
102 |
103 | * Fix dependencies - lodash was declared as a devDependency
104 |
105 | ### v2.0.0-alpha.7
106 |
107 | * Fix the URL generation when `pushState: true` and root !== '/'
108 |
109 | ### v2.0.0-alpha.1
110 |
111 | A brand new and improved cherrytree!
112 |
113 | ### v0.x.x
114 |
115 | See https://github.com/QubitProducts/cherrytree/tree/677f2c915780d712968023b8d24306ff787a426d
116 |
--------------------------------------------------------------------------------
/tests/unit/pathTest.js:
--------------------------------------------------------------------------------
1 | import { assert } from 'referee'
2 | import qs from '../../lib/qs'
3 | import Path from '../../lib/path'
4 |
5 | let {suite, test} = window
6 |
7 | suite('Path')
8 |
9 | test('Path.extractParamNames', () => {
10 | assert.equals(Path.extractParamNames('a/b/c'), [])
11 | assert.equals(Path.extractParamNames('/comments/:a/:b/edit'), ['a', 'b'])
12 | assert.equals(Path.extractParamNames('/files/:path*.jpg'), ['path'])
13 | })
14 |
15 | test('Path.extractParams', () => {
16 | assert.equals(Path.extractParams('a/b/c', 'a/b/c'), {})
17 | assert.equals(Path.extractParams('a/b/c', 'd/e/f'), null)
18 |
19 | assert.equals(Path.extractParams('comments/:id.:ext/edit', 'comments/abc.js/edit'), { id: 'abc', ext: 'js' })
20 |
21 | assert.equals(Path.extractParams('comments/:id?/edit', 'comments/123/edit'), { id: '123' })
22 | assert.equals(Path.extractParams('comments/:id?/edit', 'comments/the%2Fid/edit'), { id: 'the/id' })
23 | assert.equals(Path.extractParams('comments/:id?/edit', 'comments//edit'), null)
24 | assert.equals(Path.extractParams('comments/:id?/edit', 'users/123'), null)
25 |
26 | assert.equals(Path.extractParams('one, two', 'one, two'), {})
27 | assert.equals(Path.extractParams('one, two', 'one two'), null)
28 |
29 | assert.equals(Path.extractParams('/comments/:id/edit now', '/comments/abc/edit now'), { id: 'abc' })
30 | assert.equals(Path.extractParams('/comments/:id/edit now', '/users/123'), null)
31 |
32 | assert.equals(Path.extractParams('/files/:path*', '/files/my/photo.jpg'), { path: 'my/photo.jpg' })
33 | assert.equals(Path.extractParams('/files/:path*', '/files/my/photo.jpg.zip'), { path: 'my/photo.jpg.zip' })
34 | assert.equals(Path.extractParams('/files/:path*.jpg', '/files/my%2Fphoto.jpg'), { path: 'my/photo' })
35 | assert.equals(Path.extractParams('/files/:path*', '/files'), { path: undefined })
36 | assert.equals(Path.extractParams('/files/:path*', '/files/'), { path: undefined })
37 | assert.equals(Path.extractParams('/files/:path*.jpg', '/files/my/photo.png'), null)
38 |
39 | // splat with named
40 | assert.equals(Path.extractParams('/files/:path*.:ext', '/files/my/photo.jpg'), { path: 'my/photo', ext: 'jpg' })
41 |
42 | // multiple splats
43 | assert.equals(Path.extractParams('/files/:path*.:ext*', '/files/my/photo.jpg/gif'), { path: 'my/photo', ext: 'jpg/gif' })
44 |
45 | // one more more segments
46 | assert.equals(Path.extractParams('/files/:path+', '/files/my/photo.jpg'), { path: 'my/photo.jpg' })
47 | assert.equals(Path.extractParams('/files/:path+', '/files/my/photo.jpg.zip'), { path: 'my/photo.jpg.zip' })
48 | assert.equals(Path.extractParams('/files/:path+.jpg', '/files/my/photo.jpg'), { path: 'my/photo' })
49 | assert.equals(Path.extractParams('/files/:path+', '/files'), null)
50 | assert.equals(Path.extractParams('/files/:path+', '/files/'), null)
51 | assert.equals(Path.extractParams('/files/:path+.jpg', '/files/my/photo.png'), null)
52 |
53 | assert.equals(Path.extractParams('/archive/:name?', '/archive'), { name: undefined })
54 | assert.equals(Path.extractParams('/archive/:name?', '/archive/'), { name: undefined })
55 | assert.equals(Path.extractParams('/archive/:name?', '/archive/foo'), { name: 'foo' })
56 | assert.equals(Path.extractParams('/archive/:name?', '/archivefoo'), null)
57 | assert.equals(Path.extractParams('/archive/:name?', '/archiv'), null)
58 |
59 | assert.equals(Path.extractParams('/:query/with/:domain', '/foo/with/foo.app'), { query: 'foo', domain: 'foo.app' })
60 | assert.equals(Path.extractParams('/:query/with/:domain', '/foo.ap/with/foo'), { query: 'foo.ap', domain: 'foo' })
61 | assert.equals(Path.extractParams('/:query/with/:domain', '/foo.ap/with/foo.app'), { query: 'foo.ap', domain: 'foo.app' })
62 | assert.equals(Path.extractParams('/:query/with/:domain', '/foo.ap'), null)
63 |
64 | // advanced use case of making params in the middle of the url optional
65 | assert.equals(Path.extractParams('/comments/:id(.*\/?edit)', '/comments/123/edit'), {id: '123/edit'})
66 | assert.equals(Path.extractParams('/comments/:id(.*\/?edit)', '/comments/edit'), {id: 'edit'})
67 | assert.equals(Path.extractParams('/comments/:id(.*\/?edit)', '/comments/editor'), null)
68 | assert.equals(Path.extractParams('/comments/:id(.*\/?edit)', '/comments/123'), null)
69 | })
70 |
71 | test('Path.injectParams', () => {
72 | assert.equals(Path.injectParams('/a/b/c', {}), '/a/b/c')
73 |
74 | assert.exception(() => Path.injectParams('comments/:id/edit', {}))
75 |
76 | assert.equals(Path.injectParams('comments/:id?/edit', { id: '123' }), 'comments/123/edit')
77 | assert.equals(Path.injectParams('comments/:id?/edit', {}), 'comments//edit')
78 | assert.equals(Path.injectParams('comments/:id?/edit', { id: 'abc' }), 'comments/abc/edit')
79 | assert.equals(Path.injectParams('comments/:id?/edit', { id: 0 }), 'comments/0/edit')
80 | assert.equals(Path.injectParams('comments/:id?/edit', { id: 'one, two' }), 'comments/one%2C%20two/edit')
81 | assert.equals(Path.injectParams('comments/:id?/edit', { id: 'the/id' }), 'comments/the%2Fid/edit')
82 | assert.equals(Path.injectParams('comments/:id?/edit', { id: 'alt.black.helicopter' }), 'comments/alt.black.helicopter/edit')
83 |
84 | assert.equals(Path.injectParams('/a/:foo*/d', { foo: 'b/c' }), '/a/b/c/d')
85 | assert.equals(Path.injectParams('/a/:foo*/c/:bar*', { foo: 'b', bar: 'd' }), '/a/b/c/d')
86 | assert.equals(Path.injectParams('/a/:foo*/c/:bar*', { foo: 'b' }), '/a/b/c/')
87 |
88 | assert.equals(Path.injectParams('/a/:foo+/d', { foo: 'b/c' }), '/a/b/c/d')
89 | assert.equals(Path.injectParams('/a/:foo+/c/:bar+', { foo: 'b?', bar: 'd ' }), '/a/b%3F/c/d%20')
90 | assert.exception(() => Path.injectParams('/a/:foo+/c/:bar+', { foo: 'b' }))
91 |
92 | assert.equals(Path.injectParams('/foo.bar.baz'), '/foo.bar.baz')
93 | })
94 |
95 | test('Path.extractQuery', () => {
96 | assert.equals(Path.extractQuery(qs, '/?id=def&show=true'), { id: 'def', show: 'true' })
97 | assert.equals(Path.extractQuery(qs, '/?id=a%26b'), { id: 'a&b' })
98 | assert.equals(Path.extractQuery(qs, '/a/b/c'), null)
99 | })
100 |
101 | test('Path.withoutQuery', () => {
102 | assert.equals(Path.withoutQuery('/a/b/c?id=def'), '/a/b/c')
103 | })
104 |
105 | test('Path.withQuery', () => {
106 | assert.equals(Path.withQuery(qs, '/a/b/c', { id: 'def' }), '/a/b/c?id=def')
107 | assert.equals(Path.withQuery(qs, '/a/b/c', { id: 'def', foo: 'bar', baz: undefined }), '/a/b/c?id=def&foo=bar')
108 | assert.equals(Path.withQuery(qs, '/path?a=b', { c: 'f&a=i#j+k' }), '/path?c=f%26a%3Di%23j%2Bk')
109 | })
110 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | #
2 |
3 | Cherrytree is a flexible hierarchical router that translates every URL change into a transition descriptor object and calls your middleware functions that put the application into a desired state.
4 |
5 |
6 | ## Installation
7 |
8 | The size excluding all deps is ~4.83kB gzipped and the standalone build with all deps is ~7.24kB gzipped.
9 |
10 | $ npm install --save cherrytree
11 |
12 | In a CJS environment
13 |
14 | require('cherrytree')
15 |
16 | In an AMD environment, require the standalone UMD build - this version has all of the dependencies bundled
17 |
18 | require('cherrytree/standalone')
19 |
20 |
21 | ## Docs
22 |
23 | * [Intro Guide](docs/intro.md)
24 | * [API Docs](docs/api.md)
25 | * [Changelog](CHANGELOG.md)
26 |
27 |
28 | ## Demo
29 |
30 | See it in action in [this demo](http://kidkarolis.github.io/cherrytree-redux-react-example).
31 |
32 |
33 | ## Plugins
34 |
35 | To use `cherrytree` with React, check out [`cherrytree-for-react`](https://github.com/KidkArolis/cherrytree-for-react).
36 |
37 |
38 | ## Usage
39 |
40 | ```js
41 | var cherrytree = require('cherrytree')
42 |
43 | // create the router
44 | var router = cherrytree()
45 | var handlers = require('./handlers')
46 |
47 | // provide your route map
48 | router.map(function (route) {
49 | route('application', {path: '/', abstract: true}, function () {
50 | route('feed', {path: ''})
51 | route('messages')
52 | route('status', {path: ':user/status/:id'})
53 | route('profile', {path: ':user'}, function () {
54 | route('profile.lists')
55 | route('profile.edit')
56 | })
57 | })
58 | })
59 |
60 | router.use(function render (transition) {
61 | transition.routes.forEach(function (route, i) {
62 | route.view = handlers[route.name]({
63 | params: transition.params,
64 | query: transition.query
65 | })
66 | var parent = transition.routes[i-1]
67 | var containerEl = parent ? parent.view.el.querySelector('.outlet') : document.body
68 | containerEl.appendChild(view.render().el)
69 | })
70 | })
71 |
72 | router.use(function errorHandler (transition) {
73 | transition.catch(function (err) {
74 | if (err.type !== 'TransitionCancelled' && err.type !== 'TransitionRedirected') {
75 | console.error(err.stack)
76 | }
77 | })
78 | })
79 |
80 | // start listening to URL changes
81 | router.listen()
82 | ```
83 |
84 |
85 | ## Examples
86 |
87 | You can clone this repo if you want to run the `examples` locally:
88 |
89 | * [hello-world-react](examples/hello-world-react) - best for first introduction
90 | * [hello-world-jquery](examples/hello-world-jquery) - a single file example
91 | * [cherry-pick](examples/cherry-pick) - a mini GitHub clone written in React.js
92 | * [vanilla-blog](examples/vanilla-blog) - a small static demo of blog like app that uses no framework
93 | * [server-side-react](examples/server-side-react) - a server side express app using cherrytree for routing and react for rendering
94 |
95 | A more complex example in it's own repo:
96 |
97 | * [cherrytree-redux-react-example](https://github.com/KidkArolis/cherrytree-redux-react-example) - a more modern stack - redux + react + react-hot-loader + cherrytree-for-react
98 |
99 |
100 | ## Features
101 |
102 | * can be used with any view and data framework
103 | * nested routes are great for nested UIs
104 | * generate links in a systematic way, e.g. `router.generate('commit', {sha: '1e2760'})`
105 | * use pushState with automatic hashchange fallback
106 | * all urls are generated with or without `#` as appropriate
107 | * link clicks on the page are intercepted automatically when using pushState
108 | * dynamically load parts of your app during transitions
109 | * dynamic segments, optional params and query params
110 | * support for custom query string parser
111 | * transition is a first class citizen - abort, pause, resume, retry. E.g. pause the transition to display "There are unsaved changes" message if the user clicked some link on the page or used browser's back/forward buttons
112 | * navigate around the app programatically, e.g. `router.transitionTo('commits')`
113 | * easily rename URL segments in a single place (e.g. /account -> /profile)
114 |
115 |
116 | ## How does it compare to other routers?
117 |
118 | * **Backbone router** is nice and simple and can often be enough. In fact cherrytree uses some bits from Backbone router under the hood. Cherrytree adds nested routing, support for asynchronous transitions, more flexible dynamic params, url generation, automatic click handling for pushState.
119 | * **Ember router / router.js** is the inspiration for cherrytree. It's where cherrytree inherits the idea of declaring hierarchical nested route maps. The scope of cherrytree is slightly different than that of router.js, for example cherrytree doesn't have the concept of handler objects or model hooks. On the other hand, unlike router.js - cherrytree handles browser url changes and intercepts link clicks with pushState out of the box. The handler concept and model hooks can be implemented based on the specific application needs using the middleware mechanism. Overall, cherrytree is less prescriptive, more flexible and easier to use out of the box.
120 | * **react-router** is also inspired by router.js. React-router is trying to solve a lot of routing related aspects out of the box in the most React idiomatic way whereas with `cherrytree` you'll have to write the glue code for integrating into React yourself (see [`cherrytree-for-react` plugin](https://github.com/KidkArolis/cherrytree-for-react)). However, what you get instead is a smaller, simpler and hopefully more flexible library which should be more adaptable to your specific needs. This also means that you can use a `react-router` like approach with other `React` inspired libraries such as `mercury`, `riot`, `om`, `cycle`, `deku` and so on.
121 |
122 |
123 | ## CI
124 |
125 | [](https://travis-ci.org/QubitProducts/cherrytree)
126 | [](https://codeship.com/projects/19734)
127 |
128 |
129 | ## Browser Support
130 |
131 | [](https://saucelabs.com/u/cherrytree)
132 |
133 | Cherrytree works in all modern browsers. It requires es5 environment and es6 promises. Use polyfills for those if you have to support older browsers, e.g.:
134 |
135 | * https://github.com/es-shims/es5-shim
136 | * https://github.com/jakearchibald/es6-promise
137 |
138 | ## Acknowledgement
139 |
140 | Thanks to Marko Stupić for giving Cherrytree a logo from his http://icon-a-day.com/ project!
141 |
142 | ## FAQ
143 |
144 | * Why is `cherrytree` written as one word? You got me, I'd say that represents the [wabisabi](https://en.wikipedia.org/wiki/Wabi-sabi) nature of the library.
145 |
146 | ## Want to work on this for your day job?
147 |
148 | This project was created by the Engineering team at [Qubit](http://www.qubit.com). As we use open source libraries, we make our projects public where possible.
149 |
150 | We’re currently looking to grow our team, so if you’re a JavaScript engineer and keen on ES2016 React+Redux applications and Node micro services, why not get in touch? Work with like minded engineers in an environment that has fantastic perks, including an annual ski trip, yoga, a competitive foosball league, and copious amounts of yogurt.
151 |
152 | Find more details on our [Engineering site](https://eng.qubit.com). Don’t have an up to date CV? Just link us your Github profile! Better yet, send us a pull request that improves this project.
153 |
--------------------------------------------------------------------------------
/tests/functional/routerTest.js:
--------------------------------------------------------------------------------
1 | import $ from 'jquery'
2 | import { Promise } from 'es6-promise'
3 | import { assert } from 'referee'
4 | import TestApp from './testApp'
5 |
6 | let { suite, test, beforeEach, afterEach } = window
7 | let app, router
8 |
9 | suite('Cherrytree app')
10 |
11 | beforeEach(() => {
12 | window.location.hash = '/'
13 | app = new TestApp()
14 | router = app.router
15 | return app.start()
16 | })
17 |
18 | afterEach(() => {
19 | app.destroy()
20 | })
21 |
22 | test('transition occurs when location.hash changes', (done) => {
23 | router.use((transition) => {
24 | transition.then(() => {
25 | assert.equals(transition.path, '/about')
26 | assert.equals($('.application .outlet').html(), 'This is about page')
27 | done()
28 | }).catch(done, done)
29 | })
30 |
31 | window.location.hash = '#about'
32 | })
33 |
34 | test('programmatic transition via url and route names', async function () {
35 | await router.transitionTo('about')
36 | await router.transitionTo('/faq?sortBy=date')
37 | assert.equals($('.application .outlet').html(), 'FAQ. Sorted By: date')
38 | await router.transitionTo('faq', {}, { sortBy: 'user' })
39 | assert.equals($('.application .outlet').html(), 'FAQ. Sorted By: user')
40 | })
41 |
42 | test('cancelling and retrying transitions', async function () {
43 | await router.transitionTo('/posts/filter/foo')
44 | assert.equals(router.location.getURL(), '/posts/filter/foo')
45 | var transition = router.transitionTo('about')
46 | transition.cancel()
47 | await transition.catch(() => {})
48 | assert.equals(router.location.getURL(), '/posts/filter/foo')
49 |
50 | await transition.retry()
51 | assert.equals(router.location.getURL(), '/about')
52 | })
53 |
54 | test('transition.followRedirects resolves when all of the redirects have finished', async function () {
55 | var transition
56 |
57 | await router.transitionTo('application')
58 | // initiate a transition
59 | transition = router.transitionTo('/posts/filter/foo')
60 | // and a redirect
61 | router.transitionTo('/about')
62 |
63 | // if followRedirects is not used - the original transition is rejected
64 | var rejected = false
65 | await transition.catch(() => rejected = true)
66 | assert(rejected)
67 |
68 | await router.transitionTo('application')
69 | // initiate a transition
70 | var t = router.transitionTo('/posts/filter/foo')
71 | // and a redirect, this time using `redirectTo`
72 | t.redirectTo('/about')
73 |
74 | // when followRedirects is used - the promise is only
75 | // resolved when both transitions finish
76 | await transition.followRedirects()
77 | assert.equals(router.location.getURL(), '/about')
78 | })
79 |
80 | test('transition.followRedirects is rejected if transition fails', async function () {
81 | var transition
82 |
83 | // silence the errors for the tests
84 | router.logError = () => {}
85 |
86 | // initiate a transition
87 | transition = router.transitionTo('/posts/filter/foo')
88 | // install a breaking middleware
89 | router.use(() => {
90 | throw new Error('middleware error')
91 | })
92 | // and a redirect
93 | router.transitionTo('/about')
94 |
95 | var rejected = false
96 | await transition.followRedirects().catch((err) => rejected = err.message)
97 | assert.equals(rejected, 'middleware error')
98 | })
99 |
100 | test('transition.followRedirects is rejected if transition fails asynchronously', async function () {
101 | var transition
102 |
103 | // silence the errors for the tests
104 | router.logError = () => {}
105 |
106 | // initiate a transition
107 | transition = router.transitionTo('/posts/filter/foo')
108 | // install a breaking middleware
109 | router.use(() => {
110 | return Promise.reject(new Error('middleware promise error'))
111 | })
112 | // and a redirect
113 | router.transitionTo('/about')
114 |
115 | var rejected = false
116 | await transition.followRedirects().catch((err) => rejected = err.message)
117 | assert.equals(rejected, 'middleware promise error')
118 | })
119 |
120 | test.skip('cancelling transition does not add a history entry', async function () {
121 | // we start of at faq
122 | await router.transitionTo('faq')
123 | // then go to posts.filter
124 | await router.transitionTo('posts.filter', {filterId: 'foo'})
125 | assert.equals(window.location.hash, '#posts/filter/foo')
126 |
127 | // now attempt to transition to about and cancel
128 | var transition = router.transitionTo('/about')
129 | transition.cancel()
130 | await transition.catch(() => {})
131 |
132 | // the url is still posts.filter
133 | assert.equals(window.location.hash, '#posts/filter/foo')
134 |
135 | // going back should now take as to faq
136 | await new Promise((resolve, reject) => {
137 | router.use((transition) => {
138 | transition.then(() => {
139 | assert.equals(window.location.hash, '#faq')
140 | resolve()
141 | }).catch(reject)
142 | })
143 | window.history.back()
144 | })
145 | })
146 |
147 | test('navigating around the app', async function () {
148 | assert.equals($('.application .outlet').html(), 'Welcome to this application')
149 |
150 | await router.transitionTo('about')
151 | assert.equals($('.application .outlet').html(), 'This is about page')
152 |
153 | await router.transitionTo('/faq?sortBy=date')
154 | assert.equals($('.application .outlet').html(), 'FAQ. Sorted By: date')
155 |
156 | await router.transitionTo('faq', {}, { sortBy: 'user' })
157 | assert.equals($('.application .outlet').html(), 'FAQ. Sorted By: user')
158 |
159 | // we can also change the url directly to cause another transition to happen
160 | await new Promise(function (resolve) {
161 | router.use(resolve)
162 | window.location.hash = '#posts/filter/mine'
163 | })
164 | assert.equals($('.application .outlet').html(), 'My posts...')
165 |
166 | await new Promise(function (resolve) {
167 | router.use(resolve)
168 | window.location.hash = '#posts/filter/foo'
169 | })
170 | assert.equals($('.application .outlet').html(), 'Filter not found')
171 | })
172 |
173 | test('url behaviour during transitions', async function () {
174 | assert.equals(window.location.hash, '#/')
175 | let transition = router.transitionTo('about')
176 | assert.equals(window.location.hash, '#about')
177 | await transition
178 | assert.equals(window.location.hash, '#about')
179 | // would be cool to test history.back() here
180 | // but in IE it reloads the karma iframe, so let's
181 | // use a regular location.hash assignment instead
182 | // window.history.back()
183 | window.location.hash = '#/'
184 | await new Promise((resolve) => {
185 | router.use((transition) => {
186 | assert.equals(window.location.hash, '#/')
187 | resolve()
188 | })
189 | })
190 | })
191 |
192 | test('url behaviour during failed transitions', async function () {
193 | router.logError = () => {}
194 | await router.transitionTo('about')
195 | await new Promise((resolve, reject) => {
196 | // setup a middleware that will fail
197 | router.use((transition) => {
198 | // but catch the error
199 | transition.catch((err) => {
200 | assert.equals(err.message, 'failed')
201 | assert.equals(window.location.hash, '#faq')
202 | resolve()
203 | }).catch(reject)
204 | throw new Error('failed')
205 | })
206 | router.transitionTo('faq')
207 | })
208 | })
209 |
210 | test('uses a custom provided Promise implementation', async function() {
211 | let called = 0
212 | var LocalPromise = function (fn) {
213 | called++
214 | return new Promise(fn)
215 | }
216 | let statics = ['reject', 'resolve', 'race', 'all']
217 | statics.forEach(s => LocalPromise[s] = Promise[s].bind(Promise))
218 |
219 | app.destroy()
220 | app = new TestApp({ Promise: LocalPromise })
221 | await app.start()
222 | assert.equals(called, 1)
223 |
224 | await app.router.transitionTo('faq')
225 | assert.equals(called, 2)
226 | })
227 |
--------------------------------------------------------------------------------
/lib/router.js:
--------------------------------------------------------------------------------
1 | import { pick, clone, extend, isEqual, isString, find } from './dash'
2 | import dsl from './dsl'
3 | import Path from './path'
4 | import invariant from './invariant'
5 | import BrowserLocation from './locations/browser'
6 | import MemoryLocation from './locations/memory'
7 | import transition from './transition'
8 | import { intercept } from './links'
9 | import createLogger from './logger'
10 | import qs from './qs'
11 |
12 | function Cherrytree () {
13 | this.initialize.apply(this, arguments)
14 | }
15 |
16 | /**
17 | * The actual constructor
18 | * @param {Object} options
19 | */
20 | Cherrytree.prototype.initialize = function (options) {
21 | this.nextId = 1
22 | this.state = {}
23 | this.middleware = []
24 | this.options = extend({
25 | location: 'browser',
26 | interceptLinks: true,
27 | logError: true,
28 | Promise: Promise,
29 | qs: qs
30 | }, options)
31 | this.log = createLogger(this.options.log)
32 | this.logError = createLogger(this.options.logError, { error: true })
33 |
34 | invariant(typeof this.options.Promise === 'function',
35 | 'Cherrytree requires an ES6 Promise implementation, ' +
36 | 'either as an explicit option or a global Promise')
37 | }
38 |
39 | /**
40 | * Add a middleware
41 | * @param {Function} middleware
42 | * @return {Object} router
43 | * @api public
44 | */
45 | Cherrytree.prototype.use = function (middleware) {
46 | this.middleware.push(middleware)
47 | return this
48 | }
49 |
50 | /**
51 | * Add the route map
52 | * @param {Function} routes
53 | * @return {Object} router
54 | * @api public
55 | */
56 | Cherrytree.prototype.map = function (routes) {
57 | // create the route tree
58 | this.routes = dsl(routes)
59 |
60 | // create the matcher list, which is like a flattened
61 | // list of routes = a list of all branches of the route tree
62 | let matchers = this.matchers = []
63 | // keep track of whether duplicate paths have been created,
64 | // in which case we'll warn the dev
65 | let dupes = {}
66 | // keep track of abstract routes to build index route forwarding
67 | let abstracts = {}
68 |
69 | eachBranch({routes: this.routes}, [], function (routes) {
70 | // concatenate the paths of the list of routes
71 | let path = routes.reduce(function (memo, r) {
72 | // reset if there's a leading slash, otherwise concat
73 | // and keep resetting the trailing slash
74 | return (r.path[0] === '/' ? r.path : memo + '/' + r.path).replace(/\/$/, '')
75 | }, '')
76 | // ensure we have a leading slash
77 | if (path === '') {
78 | path = '/'
79 | }
80 |
81 | let lastRoute = routes[routes.length - 1]
82 |
83 | if (lastRoute.options.abstract) {
84 | abstracts[path] = lastRoute.name
85 | return
86 | }
87 |
88 | // register routes
89 | matchers.push({
90 | routes: routes,
91 | name: lastRoute.name,
92 | path: path
93 | })
94 |
95 | // dupe detection
96 | if (dupes[path]) {
97 | throw new Error('Routes ' + dupes[path] + ' and ' + lastRoute.name +
98 | ' have the same url path \'' + path + '\'')
99 | }
100 | dupes[path] = lastRoute.name
101 | })
102 |
103 | // check if there is an index route for each abstract route
104 | Object.keys(abstracts).forEach(function (path) {
105 | let matcher
106 | if (!dupes[path]) return
107 |
108 | matchers.some(function (m) {
109 | if (m.path === path) {
110 | matcher = m
111 | return true
112 | }
113 | })
114 |
115 | matchers.push({
116 | routes: matcher.routes,
117 | name: abstracts[path],
118 | path: path
119 | })
120 | })
121 |
122 | function eachBranch (node, memo, fn) {
123 | node.routes.forEach(function (route) {
124 | fn(memo.concat(route))
125 |
126 | if (route.routes.length) {
127 | eachBranch(route, memo.concat(route), fn)
128 | }
129 | })
130 | }
131 |
132 | return this
133 | }
134 |
135 | /**
136 | * Starts listening to the location changes.
137 | * @param {Object} location (optional)
138 | * @return {Promise} initial transition
139 | *
140 | * @api public
141 | */
142 | Cherrytree.prototype.listen = function (path) {
143 | let location = this.location = this.createLocation(path || '')
144 | // setup the location onChange handler
145 | location.onChange((url) => this.dispatch(url))
146 | // start intercepting links
147 | if (this.options.interceptLinks && location.usesPushState()) {
148 | this.interceptLinks()
149 | }
150 | // and also kick off the initial transition
151 | return this.dispatch(location.getURL())
152 | }
153 |
154 | /**
155 | * Transition to a different route. Passe in url or a route name followed by params and query
156 | * @param {String} url url or route name
157 | * @param {Object} params Optional
158 | * @param {Object} query Optional
159 | * @return {Object} transition
160 | *
161 | * @api public
162 | */
163 | Cherrytree.prototype.transitionTo = function (...args) {
164 | if (this.state.activeTransition) {
165 | return this.replaceWith.apply(this, args)
166 | }
167 | return this.doTransition('setURL', args)
168 | }
169 |
170 | /**
171 | * Like transitionTo, but doesn't leave an entry in the browser's history,
172 | * so clicking back will skip this route
173 | * @param {String} url url or route name followed by params and query
174 | * @param {Object} params Optional
175 | * @param {Object} query Optional
176 | * @return {Object} transition
177 | *
178 | * @api public
179 | */
180 | Cherrytree.prototype.replaceWith = function (...args) {
181 | return this.doTransition('replaceURL', args)
182 | }
183 |
184 | /**
185 | * Create an href
186 | * @param {String} name target route name
187 | * @param {Object} params
188 | * @param {Object} query
189 | * @return {String} href
190 | *
191 | * @api public
192 | */
193 | Cherrytree.prototype.generate = function (name, params, query) {
194 | invariant(this.location, 'call .listen() before using .generate()')
195 | let matcher
196 |
197 | params = params || {}
198 | query = query || {}
199 |
200 | this.matchers.forEach(function (m) {
201 | if (m.name === name) {
202 | matcher = m
203 | }
204 | })
205 |
206 | if (!matcher) {
207 | throw new Error('No route is named ' + name)
208 | }
209 |
210 | // this might be a dangerous feature, although it's useful in practise
211 | // if some params are not passed into the generate call, they're populated
212 | // based on the current state or on the currently active transition.
213 | // Consider removing this.. since the users can opt into this behaviour, by
214 | // reaching out to the router.state if that's what they want.
215 | let currentParams = clone(this.state.params || {})
216 | if (this.state.activeTransition) {
217 | currentParams = clone(this.state.activeTransition.params || {})
218 | }
219 | params = extend(currentParams, params)
220 |
221 | let url = Path.withQuery(this.options.qs, Path.injectParams(matcher.path, params), query)
222 | return this.location.formatURL(url)
223 | }
224 |
225 | /**
226 | * Stop listening to URL changes
227 | * @api public
228 | */
229 | Cherrytree.prototype.destroy = function () {
230 | if (this.location && this.location.destroy) {
231 | this.location.destroy()
232 | }
233 | if (this.disposeIntercept) {
234 | this.disposeIntercept()
235 | }
236 | if (this.state.activeTransition) {
237 | this.state.activeTransition.cancel()
238 | }
239 | this.state = {}
240 | }
241 |
242 | /**
243 | * Check if the given route/params/query combo is active
244 | * @param {String} name target route name
245 | * @param {Object} params
246 | * @param {Object} query
247 | * @return {Boolean}
248 | *
249 | * @api public
250 | */
251 | Cherrytree.prototype.isActive = function (name, params, query) {
252 | params = params || {}
253 | query = query || {}
254 |
255 | let activeRoutes = this.state.routes || []
256 | let activeParams = this.state.params || {}
257 | let activeQuery = this.state.query || {}
258 |
259 | let isActive = !!find(activeRoutes, route => route.name === name)
260 | isActive = isActive && !!Object.keys(params).every(key => activeParams[key] === params[key])
261 | isActive = isActive && !!Object.keys(query).every(key => activeQuery[key] === query[key])
262 |
263 | return isActive
264 | }
265 |
266 | /**
267 | * @api private
268 | */
269 | Cherrytree.prototype.doTransition = function (method, params) {
270 | let previousUrl = this.location.getURL()
271 |
272 | let url = params[0]
273 | if (url[0] !== '/') {
274 | url = this.generate.apply(this, params)
275 | url = url.replace(/^#/, '/')
276 | }
277 |
278 | if (this.options.pushState) {
279 | url = this.location.removeRoot(url)
280 | }
281 |
282 | let transition = this.dispatch(url)
283 |
284 | transition.catch((err) => {
285 | if (err && err.type === 'TransitionCancelled') {
286 | // reset the URL in case the transition has been cancelled
287 | this.location.replaceURL(previousUrl, {trigger: false})
288 | }
289 | return err
290 | })
291 |
292 | this.location[method](url, {trigger: false})
293 |
294 | return transition
295 | }
296 |
297 | /**
298 | * Match the path against the routes
299 | * @param {String} path
300 | * @return {Object} the list of matching routes and params
301 | *
302 | * @api private
303 | */
304 | Cherrytree.prototype.match = function (path) {
305 | path = (path || '').replace(/\/$/, '') || '/'
306 | let params
307 | let routes = []
308 | let pathWithoutQuery = Path.withoutQuery(path)
309 | let qs = this.options.qs
310 | this.matchers.some(function (matcher) {
311 | params = Path.extractParams(matcher.path, pathWithoutQuery)
312 | if (params) {
313 | routes = matcher.routes
314 | return true
315 | }
316 | })
317 | return {
318 | routes: routes.map(descriptor),
319 | params: params || {},
320 | pathname: pathWithoutQuery,
321 | query: Path.extractQuery(qs, path) || {}
322 | }
323 |
324 | // clone the data (only a shallow clone of options)
325 | // to make sure the internal route store is not mutated
326 | // by the middleware. The middleware can mutate data
327 | // before it gets passed into the next middleware, but
328 | // only within the same transition. New transitions
329 | // will get to use pristine data.
330 | function descriptor (route) {
331 | return {
332 | name: route.name,
333 | path: route.path,
334 | params: pick(params, Path.extractParamNames(route.path)),
335 | options: clone(route.options)
336 | }
337 | }
338 | }
339 |
340 | Cherrytree.prototype.dispatch = function (path) {
341 | let match = this.match(path)
342 | let query = match.query
343 | let pathname = match.pathname
344 |
345 | let activeTransition = this.state.activeTransition
346 |
347 | // if we already have an active transition with all the same
348 | // params - return that and don't do anything else
349 | if (activeTransition &&
350 | activeTransition.pathname === pathname &&
351 | isEqual(activeTransition.query, query)) {
352 | return activeTransition
353 | }
354 |
355 | // otherwise, cancel the active transition since we're
356 | // redirecting (or initiating a brand new transition)
357 | if (activeTransition) {
358 | let err = new Error('TransitionRedirected')
359 | err.type = 'TransitionRedirected'
360 | err.nextPath = path
361 | activeTransition.cancel(err)
362 | }
363 |
364 | // if there is no active transition, check if
365 | // this is a noop transition, in which case, return
366 | // a transition to respect the function signature,
367 | // but don't actually run any of the middleware
368 | if (!activeTransition) {
369 | if (this.state.pathname === pathname &&
370 | isEqual(this.state.query, query)) {
371 | return transition({
372 | id: this.nextId++,
373 | path: path,
374 | match: match,
375 | noop: true,
376 | router: this
377 | }, this.options.Promise)
378 | }
379 | }
380 |
381 | let t = transition({
382 | id: this.nextId++,
383 | path: path,
384 | match: match,
385 | router: this
386 | }, this.options.Promise)
387 |
388 | this.state.activeTransition = t
389 |
390 | return t
391 | }
392 |
393 | /**
394 | * Create the default location.
395 | * This is used when no custom location is passed to
396 | * the listen call.
397 | * @return {Object} location
398 | *
399 | * @api private
400 | */
401 | Cherrytree.prototype.createLocation = function (path) {
402 | let location = this.options.location
403 | if (!isString(location)) {
404 | return location
405 | }
406 | if (location === 'browser') {
407 | return new BrowserLocation(pick(this.options, ['pushState', 'root']))
408 | } else if (location === 'memory') {
409 | return new MemoryLocation({path})
410 | } else {
411 | throw new Error('Location can be `browser`, `memory` or a custom implementation')
412 | }
413 | }
414 |
415 | /**
416 | * When using pushState, it's important to setup link interception
417 | * because all link clicks should be handled via the router instead of
418 | * browser reloading the page
419 | */
420 | Cherrytree.prototype.interceptLinks = function () {
421 | let clickHandler = typeof this.options.interceptLinks === 'function'
422 | ? this.options.interceptLinks
423 | : defaultClickHandler
424 | this.disposeIntercept = intercept((event, link) => clickHandler(event, link, this))
425 |
426 | function defaultClickHandler (event, link, router) {
427 | event.preventDefault()
428 | router.transitionTo(router.location.removeRoot(link.getAttribute('href')))
429 | }
430 | }
431 |
432 | export default function cherrytree (options) {
433 | return new Cherrytree(options)
434 | }
435 |
436 | cherrytree.BrowserLocation = BrowserLocation
437 | cherrytree.MemoryLocation = MemoryLocation
438 |
--------------------------------------------------------------------------------
/docs/api.md:
--------------------------------------------------------------------------------
1 | # Docs
2 |
3 | ### var router = cherrytree(options)
4 |
5 | * **options.log** - a function that is called with logging info, default is noop. Pass in `true`/`false` or a custom logging function.
6 | * **options.logError** - default is true. A function that is called when transitions error (except for the special `TransitionRedirected` and `TransitionCancelled` errors). Pass in `true`/`false` or a custom error handling function.
7 | * **options.pushState** - default is false, which means using hashchange events. Set to `true` to use pushState.
8 | * **options.root** - default is `/`. Use in combination with `pushState: true` if your application is not being served from the root url /.
9 | * **options.interceptLinks** - default is true. When pushState is used - intercepts all link clicks when appropriate, prevents the default behaviour and instead uses pushState to update the URL and handle the transition via the router. You can also set this option to a custom function that will get called whenever a link is clicked if you want to customize the behaviour. Read more on [intercepting links below](#intercepting-links).
10 | * **options.qs** - default is a simple built in query string parser. Pass in an object with `parse` and `stringify` functions to customize how query strings get treated.
11 | * **options.Promise** - default is window.Promise or global.Promise. Promise implementation to be used when constructing transitions.
12 |
13 | ### router.map(fn)
14 |
15 | Configure the router with a route map. E.g.
16 |
17 | ```js
18 | router.map(function (route) {
19 | route('app', {path: '/'}, function () {
20 | route('about')
21 | route('post', {path: ':postId'}, function () {
22 | route('show')
23 | route('edit')
24 | })
25 | })
26 | })
27 | ```
28 |
29 | #### Nested paths
30 |
31 | Nested paths are concatenated unless they start with a '/'. For example
32 |
33 | ```js
34 | router.map(function (route) {
35 | route('foo', {path: '/foo'}, function () {
36 | route('bar', {path: '/bar'}, function () {
37 | route('baz', {path: '/baz'})
38 | });
39 | })
40 | })
41 | ```
42 |
43 | The above map results in 1 URL `/baz` mapping to ['foo', 'bar', 'baz'] routes.
44 |
45 | ```js
46 | router.map(function (route) {
47 | route('foo', {path: '/foo'}, function () {
48 | route('bar', {path: 'bar'}, function () {
49 | route('baz', {path: 'baz'})
50 | });
51 | })
52 | })
53 | ```
54 |
55 | The above map results in 1 URL `/foo/bar/baz` mapping to ['foo', 'bar', 'baz'] routes.
56 |
57 | #### Dynamic paths
58 |
59 | Paths can contain dynamic segments as described in the docs of [path-to-regexp](https://github.com/pillarjs/path-to-regexp). For example:
60 |
61 | ```js
62 | route('foo', {path: '/hello/:myParam'}) // single named param, matches /hello/1
63 | route('foo', {path: '/hello/:myParam/:myOtherParam'}) // two named params, matches /hello/1/2
64 | route('foo', {path: '/hello/:myParam?'}) // single optional named param, matches /hello and /hello/1
65 | route('foo', {path: '/hello/:splat*'}) // match 0 or more segments, matches /hello and /hello/1 and /hello/1/2/3
66 | route('foo', {path: '/hello/:splat+'}) // match 1 or more segments, matches /hello/1 and /hello/1/2/3
67 | ```
68 |
69 | #### Abstract routes
70 |
71 | By default, both leaf and non leaf routes can be navigated to. Sometimes you might not want it to be possible to navigate to certain routes at all, e.g. if the route is only used for data fetching and doesn't render anything by itself. In that case, you can set `abstract: true` in the route options. Abstract routes can still form a part of the URL.
72 |
73 | ```js
74 | router.map(function (route) {
75 | route('application', {path: '/'}, function () {
76 | route('dashboard', {path: 'dashboard/:accountId', abstract: true}, function () {
77 | route('defaultDashboard', {path: ''})
78 | route('realtimeDashboard', {path: 'realtime'})
79 | });
80 | })
81 | })
82 | ```
83 |
84 | Abstract routes are especially useful when creating `index` subroutes as demonstrated above. The above route map results in the following URLs:
85 |
86 | ```
87 | / - ['application']
88 | /dashboard/:accountId - ['application', 'dashboard', 'defaultDashboard']
89 | /dashboard/:accountId/realtime - ['application', 'dashboard', 'realtimeDashboard']
90 | ```
91 |
92 | Navigating to an abstract route that has an index route is equivalent to navigating to the index route. E.g. these are equivalent:
93 |
94 | ```js
95 | router.transitionTo('dashboard')
96 | router.transitionTo('defaultDashboard')
97 | ```
98 |
99 | Generating links is also equivalent
100 | ```js
101 | router.generate('dashboard') === router.generate('defaultDashboard')
102 | ```
103 |
104 | However, if the abstract route does not have an index route, then it's not routable and can't have URLs generated.
105 |
106 | It's also common to redirect from non leaf routes. In this example we might want to redirect from `application` to the `defaultDashboard` route. If each of your routes are backed by some route handler object, you can achieve the redirect with the following middleware:
107 |
108 | ```js
109 | router.use(function redirect (transition) {
110 | var lastRoute = transition.routes[transition.routes.length - 1]
111 | if (lastRoute.handler.redirect) {
112 | lastRoute.handler.redirect(transition.params, transition.query)
113 | }
114 | })
115 | ```
116 |
117 | #### Default path
118 |
119 | If a route path is not specified, it defaults to the name of the route, e.g.:
120 |
121 | ```js
122 | route('foo')
123 |
124 | // equivalent to
125 |
126 | route('foo', {path: 'foo'})
127 | ```
128 |
129 | If a route has a name with dots and no path specified, the path defaults to the last segment of the path. This special "dot" behaviour might be removed in the next major version of Cherrytree.
130 |
131 | ```js
132 | route('foo.bar')
133 |
134 | // equivalent to
135 |
136 | route('foo.bar', {path: 'bar'})
137 | ```
138 |
139 | ### router.use(fn)
140 |
141 | Add a transition middleware. Every time a transition takes place this middleware will be called with a transition as the argument. You can call `use` multiple times to add more middlewares. The middleware function can return a promise and the next middleware will not be called until the promise of the previous middleware is resolved. The result of the promise is passed in as a second argument to the next middleware. E.g.
142 |
143 | ```js
144 | router.use(function (transition) {
145 | return Promise.all(transition.routes.map(function (route) {
146 | return route.options.handler.fetchData()
147 | }))
148 | })
149 |
150 | router.use(function (transition, datas) {
151 | transition.routes.forEach(function (route, i) {
152 | route.options.handler.activate(datas[i])
153 | })
154 | })
155 | ```
156 |
157 | #### transition
158 |
159 | The transition object is itself a promise. It also contains the following attributes
160 |
161 | * `id`
162 | * `routes`
163 | * `path`
164 | * `pathname`
165 | * `params`
166 | * `query`
167 | * `prev`
168 | * `routes`
169 | * `path`
170 | * `pathname`
171 | * `params`
172 | * `query`
173 |
174 | And the following methods
175 |
176 | * `then`
177 | * `catch`
178 | * `cancel`
179 | * `retry`
180 | * `followRedirects`
181 | * `redirectTo`
182 |
183 | #### route
184 |
185 | During every transition, you can inspect `transition.routes` and `transition.prev.routes` to see where the router is transitioning to. These are arrays that contain a list of route descriptors. Each route descriptor has the following attributes
186 |
187 | * `name` - e.g. `'message'`
188 | * `path` - the path segment, e.g. `'message/:id'`
189 | * `params` - a list of params specifically for this route, e.g `{id: 1}`
190 | * `options` - the options object that was passed to the `route` function in the `map`
191 |
192 | ### router.listen()
193 |
194 | After the router has been configured with a route map and middleware - start listening to URL changes and transition to the appropriate route based on the current URL.
195 |
196 | When using `location: 'memory'`, the current URL is not read from the browser's location bar and instead can be passed in via listen: `listen(path)`.
197 |
198 | ### router.transitionTo(name, params, query)
199 |
200 | Transition to a route, e.g.
201 |
202 | ```js
203 | router.transitionTo('about')
204 | router.transitionTo('posts.show', {postId: 1})
205 | router.transitionTo('posts.show', {postId: 2}, {commentId: 2})
206 | ```
207 |
208 | ### router.replaceWith(name, params, query)
209 |
210 | Same as transitionTo, but doesn't add an entry in browser's history, instead replaces the current entry. Useful if you don't want this transition to be accessible via browser's Back button, e.g. if you're redirecting, or if you're navigating upon clicking tabs in the UI, etc.
211 |
212 | ### router.generate(name, params, query)
213 |
214 | Generate a URL for a route, e.g.
215 |
216 | ```js
217 | router.generate('about')
218 | router.generate('posts.show', {postId: 1})
219 | router.generate('posts.show', {postId: 2}, {commentId: 2})
220 | ```
221 |
222 | It generates a URL with # if router is in hashChange mode and with no # if router is in pushState mode.
223 |
224 | ### router.isActive(name, params, query)
225 |
226 | Check if a given route, params and query is active.
227 |
228 | ```js
229 | router.isActive('status')
230 | router.isActive('status', {user: 'me'})
231 | router.isActive('status', {user: 'me'}, {commentId: 2})
232 | router.isActive('status', null, {commentId: 2})
233 | ```
234 |
235 | ### router.state
236 |
237 | The state of the route is always available on the `router.state` object. It contains `activeTransition`, `routes`, `path`, `pathname`, `params` and `query`.
238 |
239 | ### router.matchers
240 |
241 | Use this to inspect all the routes and their URL patterns that exist in your application. It's an array of:
242 |
243 | ```js
244 | {
245 | name,
246 | path,
247 | routes
248 | }
249 | ```
250 |
251 | listed in the order that they will be matched against the URL.
252 |
253 | ## Query params
254 |
255 | Cherrytree will extract and parse the query params using a very simple query string parser that only supports key values. For example, `?a=1&b=2` will be parsed to `{a: 1, b:2}`. If you want to use a more sophisticated query parser, pass in an object with `parse` and `stringify` functions - an interface compatible with the popular [qs](https://github.com/hapijs/qs) module e.g.:
256 |
257 | ```js
258 | cherrytree({
259 | qs: require('qs')
260 | })
261 | ```
262 |
263 |
264 | ## Errors
265 |
266 | Transitions can fail, in which case the transition promise is rejected with the error object. This could happen, for example, if some middleware throws or returns a rejected promise.
267 |
268 | There are also two special errors that can be thrown when a redirect happens or when transition is cancelled completely.
269 |
270 | In case of redirect (someone initiating a router.transitionTo() while another transition was active) and error object will have a `type` attribute set to 'TransitionRedirected' and `nextPath` attribute set to the path of the new transition.
271 |
272 | In case of cancelling (someone calling transition.cancel()) the error object will have a `type` attribute set to 'TransitionCancelled'.
273 |
274 | If you have some error handling middleware - you most likely want to check for these two special errors, because they're normal to the functioning of the router, it's common to perform redirects.
275 |
276 | ## BrowserLocation
277 |
278 | Cherrytree can be configured to use differet implementations of libraries that manage browser's URL/history. By default, Cherrytree will use a very versatile implementation - `cherrytree/lib/locations/browser` which supports `pushState` and `hashChange` based URL management with graceful fallback of `pushState` -> `hashChange` -> `polling` depending on browser's capabilities.
279 |
280 | Configure BrowserLocation by passing options directly to the router.
281 |
282 | ```js
283 | var router = cherrytree({
284 | pushState: true
285 | })
286 | ```
287 |
288 | * options.pushState - default is false, which means using hashchange events. Set to true to use pushState.
289 | * options.root - default is `/`. Use in combination with `pushState: true` if your application is not being served from the root url /.
290 |
291 | ## MemoryLocation
292 |
293 | MemoryLocation can be used if you don't want router to touch the address bar at all. Navigating around the application will only be possible programatically by calling `router.transitionTo` and similar methods.
294 |
295 | e.g.
296 |
297 | ```js
298 | var router = cherrytree({
299 | location: 'memory'
300 | })
301 | ```
302 |
303 | ## CustomLocation
304 |
305 | You can also pass a custom location in explicitly. This is an advanced use case, but might turn out to be useful in non browser environments. For this you'll need to investigate how BrowserLocation is implemented.
306 |
307 | ```js
308 | var router = cherrytree({
309 | location: myCustomLocation()
310 | })
311 | ```
312 |
313 |
314 | ## Intercepting Links
315 |
316 | Cherrytree intercepts all link clicks when using pushState, because without this functionality - the browser would just do a full page refresh on every click of a link.
317 |
318 | The clicks **are** intercepted only if:
319 |
320 | * router is passed a `interceptLinks: true` (default)
321 | * the currently used location and browser supports pushState
322 | * clicked with the left mouse button with no cmd or shift key
323 |
324 | The clicks that **are never** intercepted:
325 |
326 | * external links
327 | * `javascript:` links
328 | * `mailto:` links
329 | * links with a `data-bypass` attribute
330 | * links starting with `#`
331 |
332 | The default implementation of the intercept click handler is:
333 |
334 | ```js
335 | function defaultClickHandler (event, link, router) {
336 | event.preventDefault()
337 | router.transitionTo(router.location.removeRoot(link.getAttribute('href')))
338 | }
339 | ```
340 |
341 | You can pass in a custom function as the `interceptLinks` router option to customize this behaviour. E.g. to use `replaceWith` instead of `transitionTo`.
342 |
343 |
344 | ## Handling 404
345 |
346 | There are a couple of ways to handle URLs that don't match any routes.
347 |
348 | You can create a middleware to detects when `transition.routes.length` is 0 and render a 404 page.
349 |
350 | Alternatively, you can also declare a catch all path in your route map:
351 |
352 | ```js
353 | router.map(function (route) {
354 | route('application', {path: '/'}, function () {
355 | route('blog')
356 | route('missing', {path: ':path*'})
357 | })
358 | })
359 | ```
360 |
361 | In this case, when nothing else matches, a transition to the `missing` route will be initiated with `transition.routes` as ['application', 'missing']. This gives you a chance to activate and render the `application` route before rendering a 404 page.
362 |
--------------------------------------------------------------------------------
/tests/unit/routerTest.js:
--------------------------------------------------------------------------------
1 | import { assert } from 'referee'
2 | import BrowserLocation from '../../lib/locations/browser'
3 | import MemoryLocation from '../../lib/locations/memory'
4 | import { extend } from '../../lib/dash'
5 | import cherrytree from '../..'
6 |
7 | let mouse = window.effroi.mouse
8 | let {suite, test, beforeEach, afterEach} = window
9 |
10 | let delay = (t) => new Promise((resolve) => setTimeout(resolve, t))
11 |
12 | suite('Cherrytree')
13 |
14 | let router
15 |
16 | let routes = (route) => {
17 | route('application', () => {
18 | route('notifications')
19 | route('messages')
20 | route('status', {path: ':user/status/:id'})
21 | })
22 | }
23 |
24 | beforeEach(() => {
25 | window.location.hash = ''
26 | router = cherrytree()
27 | })
28 |
29 | afterEach(() => {
30 | router.destroy()
31 | })
32 |
33 | // @api public
34 |
35 | test('#use registers middleware', () => {
36 | let m = () => {}
37 | router.use(m)
38 | assert(router.middleware.length === 1)
39 | assert(router.middleware[0] === m)
40 | })
41 |
42 | test('#use middleware gets passed a transition object', (done) => {
43 | let m = (transition) => {
44 | let t = extend({}, transition)
45 | ;['catch', 'then', 'redirectTo', 'cancel', 'retry', 'followRedirects'].forEach(attr => delete t[attr])
46 | let et = {
47 | id: 3,
48 | prev: {
49 | routes: [{
50 | name: 'application',
51 | path: 'application',
52 | params: {},
53 | options: {
54 | path: 'application'
55 | }
56 | }],
57 | path: '/application',
58 | pathname: '/application',
59 | params: {},
60 | query: {}
61 | },
62 | routes: [{
63 | name: 'application',
64 | path: 'application',
65 | params: {},
66 | options: {
67 | path: 'application'
68 | }
69 | }, {
70 | name: 'status',
71 | path: ':user/status/:id',
72 | params: {
73 | user: '1',
74 | id: '2'
75 | },
76 | options: {
77 | path: ':user/status/:id'
78 | }
79 | }],
80 | path: '/application/1/status/2?withReplies=true',
81 | pathname: '/application/1/status/2',
82 | params: {
83 | user: '1',
84 | id: '2'
85 | },
86 | query: {
87 | withReplies: 'true'
88 | }
89 | }
90 | assert.equals(t, et)
91 |
92 | done()
93 | }
94 |
95 | // first navigate to 'application'
96 | router.map(routes)
97 | router.listen()
98 | .then(() => router.transitionTo('application'))
99 | .then(() => {
100 | // then install the middleware and navigate to status page
101 | // this is so that we have a richer transition object
102 | // to assert
103 | router.use(m)
104 | return router.transitionTo('status', {user: 1, id: 2}, {withReplies: true})
105 | }).catch(done)
106 | })
107 |
108 | test('#map registers the routes', () => {
109 | router.map(routes)
110 | // check that the internal matchers object is created
111 | assert.equals(router.matchers.map(m => m.path), [
112 | '/application',
113 | '/application/notifications',
114 | '/application/messages',
115 | '/application/:user/status/:id'
116 | ])
117 | // check that the internal routes object is created
118 | assert.equals(router.routes[0].name, 'application')
119 | assert.equals(router.routes[0].routes[2].options.path, ':user/status/:id')
120 | })
121 |
122 | test('#generate generates urls given route name and params as object', () => {
123 | router.map(routes).listen()
124 | var url = router.generate('status', {user: 'foo', id: 1}, {withReplies: true})
125 | assert.equals(url, '#application/foo/status/1?withReplies=true')
126 | })
127 |
128 | if (window.history && window.history.pushState) {
129 | test('#generate when pushState: true and root != "/" in modern browsers', () => {
130 | router.options.pushState = true
131 | router.options.root = '/foo/bar'
132 | router.map(routes).listen()
133 | var url = router.generate('status', {user: 'usr', id: 1}, {withReplies: true})
134 | assert.equals(url, '/foo/bar/application/usr/status/1?withReplies=true')
135 | })
136 | }
137 |
138 | if (window.history && !window.history.pushState) {
139 | test('#generate when pushState: true and root != "/" in old browsers', () => {
140 | let browserRedirectedTo
141 | router.options.location = new BrowserLocation({
142 | pushState: true,
143 | root: '/foo/bar',
144 | location: {
145 | href: '/different/#location',
146 | pathname: '/different',
147 | hash: '#location',
148 | search: '',
149 | replace: function (path) {
150 | browserRedirectedTo = path
151 | }
152 | }
153 | })
154 |
155 | router.map(routes).listen()
156 | var url = router.generate('status', {user: 'usr', id: 1}, {withReplies: true})
157 | assert.equals(browserRedirectedTo, '/foo/bar/#different')
158 | assert.equals(url, '#application/usr/status/1?withReplies=true')
159 | })
160 | }
161 |
162 | test('#generate throws a useful error when listen has not been called', () => {
163 | router.map(routes)
164 | try {
165 | router.generate('messages')
166 | } catch (err) {
167 | assert.equals(err.message, 'Invariant Violation: call .listen() before using .generate()')
168 | }
169 | })
170 |
171 | test('#generate throws a useful error when called with an abstract route', () => {
172 | router.map((route) => {
173 | route('foo', {abstract: true})
174 | }).listen()
175 |
176 | assert.exception(function () {
177 | router.generate('foo')
178 | }, {message: 'No route is named foo'})
179 | })
180 |
181 | test('#generate succeeds when called with an abstract route that has a child index route', () => {
182 | router.map((route) => {
183 | route('foo', {abstract: true}, () => {
184 | route('bar', {path: ''})
185 | })
186 | }).listen()
187 | let url = router.generate('foo')
188 | assert.equals(url, '#foo')
189 | })
190 |
191 | test('#use middleware can not modify routers internal state by changing transition.routes', (done) => {
192 | window.location.hash = '/application/messages'
193 | router.map(routes)
194 | router.use((transition) => {
195 | assert.equals(transition.routes[0].name, 'application')
196 | transition.routes[0].foo = 1
197 | transition.routes[0].options.bar = 2
198 | })
199 | router.use((transition) => {
200 | assert.equals(transition.routes[0].name, 'application')
201 | assert.equals(transition.routes[0].foo, 1)
202 | assert.equals(transition.routes[0].options.bar, 2)
203 |
204 | assert.equals(router.routes[0].name, 'application')
205 | assert.equals(router.routes[0].foo, undefined)
206 | assert.equals(router.routes[0].options.foo, undefined)
207 | done()
208 | })
209 | router.listen()
210 | })
211 |
212 | test('#use transition fails if a middleware returns a transition', (done) => {
213 | window.location.hash = '/application/messages'
214 | router.map(routes)
215 | router.logError = function () {}
216 | router.use((transition) => {
217 | transition.catch((err) => {
218 | assert.equals(err.message, 'Invariant Violation: Middleware anonymous returned a transition which resulted in a deadlock')
219 | }).then(done).catch(done)
220 | })
221 | router.use((transition) => transition)
222 | router.listen()
223 | })
224 |
225 | // @api private
226 |
227 | test('#match matches a path against the routes', () => {
228 | router.map(routes)
229 | let match = router.match('/application/KidkArolis/status/42')
230 | assert.equals(match.params, {
231 | user: 'KidkArolis',
232 | id: '42'
233 | })
234 | assert.equals(match.routes.map(r => r.name), ['application', 'status'])
235 | })
236 |
237 | test('#match matches a path with query params', () => {
238 | router.map(routes)
239 | let match = router.match('/application/KidkArolis/status/42?withReplies=true&foo=bar')
240 | assert.equals(match.params, {
241 | user: 'KidkArolis',
242 | id: '42'
243 | })
244 | assert.equals(match.query, {
245 | withReplies: 'true',
246 | foo: 'bar'
247 | })
248 | })
249 |
250 | test('#match returns an array of route descriptors', () => {
251 | router.map((route) => {
252 | route('foo', {customData: 1}, () => {
253 | route('bar', {customData: 2})
254 | })
255 | })
256 | let match = router.match('/foo/bar')
257 | assert.equals(match.routes, [{
258 | name: 'foo',
259 | path: 'foo',
260 | params: {},
261 | options: {
262 | customData: 1,
263 | path: 'foo'
264 | }
265 | }, {
266 | name: 'bar',
267 | path: 'bar',
268 | params: {},
269 | options: {
270 | customData: 2,
271 | path: 'bar'
272 | }
273 | }])
274 | })
275 |
276 | test('#match ignores the trailing slash', () => {
277 | router.map(routes)
278 | assert(router.match('/application/messages').routes.length)
279 | assert(router.match('/application/messages/').routes.length)
280 | })
281 |
282 | test('#match returns an empty route array if nothing matches', () => {
283 | router.map(routes)
284 | let match = router.match('/foo/bar')
285 | assert.equals(match, {routes: [], params: {}, pathname: '/foo/bar', query: {}})
286 | })
287 |
288 | test('#match always parses query parameters even if a route does not match', () => {
289 | router.map(routes)
290 | let match = router.match('/foo/bar?hello=world')
291 | assert.equals(match, {routes: [], params: {}, pathname: '/foo/bar', query: { hello: 'world' }})
292 | })
293 |
294 | test('#transitionTo called multiple times reuses the active transition', (done) => {
295 | router.map(routes)
296 | router.listen().then(() => {
297 | router.use(() => delay(500))
298 | assert.equals(router.transitionTo('status', {user: 'me', id: 1}).id, 2)
299 | assert.equals(router.transitionTo('status', {user: 'me', id: 1}).id, 2)
300 | done()
301 | }).catch(done)
302 | })
303 |
304 | test('#transitionTo called on the same route, returns a completed transition', (done) => {
305 | let called = false
306 | router.map(routes)
307 | router.listen().then(() => {
308 | return router.transitionTo('status', {user: 'me', id: 1})
309 | }).then(() => {
310 | router.use(() => called = true)
311 | let t = router.transitionTo('status', {user: 'me', id: 1})
312 | assert.equals(t.noop, true)
313 | return t
314 | }).then(() => {
315 | assert.equals(called, false)
316 | done()
317 | }).catch(done)
318 | })
319 |
320 | test('#transitionTo throws an useful error when called with an abstract route', () => {
321 | router.map((route) => {
322 | route('foo', {abstract: true})
323 | }).listen()
324 |
325 | assert.exception(function () {
326 | router.transitionTo('foo')
327 | }, {message: 'No route is named foo'})
328 | })
329 |
330 | test('#transitionTo called on an abstract route with a child index route should activate the index route', async () => {
331 | router.map((route) => {
332 | route('foo', {abstract: true}, () => {
333 | route('bar', {path: ''})
334 | })
335 | }).listen()
336 | await router.transitionTo('foo')
337 | assert.equals(router.isActive('foo'), true)
338 | assert.equals(router.isActive('bar'), true)
339 | assert.equals(router.state.routes.length, 2)
340 | assert.equals(router.state.path, '/foo')
341 | })
342 |
343 | test('#isActive returns true if arguments match current state and false if not', async () => {
344 | router.map(routes)
345 | await router.listen()
346 | await router.transitionTo('notifications')
347 | assert.equals(router.isActive('notifications'), true)
348 | assert.equals(router.isActive('messages'), false)
349 | await router.transitionTo('status', {user: 'me', id: 1})
350 | assert.equals(router.isActive('status', {user: 'me'}), true)
351 | assert.equals(router.isActive('status', {user: 'notme'}), false)
352 | await router.transitionTo('messages', null, {foo: 'bar'})
353 | assert.equals(router.isActive('messages', null, {foo: 'bar'}), true)
354 | assert.equals(router.isActive('messages', null, {foo: 'baz'}), false)
355 | })
356 |
357 | suite('route maps')
358 |
359 | beforeEach(() => {
360 | router = cherrytree()
361 | })
362 |
363 | afterEach(() => {
364 | router.destroy()
365 | })
366 |
367 | test('a complex route map', () => {
368 | router.map((route) => {
369 | route('application', () => {
370 | route('notifications')
371 | route('messages', () => {
372 | route('unread', () => {
373 | route('priority')
374 | })
375 | route('read')
376 | route('draft', () => {
377 | route('recent')
378 | })
379 | })
380 | route('status', {path: ':user/status/:id'})
381 | })
382 | route('anotherTopLevel', () => {
383 | route('withChildren')
384 | })
385 | })
386 | // check that the internal matchers object is created
387 | assert.equals(router.matchers.map(m => m.path), [
388 | '/application',
389 | '/application/notifications',
390 | '/application/messages',
391 | '/application/messages/unread',
392 | '/application/messages/unread/priority',
393 | '/application/messages/read',
394 | '/application/messages/draft',
395 | '/application/messages/draft/recent',
396 | '/application/:user/status/:id',
397 | '/anotherTopLevel',
398 | '/anotherTopLevel/withChildren'
399 | ])
400 | })
401 |
402 | test('a parent route can be excluded from the route map by setting abstract to true', () => {
403 | router.map((route) => {
404 | route('application', { abstract: true }, () => {
405 | route('notifications')
406 | route('messages', () => {
407 | route('unread', () => {
408 | route('priority')
409 | })
410 | route('read')
411 | route('draft', { abstract: true }, () => {
412 | route('recent')
413 | })
414 | })
415 | route('status', {path: ':user/status/:id'})
416 | })
417 | route('anotherTopLevel', () => {
418 | route('withChildren')
419 | })
420 | })
421 |
422 | assert.equals(router.matchers.map(m => m.path), [
423 | '/application/notifications',
424 | '/application/messages',
425 | '/application/messages/unread',
426 | '/application/messages/unread/priority',
427 | '/application/messages/read',
428 | '/application/messages/draft/recent',
429 | '/application/:user/status/:id',
430 | '/anotherTopLevel',
431 | '/anotherTopLevel/withChildren'
432 | ])
433 | })
434 |
435 | test('routes with duplicate names throw a useful error', () => {
436 | try {
437 | router.map((route) => {
438 | route('foo', () => {
439 | route('foo')
440 | })
441 | })
442 | } catch (e) {
443 | assert.equals(e.message, 'Invariant Violation: Route names must be unique, but route "foo" is declared multiple times')
444 | return
445 | }
446 | assert(false, 'Should not reach this')
447 | })
448 |
449 | test('modifying params or query in middleware does not affect the router state', async function () {
450 | router.map(routes)
451 | await router.listen()
452 | router.use(transition => {
453 | transition.params.foo = 1
454 | transition.query.bar = 2
455 | transition.routes.push({})
456 | transition.routes[0].foobar = 123
457 | })
458 | await router.transitionTo('status', {user: 'me', id: 42}, {q: 'abc'})
459 | // none of the modifications to params, query or routes
460 | // array are persisted to the router state
461 | assert.equals(router.state.params, {user: 'me', id: '42'})
462 | assert.equals(router.state.query, {q: 'abc'})
463 | assert.equals(router.state.routes.length, 2)
464 | })
465 |
466 | if (window.history && window.history.pushState) {
467 | test('custom link intercept click handler', async function () {
468 | let interceptCalledWith = false
469 | router.options.pushState = true
470 | router.options.interceptLinks = function (event, link) {
471 | event.preventDefault()
472 | interceptCalledWith = link.getAttribute('href')
473 | }
474 | router.map(routes)
475 | await router.listen('foobar')
476 | let a = document.createElement('a')
477 | a.href = '/hello/world'
478 | a.innerHTML = 'hello'
479 | document.body.appendChild(a)
480 | mouse.click(a)
481 | assert.equals(interceptCalledWith, '/hello/world')
482 | document.body.removeChild(a)
483 | })
484 | }
485 |
486 | test('Browser and Memory locations are exported in the main router file', function () {
487 | assert.equals(cherrytree.BrowserLocation, BrowserLocation)
488 | assert.equals(cherrytree.MemoryLocation, MemoryLocation)
489 | })
490 |
--------------------------------------------------------------------------------