├── .babelrc
├── .editorconfig
├── .eslintignore
├── .eslintrc
├── .gitignore
├── .npmignore
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── LICENSE.md
├── README.md
├── circle.yml
├── package.json
├── scripts
└── release
├── src
├── Route.js
├── Router.js
├── actionTypes.js
├── actions.js
├── createRoutex.js
├── errors.js
├── index.js
├── react
│ ├── Link.js
│ ├── View.js
│ └── index.js
└── utils
│ ├── routeUtils.js
│ ├── routerUtils.js
│ ├── stringUtils.js
│ └── urlUtils.js
├── test
├── Route.spec.js
├── Router.spec.js
├── createRoutex.spec.js
├── react
│ ├── Link.spec.js
│ └── View.spec.js
├── routex.spec.js
└── utils
│ └── urlUtils.spec.js
└── webpack.config.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "loose": "all",
3 | "stage": 0
4 | }
5 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [**]
4 | end_of_line = lf
5 | insert_final_newline = true
6 | charset = utf-8
7 |
8 | [**.{js}]
9 | indent_style = space
10 | indent_size = 4
11 |
12 | [**.{html,json}]
13 | indent_style = space
14 | indent_size = 2
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | lib
3 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "eslint-config-airbnb",
3 | "env": {
4 | "browser": true,
5 | "mocha": true,
6 | "node": true
7 | },
8 | "ecmaFeatures": {
9 | "experimentalObjectRestSpread": true
10 | },
11 | "rules": {
12 | "comma-dangle": [2, "never"],
13 | "padded-blocks": 0,
14 | // indent 4 spaces
15 | "indent": [2, 4, {"SwitchCase": 1}]
16 | },
17 | "plugins": [
18 | "react"
19 | ]
20 | }
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by .ignore support plugin (hsz.mobi)
2 | ### JetBrains template
3 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion
4 |
5 | *.iml
6 |
7 | ## Directory-based project format:
8 | .idea/
9 | # if you remove the above rule, at least ignore the following:
10 |
11 | # User-specific stuff:
12 | # .idea/workspace.xml
13 | # .idea/tasks.xml
14 | # .idea/dictionaries
15 |
16 | # Sensitive or high-churn files:
17 | # .idea/dataSources.ids
18 | # .idea/dataSources.xml
19 | # .idea/sqlDataSources.xml
20 | # .idea/dynamic.xml
21 | # .idea/uiDesigner.xml
22 |
23 | # Gradle:
24 | # .idea/gradle.xml
25 | # .idea/libraries
26 |
27 | # Mongo Explorer plugin:
28 | # .idea/mongoSettings.xml
29 |
30 | ## File-based project format:
31 | *.ipr
32 | *.iws
33 |
34 | ## Plugin-specific files:
35 |
36 | # IntelliJ
37 | /out/
38 |
39 | # mpeltonen/sbt-idea plugin
40 | .idea_modules/
41 |
42 | # JIRA plugin
43 | atlassian-ide-plugin.xml
44 |
45 | # Crashlytics plugin (for Android Studio and IntelliJ)
46 | com_crashlytics_export_strings.xml
47 | crashlytics.properties
48 | crashlytics-build.properties
49 |
50 |
51 | ### Linux template
52 | *~
53 |
54 | # KDE directory preferences
55 | .directory
56 |
57 | # Linux trash folder which might appear on any partition or disk
58 | .Trash-*
59 |
60 |
61 | ### OSX template
62 | .DS_Store
63 | .AppleDouble
64 | .LSOverride
65 |
66 | # Icon must end with two \r
67 | Icon
68 |
69 | # Thumbnails
70 | ._*
71 |
72 | # Files that might appear in the root of a volume
73 | .DocumentRevisions-V100
74 | .fseventsd
75 | .Spotlight-V100
76 | .TemporaryItems
77 | .Trashes
78 | .VolumeIcon.icns
79 |
80 | # Directories potentially created on remote AFP share
81 | .AppleDB
82 | .AppleDesktop
83 | Network Trash Folder
84 | Temporary Items
85 | .apdisk
86 |
87 |
88 | ### Node template
89 | # Logs
90 | logs
91 | *.log
92 |
93 | # Runtime data
94 | pids
95 | *.pid
96 | *.seed
97 |
98 | # Directory for instrumented libs generated by jscoverage/JSCover
99 | lib-cov
100 |
101 | # Coverage directory used by tools like istanbul
102 | coverage
103 |
104 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
105 | .grunt
106 |
107 | # node-waf configuration
108 | .lock-wscript
109 |
110 | # Compiled binary addons (http://nodejs.org/api/addons.html)
111 | build/Release
112 |
113 | # Dependency directory
114 | # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git
115 | node_modules
116 |
117 | webpack-stats.json
118 |
119 | lib
120 | dist
121 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | # Created by .ignore support plugin (hsz.mobi)
2 |
3 | src
4 | test
5 | scripts
6 | *.log
7 | .DS_Store
8 |
9 |
10 | ### JetBrains template
11 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion
12 |
13 | *.iml
14 |
15 | ## Directory-based project format:
16 | .idea/
17 | # if you remove the above rule, at least ignore the following:
18 |
19 | # User-specific stuff:
20 | # .idea/workspace.xml
21 | # .idea/tasks.xml
22 | # .idea/dictionaries
23 |
24 | # Sensitive or high-churn files:
25 | # .idea/dataSources.ids
26 | # .idea/dataSources.xml
27 | # .idea/sqlDataSources.xml
28 | # .idea/dynamic.xml
29 | # .idea/uiDesigner.xml
30 |
31 | # Gradle:
32 | # .idea/gradle.xml
33 | # .idea/libraries
34 |
35 | # Mongo Explorer plugin:
36 | # .idea/mongoSettings.xml
37 |
38 | ## File-based project format:
39 | *.ipr
40 | *.iws
41 |
42 | ## Plugin-specific files:
43 |
44 | # IntelliJ
45 | /out/
46 |
47 | # mpeltonen/sbt-idea plugin
48 | .idea_modules/
49 |
50 | # JIRA plugin
51 | atlassian-ide-plugin.xml
52 |
53 | # Crashlytics plugin (for Android Studio and IntelliJ)
54 | com_crashlytics_export_strings.xml
55 | crashlytics.properties
56 | crashlytics-build.properties
57 |
58 | .babelrc
59 | .editorconfig
60 | .eslintignore
61 | .eslint
62 | .gitignore
63 | .travis.yml
64 | webpack.config.js
65 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change log
2 |
3 | All notable changes to this project will be documented in this file.
4 | This project adheres to [Semantic Versioning](http://semver.org/).
5 |
6 | ## [1.0.0-alpha.21] - 2016/03/10
7 | * `onEnter` and `onLeave` handlers are resolved always, please make sure in handlers if you need to do something (e.g. ajax calls, ...) or not
8 | * removed argument `resolveOnLoad` from router, router always resolves routes and route handlers
9 |
10 | ## [1.0.0-alpha.18] - 2016/01/06
11 | * removed dependency on `rackt/history`
12 | * onEnter handlers on initial load can be disabled with fourth argument to `createRoutex` or `Route`. `false` will disable running onEnter handlers on initial load (after page is loaded)
13 |
14 | ## [1.0.0-alpha.17] - 2016/01/04
15 | * run onEnter handlers on initial load (after page is loaded)
16 |
17 | ## [1.0.0-alpha.16] - 2015/12/16
18 | * add `fullPath` to resolved route
19 |
20 | ## [1.0.0-alpha.15] - 2015/12/15
21 | * run route `onEnter` and `onLeave` handlers in order (not parallel)
22 |
23 | ## [1.0.0-alpha.14] - 2015/12/06
24 | * fix regex groups in route patterns
25 |
26 | ## [1.0.0-alpha.13] - 2015/12/06
27 | * fix link blinking in TRANSITIONING state
28 |
29 | ## [1.0.0-alpha.9] - 2015/12/05
30 | * fixed bug when multiple regex patterns are in route pattern
31 |
32 | ## [1.0.0-alpha.8] - 2015/10/27
33 | * fixed bug in matching active routes if href length is equal 1
34 |
35 | ## [1.0.0-alpha.7] - 2015/10/27
36 | * fixed bug in matching active routes if `Link` href is longer than matching path
37 |
38 | ## [1.0.0-alpha.6] - 2015/10/27
39 | * fixed active props for nested routes
40 |
41 | ## [1.0.0-alpha.5] - 2015/09/30
42 | * fixed matching of active routes
43 |
44 | ## [1.0.0-alpha.4] - 2015/09/30
45 | * fixed `Link` component `propTypes`
46 |
47 | ## [1.0.0-alpha.3] - 2015/09/30
48 | * added active and inactive props to `Link` component
49 |
50 | ## [1.0.0-alpha.2] - 2015/09/26
51 | * fixed bug in subroute base path
52 |
53 | ## [0.4.0] - 2015/08/16
54 | * added `createHref` to Router API
55 | * `Link` component is using `createHref` from a router instance
56 |
57 | ## [0.3.0] - 2015/07/31
58 | * replaced histories with rackt-history (BC breaking change)
59 | * added prever testing for node/browser environment
60 |
61 | ## [0.1.0] - 2015/07/18
62 | * Initial public release (dev, use on your own risk)
63 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Code of Conduct
2 |
3 | As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities.
4 |
5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality.
6 |
7 | Examples of unacceptable behavior by participants include:
8 |
9 | * The use of sexualized language or imagery
10 | * Personal attacks
11 | * Trolling or insulting/derogatory comments
12 | * Public or private harassment
13 | * Publishing other's private information, such as physical or electronic addresses, without explicit permission
14 | * Other unethical or unprofessional conduct.
15 |
16 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team.
17 |
18 | This code of conduct applies both within project spaces and in public spaces when an individual is representing the project or its community.
19 |
20 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers.
21 |
22 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.2.0, available at [http://contributor-covenant.org/version/1/2/0/](http://contributor-covenant.org/version/1/2/0/)
23 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 Michal Kvasničák
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Routex
2 |
3 | [](https://circleci.com/gh/michalkvasnicak/routex)
4 |
5 | Simple router for [Redux](https://github.com/rackt/redux) universal applications. Can be used with [React](https://github.com/facebook/react) too.
6 |
7 | ## Installation
8 |
9 | `npm install routex`
10 |
11 | ## Requirements
12 |
13 | **Routex needs some abstraction over browser history, we recommend to use [rackt/history^1.0.0](https://github.com/rackt/history)**
14 |
15 | ## Usage
16 |
17 | ### Creating routex (without react)
18 |
19 | ```js
20 | import { createRoutex, actions } from 'routex';
21 | import { compose, createStore, combineReducers } from 'redux';
22 | import { createHistory } from 'history';
23 |
24 | const routes = [
25 | {
26 | path: '/',
27 | children: [
28 | {
29 | path: 'about',
30 | children: [/* ... */]
31 | }
32 | ],
33 | attrs: {
34 | custom: true // optional custom attributes to assign to route
35 | }
36 | }/* ... */
37 | ];
38 |
39 | // this will return object with high order store and reducer
40 | const routex = createRoutex(routes, createHistory(), () => console.log('Transition finished') );
41 |
42 | const newCreateStore = compose(routex.store, createStore);
43 | const routexReducer = routex.reducer;
44 | const reducers = combineReducers({ ...routexReducer /* your reducers */ });
45 |
46 | const store = newCreateStore(reducers);
47 |
48 | store.dispatch(actions.transitionTo('/about')); // transitions to about
49 |
50 | store.generateLink('about'); // generates link object (see api)
51 | ```
52 |
53 | ### Creating routex using in React app (React >= 0.14)
54 |
55 | ```js
56 | import { createRoutex } from 'routex';
57 | import { compose, createStore, combineReducers } from 'redux';
58 | import React, { Component } from 'react';
59 | import { View, Link } from 'routex/lib/react';
60 | import { createHistory } from 'history';
61 |
62 | class App extends Component {
63 | render() {
64 | //this props children contains nested route
65 | // so everywhere when you can render nested routes you need to do this
66 | return (
67 |
68 |
69 | {this.props.children}
70 |
71 | );
72 | }
73 | }
74 |
75 | const routes = [
76 | {
77 | path: '/',
78 | component: App, // you need components in all routes because needs to render them
79 | attrs: {}, // default attrs
80 | children: [
81 | {
82 | path: 'about',
83 | attrs: { test: 1 },
84 | component: () => Promise.resolve(About),
85 | children: () => Promise.resolve([{ path: '/', component: Child }])
86 | }
87 | ]
88 | }/* ... */
89 | ];
90 |
91 | // this will return object with high order store and reducer
92 | const routex = createRoutex(routes, createHistory(), () => console.log('Transition finished') );
93 |
94 | const newCreateStore = compose(routex.store, createStore);
95 | const routexReducer = routex.reducer;
96 | const reducers = combineReducers({ ...routexReducer /* your reducers */ });
97 |
98 | const store = newCreateStore(reducers);
99 |
100 | React.render(
101 |
102 |
103 |
104 | , document.getElementById('App')
105 | );
106 |
107 | ```
108 |
109 | ### Use router as standalone (without redux / react)
110 |
111 | ```js
112 | import { Router } from 'routex';
113 | import { createHistory } from 'history';
114 |
115 | const router = new Router([/* routes */], createHistory() /*, optional onTransition hook */);
116 |
117 | router.listen(); // start listening to pop state events (immediately will start transition for current location)
118 |
119 | // if you want to transition to another location you have to run this
120 | // if you won't then router will lose track of current location and will pretend
121 | // that location didn't change
122 | router.run('/where-you-want-to-go', { /* query params object */});
123 | ```
124 |
125 | ### API
126 |
127 | - **`Router`**:
128 | - **`constructor(routes, history, onTransition, resolveOnLoad)`**:
129 | - **`routes`** (`RouteObject[]`) array of route objects (see below)
130 | - **`history`** (`HistoryObject`) history object (see below)
131 | - **`onTransition`** (`Function(error: ?Error, resolvedRoute: ?Object`) optional function called every time router resolves/rejects route
132 | - **`resolveOnLoad`** (`Boolean`) optional, should route onEnter handlers be called on initial load? (useful if page is rendered in node, so we don't want to run onEnter again)
133 | - **`wrapOnEnterHandler(wrapper)`**:
134 | - **`wrapper`** (`Function(Function)`):
135 | - wrapper is function receiving route onEnter handler and returning its result
136 | - can be used to decorate onEnter handler (e.g. passing some variables, etc)
137 | - it will be called with original handler bound to default arguments (see routeObject) as a first argument
138 | - `router.wrapOnEnterHandler((onEnter) => onEnter(someVar)` will append someVar to default onEnter argument list
139 | - **`wrapOnLeaveHandler(wrapper)`**:
140 | - **`wrapper`** (`Function(Function)`):
141 | - wrapper is function receiving route onLeave handler and returning its result
142 | - can be used to decorate onLeave handler (e.g. passing some variables, etc)
143 | - it will be called with original handler bound to default arguments (see routeObject) as a first argument
144 | - `router.wrapOnLeaveHandler((onLeave) => onLeave(someVar)` will append someVar to default onLeave argument list
145 | - **`createHref(pathname, query):String`**
146 | - **`pathname`** (`String`) - url pathname
147 | - **`query`** (`?Object.`) - optional query parameters
148 | - creates link
149 | - **`currentRoute():null|RouteObject`** returns current route
150 | - **`addChangeStartListener(listener:Function):Function`** - returns unsubscribe function
151 | - **`addChangeSuccessListener(listener:Function):Function`** - returns unsubscribe function
152 | - **`addChangeFailListener(listener:Function):Function`** - returns unsubscribe function
153 | - **`addNotFoundListener(listener:Function):Function`** - returns unsubscribe function
154 | - **`run(path, query):Promise`**:
155 | - **`path`** (`String`) - url pathname
156 | - **`query`** (`?Object.`) - optional query parameters
157 | - resolves route for given pathname
158 | - **`listen()`** - starts listening to history pop events (and will fire POPstate event immediately after `listen()` call
159 |
160 | - **`createRoutex(routes, history, onTransition, resolveOnLoad)`**
161 | - **`routes`** (`RouteObject[]`) array of RouteObject (see below)
162 | - **`history`** (`HistoryObject`) history object (see below)
163 | - **`onTransition`** (`Function(error: ?Error, resolvedRoute: ?Object`) optional function called every time router resolves/rejects route
164 | - **`resolveOnLoad`** (`Boolean`) optional, should route onEnter handlers be called on initial load? (useful if page is rendered in node, so we don't want to run onEnter again)
165 | - returns (`Object`)
166 | - **`store`** (`Function`) - high order store function
167 | - **`reducer`** (`{ router: Function }`) - object usable in `combineReducers` of `redux`
168 |
169 | - **`actions.transitionTo(pathname, query)`**
170 | - creates action, that routex store will try to transition to
171 | - **`path`** (`String`) - path without query string of new route
172 | - **`query`** (`Object.`) - optional, parsed query string parameters to object
173 |
174 | - **`RouteObject:`** (`Object`):
175 | - **`path`** (`String`) - route path (regexp will be created from it)
176 | - `/path:variable`
177 | - `/path/:variable`
178 | - `/path/:variable{\\d+}` - variable should be number
179 | - **`component`** (`Function|ReactElement`) ReactElement (optional)|Function:Promise`
180 | - returns ReactElement or `Function` returning `Promise` resolving to ReactElement
181 | - ReactElement is required only in case that you are using `` with React otherwise component can be anything you want
182 | - can be async, have to be a function returning a Promise otherwise it is sync
183 | - **`?children`** (`RouteObject[]`)
184 | - optional array of RouteObjects or function returning Promise (which resolves to array of RouteObjects)
185 | - **`?onEnter`** (`Function`)
186 | - optional route onEnter handler function
187 | - function used to determine if router can transition to this route (can be used as guard, or to load data needed for view to store)
188 | - **this function is called on popState (moving in browser history using back/forward buttons), on `` click or dispatching `transitionTo`**
189 | - function signature is `function (currentRoute, nextRoute, router):Promise` **if is used outside of createRoutex**
190 | - function signature is `function (currentRoute, nextRoute, router, dispatch, getState):Promise` **if is used by createRoutex, because it is wrapped**
191 | - **`currentRoute`** (`RouteObject|null`)` - current state of routex
192 | - **`nextRoute`** (`RouteObject`) - route we are transitioning to
193 | - **`router`**: (`Router`) - instance of router
194 | - returns **`Promise`**
195 | - if promise is resolved, transition will finish and changes the state of the router reducer
196 | - if promise is rejected, transition will finish but it won't change the state of the router reducer
197 | - **`?onLeave`** (`Function`)
198 | - optional route onLeave handler function
199 | - signature is same as in the `onEnter`
200 | - function used to determine if router can transition from this route (can be used as guard, ...) to a new route
201 | - **this function is called on popState (moving in browser history using back/forward buttons), on `` click or dispatching `transitionTo`**
202 | - **`?attrs`** (`Object`)
203 | - optional object of attributes assigned to route
204 | - is overridden by child attributes if have same key name
205 |
206 | - **`HistoryObject:`** (`Object`):
207 | - abstraction over browser history
208 | - **`listen`** (`Function(Function(LocationObject))`) -
209 | - method used to register history change events listeners (pop and push)
210 | - **`pushState`** (`Function(state, path)`)
211 | - pushes state for given path
212 | - **`state`** (`?Object`) - state stored for given path
213 | - **`path`** (`String)` - full path with query parameters
214 | - **`replaceState`** (`Function(state, path)`)
215 | - replaces current state with given state and path
216 | - **`state`** (`?Object`) - state stored for given path
217 | - **`path`** (`String)` - full path with query parameters
218 |
219 | - **`LocationObject:`** (`Object`):
220 | - abstraction over current location
221 | - **`action`** (`String`) - `POP` or `PUSH`
222 | - **`state`** (`?Object`) - current state of location
223 | - **`pathname`** (`String`) - pathname without query parameters
224 | - **`search`** (`String`) - search part of path (query parameters as string)
225 |
226 | ### React components
227 |
228 | #### `` Component
229 |
230 | Use this component whenever you want to render routes. This component needs `store` to be accessible in context.
231 | `` components can be nested, so you can use them in your own components (in case of nested routes)
232 |
233 | ```
234 | // will render current route component (if route component renders too, it will render component of nested route
235 | ```
236 |
237 | #### `` Component
238 |
239 | Use this component whenever you want an `` element to go to route. This component need `store` to be accessible in context.
240 | Internally this component is dispatching action `transitionTo()`
241 |
242 | - **Props**:
243 | - **`to`** (`String`) - url pathname to go to
244 | - **`query`** (`?Object.`) - optional, query parameters (will be add to `href` attribute)
245 | - **`stateProps`** (`?Object.>`) - properties for `active`, `inactive` state of ``
246 | - **`active`** (`?Object.`) - optional props to be assigned if `` `href` is active (matching current route)
247 | - **`inactive`** (`?Object.`) - optional props to be assigned if `` `href` is inactive (not matching current route)
248 |
--------------------------------------------------------------------------------
/circle.yml:
--------------------------------------------------------------------------------
1 | machine:
2 | pre:
3 | - "sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-4.9 20"
4 | - "sudo update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-4.9 20"
5 | - "nvm install v4.1.1 && nvm alias default v4.1.1"
6 | dependencies:
7 | pre:
8 | - "npm install -g npm"
9 | test:
10 | override:
11 | - "npm run test"
12 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "routex",
3 | "version": "1.0.0-alpha.22",
4 | "description": "Simple router for Redux universal applications. Can be used with React too.",
5 | "main": "lib/index.js",
6 | "license": "MIT",
7 | "repository": {
8 | "type": "git",
9 | "url": "https://github.com/michalkvasnicak/routex.git"
10 | },
11 | "bugs": {
12 | "url": "https://github.com/michalkvasnicak/routex/issues"
13 | },
14 | "homepage": "https://github.com/michalkvasnicak/routex",
15 | "keywords": [
16 | "redux",
17 | "router",
18 | "react"
19 | ],
20 | "scripts": {
21 | "clean": "rimraf lib && rimraf dist",
22 | "build": "npm run clean && npm run build:node && npm run build:umd",
23 | "build:node": "babel src --out-dir lib",
24 | "build:umd": "npm run build:umd:routex && npm run build:umd:react",
25 | "build:umd:routex": "webpack src/index.js dist/routex.js && NODE_ENV=production webpack src/index.js dist/routex.min.js",
26 | "build:umd:react": "MODULENAME=react-routex webpack src/react/index.js dist/react-routex.js && NODE_ENV=production MODULE_NAME=react-routex webpack src/react/index.js dist/react-routex.min.js",
27 | "lint": "eslint -c .eslintrc src test",
28 | "test": "npm run lint && mocha --compilers js:babel/register --recursive"
29 | },
30 | "peerDependencies": {
31 | "redux": ">=2.0.0",
32 | "react-redux": ">=2.0.0"
33 | },
34 | "dependencies": {
35 | "qs": "^4.0.0",
36 | "invariant": "^2.1.0"
37 | },
38 | "devDependencies": {
39 | "babel": "^5.5.8",
40 | "babel-core": "^5.6.15",
41 | "babel-eslint": "^3.1.15",
42 | "babel-loader": "^5.1.4",
43 | "eslint": "^1.0.0",
44 | "eslint-config-airbnb": "^1.0.0",
45 | "eslint-plugin-mocha": "^1.1.0",
46 | "eslint-plugin-react": "^3.13.1",
47 | "history": "^1.3.0",
48 | "chai": "^3.0.0",
49 | "chai-as-promised": "5.1.0",
50 | "jsdom": "~5.4.3",
51 | "mocha": "^2.2.5",
52 | "mocha-jsdom": "~0.4.0",
53 | "redux": "^3.0.0",
54 | "react": "^15.0.2",
55 | "react-addons-test-utils": "^15.0.2",
56 | "react-dom": "^15.0.2",
57 | "react-redux": "^4.0.0",
58 | "rimraf": "^2.3.4",
59 | "sinon": "^1.16.0",
60 | "webpack": "^1.10.1"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/scripts/release:
--------------------------------------------------------------------------------
1 | #!/bin/bash -e
2 |
3 | update_version() {
4 | echo "$(node -p "p=require('./${1}');p.version='${2}';JSON.stringify(p,null,2)")" > $1
5 | echo "Updated ${1} version to ${2}"
6 | }
7 |
8 | validate_semver() {
9 | if ! [[ $1 =~ ^[0-9]\.[0-9]+\.[0-9](-.+)? ]]; then
10 | echo "Version $1 is not valid! It must be a valid semver string like 1.0.2 or 2.3.0-beta.1"
11 | exit 1
12 | fi
13 | }
14 |
15 | current_version=$(node -p "require('./package').version")
16 |
17 | printf "Next version (current is $current_version)? "
18 | read next_version
19 |
20 | validate_semver $next_version
21 |
22 | next_ref="v$next_version"
23 |
24 | npm test
25 |
26 | update_version 'package.json' $next_version
27 |
28 | npm run build
29 | git add -A src
30 | git add -A test
31 |
32 | git commit -am "Version $next_version"
33 |
34 | git tag $next_ref
35 | git tag latest -f
36 |
37 | git push origin master
38 | git push origin $next_ref
39 | git push origin latest -f
40 |
41 | npm publish
42 |
--------------------------------------------------------------------------------
/src/Route.js:
--------------------------------------------------------------------------------
1 | import invariant from 'invariant';
2 | import { buildMatcher, normalizeRouteDefinition } from './utils/routeUtils';
3 | import { resolveWithFirstMatched } from './utils/routerUtils';
4 | import { normalizeSlashes } from './utils/stringUtils';
5 | import {
6 | NoRoutesToResolveError
7 | } from './errors';
8 | import { createHref } from './utils/urlUtils';
9 |
10 | /**
11 | * Resolves async routes and returns Promise which resolves to normalized definitions
12 | *
13 | * @param {Function} children
14 | * @returns {Promise}
15 | */
16 | function resolveAsyncRoutes(children) {
17 | return new Promise((resolve, reject) => {
18 | const routes = children();
19 |
20 | if (!(routes instanceof Promise)) {
21 | const type = typeof routes;
22 |
23 | reject(
24 | Error(`Async route definition resolvers should return a promise, ${type} given.`)
25 | );
26 | }
27 |
28 | routes.then(
29 | (_routes) => {
30 | if (!Array.isArray(_routes)) {
31 | const type = typeof _routes;
32 |
33 | reject(
34 | Error(`Async route definition resolvers should resolve to array, ${type} given.`)
35 | );
36 | }
37 |
38 | resolve(_routes);
39 | }, reject
40 | );
41 | });
42 | }
43 |
44 | /**
45 | * Resolves child routes (sync and async too)
46 | *
47 | * @param {Array|Function} children
48 | * @returns {Promise}
49 | */
50 | function resolveChildRoutes(children) {
51 | function normalizeRoutes(routes, onError) {
52 | try {
53 | return routes.map((route) => {
54 | return normalizeRouteDefinition(route);
55 | });
56 | } catch (e) {
57 | onError(e);
58 | }
59 | }
60 |
61 | return new Promise((resolve, reject) => {
62 | if (!Array.isArray(children)) {
63 | resolveAsyncRoutes(children).then(
64 | (routes) => resolve(normalizeRoutes(routes, reject)),
65 | reject
66 | );
67 | } else {
68 | if (!children.length) {
69 | resolve([]);
70 | } else {
71 | resolve(normalizeRoutes(children, reject));
72 | }
73 | }
74 | });
75 | }
76 |
77 | export default class Route {
78 | constructor(
79 | path = '/',
80 | basePath = '/',
81 | children = [],
82 | onEnter,
83 | onLeave,
84 | component,
85 | attrs = {}
86 | ) {
87 | const pathType = typeof path;
88 | const basePathType = typeof basePath;
89 | const childrenType = typeof children;
90 | const onEnterType = typeof onEnter;
91 | const onLeaveType = typeof onLeave;
92 |
93 | invariant(pathType === 'string', `Route path should be string, ${pathType} given.`);
94 | invariant(basePathType === 'string', `Route base path should be string, ${basePathType} given.`);
95 | invariant(
96 | Array.isArray(children) || childrenType === 'function',
97 | `Route children should be an array or function, ${childrenType} given.`
98 | );
99 | invariant(
100 | onEnterType === 'function',
101 | `Route handler \`onEnter\` should be a function, ${onEnterType} given.`
102 | );
103 | invariant(
104 | onLeaveType === 'function',
105 | `Route handler \`onLeave\` should be a function, ${onLeaveType} given.`
106 | );
107 |
108 | /**
109 | * Eager matcher for this route only
110 | *
111 | * @type {null|Function}
112 | */
113 | this.matcher = null;
114 |
115 | /**
116 | * Non eager matcher for this route (will match this route + something more)
117 | *
118 | * @type {null|Function}
119 | */
120 | this.childMatcher = null;
121 |
122 | this.path = path;
123 |
124 | this.basePath = basePath;
125 |
126 | this.onEnter = onEnter;
127 |
128 | this.onLeave = onLeave;
129 |
130 | this.component = component;
131 |
132 | this.children = children;
133 |
134 | this.attrs = attrs;
135 | }
136 |
137 | match(path, query) {
138 | return new Promise((resolve, reject) => {
139 | // lazy create matchers
140 | if (this.matcher === null) {
141 | const { eager, nonEager } = buildMatcher(this.path, this.basePath);
142 |
143 | this.matcher = eager;
144 | this.childMatcher = nonEager;
145 | }
146 |
147 | const instantiateRoutes = (routes) => {
148 | return routes.map((route) => {
149 | return new Route(
150 | route.path,
151 | normalizeSlashes(this.basePath + '/' + this.path),
152 | route.children,
153 | route.onEnter,
154 | route.onLeave,
155 | route.component,
156 | route.attrs
157 | );
158 | });
159 | };
160 |
161 |
162 | // this resolves current path using eager regexp
163 | // in case children does not match
164 | const resolveOnlyCurrentRoute = () => {
165 | const match = this.matcher(path);
166 |
167 | if (match) {
168 | const { vars } = match;
169 |
170 | return resolve({
171 | pathname: path,
172 | vars,
173 | query,
174 | fullPath: createHref(path, query),
175 | components: [this.component],
176 | onEnter: [this.onEnter],
177 | onLeave: [this.onLeave],
178 | attrs: this.attrs
179 | });
180 | }
181 |
182 | return reject();
183 | };
184 |
185 | // this resolves current route only if child routes returned
186 | // NoRoutesToResolveError ( means children is empty )
187 | const resolveOnlyCurrentIfNoError = (err) => {
188 | if (!err || (err instanceof NoRoutesToResolveError)) {
189 | resolveOnlyCurrentRoute();
190 | } else {
191 | reject(err);
192 | }
193 | };
194 |
195 | // if child matchers matches, try to match children first
196 | const childMatch = this.childMatcher(path);
197 |
198 | if (childMatch) {
199 | // resolve children routes
200 | resolveChildRoutes(this.children).then(
201 | (routes) => {
202 | try {
203 | this.children = instantiateRoutes(routes);
204 |
205 | // try to match children and resolve with first matched
206 | resolveWithFirstMatched(this.children, path, query).then(
207 | (match) => {
208 | const { vars, onEnter, onLeave, components, attrs } = match;
209 |
210 | resolve({
211 | pathname: path,
212 | vars,
213 | query,
214 | fullPath: createHref(path, query),
215 | components: [this.component, ...components],
216 | onEnter: [this.onEnter, ...onEnter],
217 | onLeave: [this.onLeave, ...onLeave],
218 | attrs: { ...this.attrs, ...attrs }
219 | });
220 | },
221 | resolveOnlyCurrentIfNoError // this is called when children don't match
222 | );
223 | } catch (e) {
224 | reject(e);
225 | }
226 | },
227 | reject
228 | );
229 | } else {
230 | resolveOnlyCurrentRoute();
231 | }
232 | });
233 | }
234 | }
235 |
--------------------------------------------------------------------------------
/src/Router.js:
--------------------------------------------------------------------------------
1 | import Route from './Route';
2 | import { normalizeRouteDefinition, runRouteHandlers, resolveComponents } from './utils/routeUtils';
3 | import { resolveWithFirstMatched } from './utils/routerUtils';
4 | import invariant from 'invariant';
5 | import { RouteNotFoundError } from './errors';
6 | import { createHref, parseQuery } from './utils/urlUtils';
7 |
8 | function instantiateRoutes(routes) {
9 | return routes.map((definition) => {
10 | const normalized = normalizeRouteDefinition(definition);
11 |
12 | return new Route(
13 | normalized.path,
14 | undefined,
15 | normalized.children,
16 | normalized.onEnter,
17 | normalized.onLeave,
18 | normalized.component,
19 | normalized.attrs
20 | );
21 | });
22 | }
23 |
24 | const REPLACE_STATE = 'replace';
25 | const PUSH_STATE = 'push';
26 | const DO_NOTHING = 'nope';
27 |
28 | export default class Router {
29 | constructor(
30 | routes = [],
31 | history,
32 | onTransition = function transitionFinished() {}
33 | ) {
34 | invariant(Array.isArray(routes), `Routes should be an array, ${typeof routes} given.`);
35 | invariant(
36 | typeof onTransition === 'function',
37 | `Router onTransition callback should be a function, ${typeof onTransition} given.`
38 | );
39 |
40 | this.routes = instantiateRoutes(routes);
41 |
42 | // enable queries means that query parameters can be used directly as objects
43 | this.history = history;
44 |
45 | this.onTransition = onTransition || function transitionFinished() {};
46 |
47 | this.listeners = {
48 | changeStart: [],
49 | changeSuccess: [],
50 | changeFail: [],
51 | notFound: []
52 | };
53 |
54 | this.handlerWrappers = {
55 | onEnter(onEnter) {
56 | return onEnter();
57 | },
58 | onLeave(onLeave) {
59 | return onLeave();
60 | }
61 | };
62 |
63 | this._currentRoute = null;
64 | }
65 |
66 | listen() {
67 | // listen to popState event
68 | this.history.listen(this._handleChange.bind(this));
69 | }
70 |
71 | _handleChange(location) {
72 | if (location.action === 'POP') {
73 | // on handle pop state (we are moving in history)
74 | const path = location.pathname;
75 | const query = parseQuery(location.search);
76 |
77 | this.run(path, query, !!location.state ? DO_NOTHING : REPLACE_STATE);
78 | }
79 | }
80 |
81 | currentRoute() {
82 | return this._currentRoute;
83 | }
84 |
85 | _wrapRouteHandler(name, wrapper) {
86 | invariant(
87 | typeof wrapper === 'function',
88 | `${name} handler wrapper should be a function, ${typeof wrapper} given.`
89 | );
90 |
91 | this.handlerWrappers[name] = wrapper;
92 | }
93 |
94 | _callEventListeners(name, ...args) {
95 | this.listeners[name].forEach((listener) => listener(...args));
96 | }
97 |
98 | _registerEventListener(name, listener) {
99 | invariant(
100 | typeof listener === 'function',
101 | `${name} event listener should be function, ${typeof listener} given.`
102 | );
103 |
104 | const listeners = this.listeners[name];
105 |
106 | listeners.push(listener);
107 |
108 | return function unsubscribe() {
109 | const index = listeners.indexOf(listener);
110 | listeners.splice(index);
111 | };
112 | }
113 |
114 | _rejectTransition(reason) {
115 | const err = new Error(reason);
116 |
117 | return (parentErr) => {
118 | const e = parentErr || err;
119 | this._callEventListeners('changeFail', e, this._currentRoute, this);
120 | this.onTransition(e);
121 |
122 | throw err;
123 | };
124 | }
125 |
126 | /**
127 | * Finishes run route resolving
128 | *
129 | * @param {Object} resolvedRoute
130 | * @param {String} path
131 | * @param {Object} query
132 | * @param {String} action
133 | * @returns {Object}
134 | * @private
135 | */
136 | _finishRun(resolvedRoute, path, query, action) {
137 | this._currentRoute = resolvedRoute;
138 | this._callEventListeners('changeSuccess', resolvedRoute);
139 |
140 | /* eslint-disable default-case */
141 | switch (action) {
142 | case PUSH_STATE:
143 | this.history.pushState(resolvedRoute, createHref(path, query));
144 | break;
145 | case REPLACE_STATE:
146 | this.history.replaceState(
147 | resolvedRoute,
148 | createHref(path, query)
149 | );
150 | break;
151 | }
152 | /* eslint-enable default-case */
153 |
154 | this.onTransition(null, resolvedRoute);
155 |
156 | return resolvedRoute;
157 | }
158 |
159 | addChangeStartListener(listener) {
160 | return this._registerEventListener('changeStart', listener);
161 | }
162 |
163 | addChangeSuccessListener(listener) {
164 | return this._registerEventListener('changeSuccess', listener);
165 | }
166 |
167 | addChangeFailListener(listener) {
168 | return this._registerEventListener('changeFail', listener);
169 | }
170 |
171 | addNotFoundListener(listener) {
172 | return this._registerEventListener('notFound', listener);
173 | }
174 |
175 | /**
176 | * Wraps route onEnter handler
177 | *
178 | * @param {Function} handler
179 | */
180 | wrapOnEnterHandler(handler) {
181 | this._wrapRouteHandler('onEnter', handler);
182 | }
183 |
184 | /**
185 | * Wraps route onLeave handler
186 | *
187 | * @param {Function} handler
188 | */
189 | wrapOnLeaveHandler(handler) {
190 | this._wrapRouteHandler('onLeave', handler);
191 | }
192 |
193 | /**
194 | * Starts router transition
195 | *
196 | * @param {String} path
197 | * @param {Object} query
198 | * @param {String} action
199 | * @returns {Promise}
200 | */
201 | run(path, query = {}, action = PUSH_STATE) {
202 | const runResolvedRoute = (resolvedRoute) => {
203 | const currentRoute = this._currentRoute;
204 | this._callEventListeners('changeStart', currentRoute, resolvedRoute, this);
205 |
206 | const handlerWrappers = this.handlerWrappers;
207 |
208 | // call on leave in order (so we can cancel transition)
209 | return runRouteHandlers('onLeave', currentRoute, handlerWrappers, resolvedRoute, this).then(
210 | () => runRouteHandlers('onEnter', resolvedRoute, handlerWrappers, currentRoute, resolvedRoute, this).then(
211 | () => resolveComponents(resolvedRoute.components).then(
212 | (components) => {
213 | return this._finishRun({ ...resolvedRoute, components }, path, query, action);
214 | },
215 | this._rejectTransition('Route components cannot be resolved')
216 | ),
217 | this._rejectTransition('Route onEnter handlers are rejected.')
218 | ),
219 | this._rejectTransition('Current route onLeave handlers are rejected.')
220 | );
221 | };
222 |
223 | const notFound = () => {
224 | const err = new RouteNotFoundError('Route not found');
225 | this._callEventListeners('notFound', path, query);
226 | this.onTransition(err);
227 |
228 | throw err;
229 | };
230 |
231 | return resolveWithFirstMatched(this.routes, path, query).then(
232 | runResolvedRoute,
233 | notFound
234 | );
235 | }
236 | }
237 |
--------------------------------------------------------------------------------
/src/actionTypes.js:
--------------------------------------------------------------------------------
1 | export const ROUTE_CHANGE_START = '@@ROUTEX/ROUTE_CHANGE_START';
2 | export const ROUTE_CHANGE_SUCCESS = '@@ROUTEX/ROUTE_CHANGE_SUCCESS';
3 | export const ROUTE_CHANGE_FAIL = '@@ROUTEX/ROUTE_CHANGE_FAIL';
4 | export const ROUTE_NOT_FOUND = '@@ROUTEX/ROUTE_NOT_FOUND';
5 | export const TRANSITION_TO = '@@ROUTEX/TRANSITION_TO';
6 |
--------------------------------------------------------------------------------
/src/actions.js:
--------------------------------------------------------------------------------
1 | import {
2 | ROUTE_CHANGE_START,
3 | ROUTE_CHANGE_SUCCESS,
4 | ROUTE_CHANGE_FAIL,
5 | ROUTE_NOT_FOUND,
6 | TRANSITION_TO
7 | } from './actionTypes';
8 |
9 |
10 | export function changeStart(currentRoute, nextRoute) {
11 | return {
12 | type: ROUTE_CHANGE_START,
13 | route: currentRoute,
14 | nextRoute: nextRoute
15 | };
16 | }
17 |
18 | export function changeSuccess(currentRoute) {
19 | return {
20 | type: ROUTE_CHANGE_SUCCESS,
21 | route: currentRoute
22 | };
23 | }
24 |
25 | export function changeFail(currentRoute, error) {
26 | return {
27 | type: ROUTE_CHANGE_FAIL,
28 | route: currentRoute,
29 | error: error
30 | };
31 | }
32 |
33 | export function notFound(path, query) {
34 | return {
35 | type: ROUTE_NOT_FOUND,
36 | path,
37 | query
38 | };
39 | }
40 |
41 | export function transitionTo(path, query = {}) {
42 | return {
43 | type: TRANSITION_TO,
44 | pathname: path,
45 | query: query
46 | };
47 | }
48 |
--------------------------------------------------------------------------------
/src/createRoutex.js:
--------------------------------------------------------------------------------
1 | import Router from './Router';
2 |
3 | import {
4 | ROUTE_CHANGE_START,
5 | ROUTE_CHANGE_FAIL,
6 | ROUTE_CHANGE_SUCCESS,
7 | TRANSITION_TO
8 | } from './actionTypes';
9 |
10 | import {
11 | changeSuccess,
12 | changeFail,
13 | notFound,
14 | changeStart
15 | } from './actions';
16 |
17 | /**
18 | * Creates routex instance and returns store, reducer and router insance
19 | *
20 | * @param {Array} routes
21 | * @param {Object} history
22 | * @param {?Function} onTransition
23 | * @returns {{router: Router, store: store, reducer: {router: reducer}}}
24 | */
25 | export default function createRoutex(routes, history, onTransition) {
26 | const initialReducerState = { state: 'INITIAL', route: null };
27 |
28 | const router = new Router(routes, history, onTransition);
29 |
30 | const store = (next) => (reducer, initialState) => {
31 | const modifiedInitialState = initialState;
32 |
33 | // reset state of reducer to be initial because components cannot be rehydrated immediately
34 | // because we need to wait for initial router.run
35 | if (typeof initialState === 'object' && initialState !== null && initialState.hasOwnProperty('router')) {
36 | modifiedInitialState.router = initialReducerState;
37 | }
38 |
39 | const nextStore = next(reducer, modifiedInitialState);
40 |
41 | /**
42 | * Dispatch function of this store
43 | *
44 | * @param {*} action
45 | * @returns {*}
46 | */
47 | function dispatch(action) {
48 | if (typeof action !== 'object' || !action.hasOwnProperty('type') || action.type !== TRANSITION_TO) {
49 | return nextStore.dispatch(action);
50 | }
51 |
52 | return router.run(action.pathname, action.query);
53 | }
54 |
55 | // register listeners
56 | router.addChangeStartListener((currentRoute, resolvedRoute/* , router*/) => {
57 | nextStore.dispatch(changeStart(currentRoute, resolvedRoute));
58 | });
59 |
60 | router.addChangeSuccessListener((resolvedRoute) => {
61 | nextStore.dispatch(changeSuccess(resolvedRoute));
62 | });
63 |
64 | router.addChangeFailListener((error, previousRoute/* , router*/) => {
65 | nextStore.dispatch(changeFail(previousRoute, error));
66 | });
67 |
68 | router.addNotFoundListener((path, query) => {
69 | nextStore.dispatch(notFound(path, query));
70 | });
71 |
72 | // wrap handlers
73 | router.wrapOnEnterHandler((onEnter) => {
74 | return onEnter(dispatch, nextStore.getState);
75 | });
76 |
77 | router.wrapOnLeaveHandler((onLeave) => {
78 | return onLeave(dispatch, nextStore.getState);
79 | });
80 |
81 | // initial run of router
82 | // this is not needed because history.listen will be called with location
83 | // so pop state event will be handled
84 | // router.run(history.pathname(), history.query());
85 | router.listen(); // register popState listener
86 |
87 | return {
88 | ...nextStore,
89 | dispatch,
90 | router
91 | };
92 | };
93 |
94 | const reducer = (state = initialReducerState, action) => {
95 | switch (action.type) {
96 | case ROUTE_CHANGE_START:
97 | return {
98 | state: 'TRANSITIONING',
99 | nextRoute: action.nextRoute,
100 | route: state.route
101 | };
102 | case ROUTE_CHANGE_SUCCESS:
103 | return {
104 | state: 'TRANSITIONED',
105 | route: action.route
106 | };
107 | case ROUTE_CHANGE_FAIL:
108 | return {
109 | state: 'TRANSITIONED',
110 | route: action.route, // will be set to previous route
111 | error: action.error
112 | };
113 | /*
114 | todo: not found make as only action which can user listen to and make redirects?
115 | case 'ROUTE_NOT_FOUND':
116 | return {
117 | state: 'TRANSITIONED',
118 | route: action.route // set to previous route
119 | };*/
120 | default:
121 | return state;
122 | }
123 | };
124 |
125 | return {
126 | router,
127 | store,
128 | reducer: { router: reducer }
129 | };
130 | }
131 |
--------------------------------------------------------------------------------
/src/errors.js:
--------------------------------------------------------------------------------
1 | export class NoRoutesToResolveError extends Error {}
2 | export class RouteNotFoundError extends Error {}
3 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import * as actionTypes from './actionTypes';
2 | import * as actions from './actions';
3 | import Router from './Router';
4 | import createRoutex from './createRoutex';
5 |
6 | export {
7 | actionTypes,
8 | actions,
9 | createRoutex,
10 | Router
11 | };
12 |
--------------------------------------------------------------------------------
/src/react/Link.js:
--------------------------------------------------------------------------------
1 | import { transitionTo } from '../actions';
2 | import { createHref } from '../utils/urlUtils';
3 | import Router from '../Router';
4 |
5 | export default function createLink(React, connect) {
6 | const { Component, PropTypes } = React;
7 |
8 | class Link extends Component {
9 | shouldComponentUpdate(nextProps) {
10 | return nextProps.router.state === 'TRANSITIONED';
11 | }
12 |
13 | handleClick(e) {
14 | e.preventDefault();
15 |
16 | this.context.store.dispatch(
17 | transitionTo(
18 | this.props.to,
19 | this.props.query
20 | )
21 | );
22 | }
23 |
24 | render() {
25 | const { to, query, router, stateProps, ...props } = this.props;
26 | const href = createHref(to, query);
27 | const { state, route } = router;
28 | let newProps = props;
29 |
30 | if (state === 'TRANSITIONED' && stateProps && route) {
31 | let matches = href === route.pathname;
32 |
33 | if (!matches) {
34 | if (href === '/') {
35 | matches = true;
36 | } else if (href.length < route.pathname.length) {
37 | matches = (new RegExp(`^(${href}|${href}/.*)$`)).test(route.pathname);
38 | }
39 | }
40 |
41 | newProps = {
42 | ...props,
43 | ...(stateProps[matches ? 'active' : 'inactive'] || {})
44 | };
45 | }
46 |
47 | return (
48 |
52 | {this.props.children}
53 |
54 | );
55 | }
56 | }
57 |
58 | Link.propTypes = {
59 | to: PropTypes.string.isRequired,
60 | query: PropTypes.object,
61 | children: PropTypes.oneOfType([PropTypes.element, PropTypes.array]),
62 | stateProps: PropTypes.shape({
63 | active: PropTypes.object,
64 | inactive: PropTypes.object
65 | }),
66 | router: PropTypes.object.isRequired
67 | };
68 |
69 | Link.contextTypes = {
70 | store: PropTypes.shape({
71 | dispatch: PropTypes.func.isRequired,
72 | router: PropTypes.instanceOf(Router).isRequired
73 | }).isRequired
74 | };
75 |
76 | return connect(
77 | (state) => {
78 | return {
79 | router: state.router
80 | };
81 | }
82 | )(Link);
83 | }
84 |
--------------------------------------------------------------------------------
/src/react/View.js:
--------------------------------------------------------------------------------
1 | export default function createView(React, connect) {
2 | const { Component, PropTypes, isValidElement } = React;
3 |
4 | class View extends Component {
5 | render() {
6 | const { state, route, ...props } = this.props;
7 |
8 | if (state === 'INITIAL' || !route || !route.components) {
9 | return null;
10 | }
11 |
12 | return route.components.reduceRight((component, parent) => {
13 | if (component === null) {
14 | return React.createElement(parent, props);
15 | }
16 |
17 | const child = isValidElement(component) ? component : React.createElement(component, props);
18 |
19 | return React.createElement(parent, props, child);
20 | }, null);
21 | }
22 | }
23 |
24 | View.propTypes = {
25 | state: PropTypes.oneOf(['INITIAL', 'TRANSITIONING', 'TRANSITIONED']).isRequired,
26 | route: PropTypes.shape({
27 | pathname: PropTypes.string.isRequired,
28 | query: PropTypes.object.isRequired,
29 | vars: PropTypes.object.isRequired,
30 | components: PropTypes.array.isRequired
31 | })
32 | };
33 |
34 | View.contextTypes = {
35 | store: PropTypes.object.isRequired
36 | };
37 |
38 | return connect((state) => state.router)(View);
39 | }
40 |
--------------------------------------------------------------------------------
/src/react/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { connect } from 'react-redux';
3 | import createLink from './Link';
4 | import createView from './View';
5 |
6 | export default {
7 | Link: createLink(React, connect),
8 | View: createView(React, connect)
9 | };
10 |
--------------------------------------------------------------------------------
/src/utils/routeUtils.js:
--------------------------------------------------------------------------------
1 | import { normalizeSlashes, trimSlashesFromPathEnd } from './stringUtils';
2 | import invariant from 'invariant';
3 |
4 | /**
5 | * Reduce promises
6 | *
7 | * @param {Function} fn
8 | * @param {*} start
9 | * @returns {Function}
10 | */
11 | function reduce(fn, start) {
12 | return (val) => {
13 | const values = Array.isArray(val) ? val : [val];
14 |
15 | return values.reduce((promise, curr) => {
16 | return promise.then((prev) => {
17 | return fn(prev, curr);
18 | });
19 | }, Promise.resolve(start));
20 | };
21 | }
22 |
23 | /**
24 | * Builds path matcher
25 | *
26 | * @param {string} pathPattern
27 | * @param {string} basePath
28 | *
29 | * @returns {{ eager: Function, nonEager: Function }}
30 | */
31 | export function buildMatcher(pathPattern, basePath = '/') {
32 | // first find all variables
33 | let pathRegexp;
34 | const variableNames = [];
35 | const variablePatterns = [];
36 |
37 | // normalize slashes, trim slashes from end
38 | // and parse path pattern to variable names, etc
39 | pathRegexp = normalizeSlashes(basePath + '/' + pathPattern);
40 | pathRegexp = trimSlashesFromPathEnd(pathRegexp);
41 |
42 | pathRegexp = pathRegexp.replace(/:([a-zA-Z]+)({([^:]+)})?/g, (match, variableName, _, variablePattern) => {
43 | if (variableNames.indexOf(variableName) !== -1) {
44 | throw Error(`Route parameter \`${variableName}\` is already defined.`);
45 | }
46 |
47 | if (variableName) {
48 | variableNames.push(variableName);
49 | }
50 |
51 | const pattern = variablePattern || '[^/]+';
52 |
53 | variablePatterns.push(pattern);
54 |
55 | return `(${pattern})`;
56 | });
57 |
58 | pathRegexp += '/?';
59 |
60 | /**
61 | * Creates matcher for route path
62 | *
63 | * @param {string } pattern
64 | * @param {bool} eager should matcher be eager?
65 | * @returns {Function}
66 | */
67 | function createMatcher(pattern, eager) {
68 | return function matcher(path) {
69 | const matched = path.match(new RegExp(`^${pattern}${eager ? '$' : '.*$'}`, 'i'));
70 |
71 | if (!matched || !matched.length) {
72 | return false;
73 | }
74 |
75 | const vars = {};
76 | let indexInMatch = 1;
77 |
78 | variableNames.forEach((name, index) => {
79 | const start = variablePatterns[index][0];
80 | const end = variablePatterns[index].slice(-1);
81 |
82 | if (start === '(' && end === ')') {
83 | vars[name] = matched[indexInMatch];
84 | indexInMatch += 2; // skip nested group
85 | return;
86 | }
87 |
88 | vars[name] = matched[indexInMatch++];
89 | });
90 |
91 | return {
92 | matched,
93 | vars
94 | };
95 | };
96 | }
97 |
98 | return {
99 | eager: createMatcher(pathRegexp, true),
100 | nonEager: createMatcher(pathRegexp, false)
101 | };
102 | }
103 |
104 |
105 | /**
106 | * Normalizes route definition object (validates it and sets default values)
107 | *
108 | * @param {Object} definition
109 | * @returns {{path: *, children: (*|Array), onEnter: (*|Function), onLeave: (*|Function), component: (*|{}), attrs: ({})}}
110 | */
111 | export function normalizeRouteDefinition(definition) {
112 | const definitionType = typeof definition;
113 |
114 | invariant(
115 | typeof definition === 'object' && definition !== null,
116 | `Route definition should be plain object, ${definitionType} given.`
117 | );
118 |
119 | invariant(
120 | definition.hasOwnProperty('path'),
121 | `Route definition should have \`path\` property.`
122 | );
123 |
124 | const noop = () => { return Promise.resolve(); };
125 |
126 | return {
127 | path: definition.path,
128 | children: definition.children || [],
129 | onEnter: definition.onEnter || noop,
130 | onLeave: definition.onLeave || noop,
131 | component: definition.component || null,
132 | attrs: definition.attrs || {}
133 | };
134 | }
135 |
136 | /* eslint-disable consistent-return */
137 | export function runRouteHandlers(handlers, route, wrappers = [], ...args) {
138 | // if current route is not defined, resolve immediately
139 | // this will prevent calling onLeave on initial load, because we don't have previous route
140 | if (!route) {
141 | return Promise.resolve();
142 | }
143 |
144 | // runs route handler bound to given arguments (from our code)
145 | // wrapper can call it with additional parameters
146 | const runWrappedHandler = (originalHandler, originalProps, wrapper) => {
147 | return wrapper((...fromWrapper) => originalHandler(...originalProps, ...fromWrapper));
148 | };
149 |
150 | // create handlers runner
151 | const composedHandlers = reduce(
152 | (acc, current) => {
153 | try {
154 | const result = runWrappedHandler(current, args, wrappers[handlers]);
155 |
156 | if (result && typeof result.then === 'function') {
157 | return result.then(res => {
158 | acc.push(res);
159 |
160 | return acc;
161 | });
162 | }
163 |
164 | acc.push(result);
165 |
166 | return Promise.resolve(acc);
167 | } catch (e) {
168 | return Promise.reject(e);
169 | }
170 | }, []
171 | );
172 |
173 | const routeHandlers = route[handlers];
174 |
175 | // if running onEnter, run handlers from parent to child
176 | // if onLeave, run them from child to parent
177 | return composedHandlers(
178 | handlers === 'onEnter' ? routeHandlers : routeHandlers.reverse()
179 | );
180 | }
181 | /* eslint-enable consistent-return */
182 |
183 | export function resolveComponents(components) {
184 | if (!Array.isArray(components)) {
185 | return Promise.resolve([]);
186 | }
187 |
188 | // go through components and if function, call it
189 | return Promise.all(
190 | components.map((component) => {
191 | if (typeof component === 'function') {
192 | try {
193 | // if is react class, it throws error
194 | const result = component();
195 |
196 | if (typeof result.then === 'function') {
197 | return result;
198 | }
199 |
200 | return component;
201 | } catch (e) {
202 | return component;
203 | }
204 | }
205 |
206 | return component;
207 | })
208 | );
209 | }
210 |
--------------------------------------------------------------------------------
/src/utils/routerUtils.js:
--------------------------------------------------------------------------------
1 | import invariant from 'invariant';
2 | import Route from '../Route';
3 | import {
4 | NoRoutesToResolveError
5 | } from '../errors';
6 |
7 | /**
8 | * Returns first resolved route, if none resolve, rejects
9 | *
10 | * Routes are resolved in order
11 | *
12 | * @param {Array} routes
13 | * @param {string} path
14 | * @param {query} query
15 | * @returns {Promise}
16 | */
17 | export function resolveWithFirstMatched(routes = [], path, query) {
18 | invariant(Array.isArray(routes), `Routes should be an array, ${typeof routes} given.`);
19 |
20 | function runAndResolveOnFirstResolved(promises, _resolve, _reject, currentIndex = 0) {
21 | const route = promises[currentIndex];
22 |
23 | invariant(
24 | route instanceof Route,
25 | `Routes should contain only Route objects, ${typeof route} given at index ${currentIndex}`
26 | );
27 |
28 | const result = route.match(path, query);
29 |
30 | result.then(
31 | _resolve,
32 | (err) => {
33 | if (currentIndex === routes.length - 1) {
34 | _reject(err);
35 | } else {
36 | runAndResolveOnFirstResolved(promises, _resolve, _reject, currentIndex + 1);
37 | }
38 | }
39 | );
40 | }
41 |
42 | return new Promise((resolve, reject) => {
43 | // call routes in order
44 | if (!routes.length) {
45 | return reject(new NoRoutesToResolveError('No routes to resolve'));
46 | }
47 |
48 | return runAndResolveOnFirstResolved(routes, resolve, reject);
49 | });
50 | }
51 |
--------------------------------------------------------------------------------
/src/utils/stringUtils.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Trims slashes from path end
3 | *
4 | * @param {string} path
5 | * @returns {string}
6 | */
7 | export function trimSlashesFromPathEnd(path) {
8 | return path.replace(/(\/)$/, '');
9 | }
10 |
11 | /**
12 | * Normalizes occurrences of multiple slashes in one place to just one slash
13 | *
14 | * @param {string} path
15 | * @returns {string}
16 | */
17 | export function normalizeSlashes(path) {
18 | return path.replace(/(\/)+\//g, '/');
19 | }
20 |
--------------------------------------------------------------------------------
/src/utils/urlUtils.js:
--------------------------------------------------------------------------------
1 | import { parse as _parseQuery, stringify as _stringifyQuery } from 'qs';
2 |
3 | /**
4 | * Parses query
5 | *
6 | * @param {String} search
7 | * @returns {Object.}
8 | */
9 | export function parseQuery(search) {
10 | if (/^\?/.test(search)) {
11 | return _parseQuery(search.substring(1));
12 | }
13 |
14 | return {};
15 | }
16 |
17 | /**
18 | * Stringifies query
19 | *
20 | * @param {Object.} query
21 | * @returns {String}
22 | */
23 | export function stringifyQuery(query = {}) {
24 | return _stringifyQuery(query, { arrayFormat: 'brackets' });
25 | }
26 |
27 | /**
28 | * Creates href
29 | *
30 | * @param {String} path
31 | * @param {Object.} query
32 | * @returns {String}
33 | */
34 | export function createHref(path, query = {}) {
35 | // if path contains ? strip it
36 | const match = path.match(/^([^?]*)(\?.*)?$/);
37 |
38 | let url = `${match[1]}`;
39 | let queryParams = match[2] ? parseQuery(match[2]) : {};
40 |
41 | // merge with query
42 | queryParams = { ...queryParams, ...query };
43 |
44 | // stringify params only if query contains something
45 | if (Object.keys(queryParams).length) {
46 | url += `?${stringifyQuery(queryParams)}`;
47 | }
48 |
49 | return url;
50 | }
51 |
--------------------------------------------------------------------------------
/test/Route.spec.js:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 | import Route from '../src/Route';
3 |
4 | describe('Route', () => {
5 | describe('#constructor()', () => {
6 | it('throws if path is not an string', () => {
7 | [true, 1, 1.0, null].forEach((path) => {
8 | const type = typeof path;
9 |
10 | expect(() => new Route(path)).to.throw(
11 | `Route path should be string, ${type} given.`
12 | );
13 | });
14 | });
15 |
16 | it('throws if path is not an string', () => {
17 | [true, 1, 1.0, null].forEach((path) => {
18 | const type = typeof path;
19 |
20 | expect(() => new Route(path)).to.throw(
21 | `Route path should be string, ${type} given.`
22 | );
23 | });
24 | });
25 |
26 | it('throws if base path is not an string', () => {
27 | [true, 1, 1.0, null].forEach((path) => {
28 | const type = typeof path;
29 |
30 | expect(() => new Route('/', path)).to.throw(
31 | `Route base path should be string, ${type} given.`
32 | );
33 | });
34 | });
35 |
36 | it('throws if route children is not an array or function', () => {
37 | [true, 1].forEach((children) => {
38 | const type = typeof children;
39 |
40 | expect(() => new Route('/', '', children)).to.throw(
41 | `Route children should be an array or function, ${type} given.`
42 | );
43 | });
44 | });
45 |
46 | it('throws if route onEnter handler is not an function', () => {
47 | expect(() => new Route('/', '', [], 'a')).to.throw(
48 | `Route handler \`onEnter\` should be a function, string given.`
49 | );
50 | });
51 |
52 | it('throws if route onLeave handler is not an function', () => {
53 | expect(() => new Route('/', '', [], () => {}, 'a')).to.throw(
54 | `Route handler \`onLeave\` should be a function, string given.`
55 | );
56 | });
57 | });
58 |
59 | describe('#match()', () => {
60 | it('rejects if async route definition does not return Promise or array', (done) => {
61 | const steps = [true, 1, 'a', null];
62 |
63 | const stepper = (step, doneFn) => {
64 | if (step === steps.length) {
65 | return doneFn();
66 | }
67 |
68 | const asyncRoutes = () => steps[step];
69 | const route = new Route('/', '', asyncRoutes, () => {}, () => {});
70 |
71 | route.match('/test').then(
72 | doneFn.bind(this, Error('Route should reject for value of type ' + typeof steps[step])),
73 | () => stepper(step + 1, doneFn)
74 | );
75 | };
76 |
77 | stepper(0, done);
78 | });
79 |
80 | it('rejects if route contains multiple variables of the same name', () => {
81 | const asyncRoutes = () => {
82 | return Promise.resolve([
83 | {
84 | path: '/:variable',
85 | component: 'b'
86 | }
87 | ]);
88 | };
89 | const route = new Route('/:variable', '', asyncRoutes, () => {}, () => {}, 'a');
90 |
91 | return route.match('/test/test').catch((err) => {
92 | expect(err).to.be.eql(Error('Route parameter `variable` is already defined.'));
93 | });
94 | });
95 |
96 | it('resolves simple route without children', () => {
97 | const onEnter = () => {};
98 | const onLeave = () => {};
99 | const eagerlyMatchedRoute = new Route('/', '', [], onEnter, onLeave, 'a');
100 |
101 | return eagerlyMatchedRoute.match('/').then(
102 | (match) => {
103 | expect(match).be.an('object');
104 | expect(match)
105 | .to.have.property('pathname')
106 | .and.to.be.equal('/');
107 | expect(match)
108 | .to.have.property('fullPath')
109 | .and.to.be.equal('/');
110 | expect(match)
111 | .to.have.property('vars')
112 | .and.to.be.deep.equal({});
113 | expect(match)
114 | .to.have.property('onEnter')
115 | .and.to.be.an('array')
116 | .and.to.be.deep.equal([onEnter]);
117 | expect(match)
118 | .to.have.property('onLeave')
119 | .and.to.be.an('array')
120 | .and.to.be.deep.equal([onLeave]);
121 | expect(match)
122 | .to.have.property('components')
123 | .and.to.be.an('array')
124 | .and.to.be.deep.equal(['a']);
125 | expect(match)
126 | .to.have.property('attrs')
127 | .and.to.be.an('object')
128 | .and.to.be.deep.equal({});
129 | }
130 | );
131 | });
132 |
133 | it('resolves complex route with multiple variables', () => {
134 | const onEnter = () => {};
135 | const onLeave = () => {};
136 | const eagerlyMatchedRoute = new Route('/:from-:to', '', [], onEnter, onLeave, 'a');
137 |
138 | return eagerlyMatchedRoute.match('/10-11').then(
139 | (match) => {
140 | expect(match).be.an('object');
141 | expect(match)
142 | .to.have.property('vars')
143 | .and.to.be.deep.equal({
144 | from: '10',
145 | to: '11'
146 | });
147 | expect(match)
148 | .to.have.property('pathname')
149 | .and.to.be.equal('/10-11');
150 | expect(match)
151 | .to.have.property('fullPath')
152 | .and.to.be.equal('/10-11');
153 | expect(match)
154 | .to.have.property('onEnter')
155 | .and.to.be.an('array')
156 | .and.to.be.deep.equal([onEnter]);
157 | expect(match)
158 | .to.have.property('onLeave')
159 | .and.to.be.an('array')
160 | .and.to.be.deep.equal([onLeave]);
161 | expect(match)
162 | .to.have.property('components')
163 | .and.to.be.an('array')
164 | .and.to.be.deep.equal(['a']);
165 | expect(match)
166 | .to.have.property('attrs')
167 | .and.to.be.an('object')
168 | .and.to.be.deep.equal({});
169 | }
170 | );
171 | });
172 |
173 | it('resolves complex route with multiple variables (patterns)', () => {
174 | const onEnter = () => {};
175 | const onLeave = () => {};
176 | const eagerlyMatchedRoute = new Route('/:from{[0-9]+}-:to{[a-z]+}', '', [], onEnter, onLeave, 'a', { a: true });
177 |
178 | return eagerlyMatchedRoute.match('/10-a').then(
179 | (match) => {
180 | expect(match).be.an('object');
181 | expect(match)
182 | .to.have.property('vars')
183 | .and.to.be.deep.equal({
184 | from: '10',
185 | to: 'a'
186 | });
187 | expect(match)
188 | .to.have.property('pathname')
189 | .and.to.be.equal('/10-a');
190 | expect(match)
191 | .to.have.property('fullPath')
192 | .and.to.be.equal('/10-a');
193 | expect(match)
194 | .to.have.property('onEnter')
195 | .and.to.be.an('array')
196 | .and.to.be.deep.equal([onEnter]);
197 | expect(match)
198 | .to.have.property('onLeave')
199 | .and.to.be.an('array')
200 | .and.to.be.deep.equal([onLeave]);
201 | expect(match)
202 | .to.have.property('components')
203 | .and.to.be.an('array')
204 | .and.to.be.deep.equal(['a']);
205 | expect(match)
206 | .to.have.property('attrs')
207 | .and.to.be.an('object')
208 | .and.to.be.deep.equal({ a: true });
209 | }
210 | );
211 | });
212 |
213 | it('resolves complex route with complex children', () => {
214 | const onEnter = () => {};
215 | const onLeave = () => {};
216 | const children = [
217 | {
218 | path: '/detail/:id{[a-zA-Z0-9]+}-:slug',
219 | component: 'b',
220 | onEnter,
221 | onLeave,
222 | attrs: {
223 | a: false,
224 | b: true
225 | }
226 | }
227 | ];
228 | const eagerlyMatchedRoute = new Route('/:lang{(en|de)}', '', children, onEnter, onLeave, 'a', { a: true });
229 |
230 | return eagerlyMatchedRoute.match('/en/detail/565ee0d31709ae7b174eb8a1-test').then(
231 | (match) => {
232 | expect(match).be.an('object');
233 | expect(match)
234 | .to.have.property('vars')
235 | .and.to.be.deep.equal({
236 | lang: 'en',
237 | id: '565ee0d31709ae7b174eb8a1',
238 | slug: 'test'
239 | });
240 | expect(match)
241 | .to.have.property('pathname')
242 | .and.to.be.equal('/en/detail/565ee0d31709ae7b174eb8a1-test');
243 | expect(match)
244 | .to.have.property('fullPath')
245 | .and.to.be.equal('/en/detail/565ee0d31709ae7b174eb8a1-test');
246 | expect(match)
247 | .to.have.property('onEnter')
248 | .and.to.be.an('array')
249 | .and.to.be.deep.equal([onEnter, onEnter]);
250 | expect(match)
251 | .to.have.property('onLeave')
252 | .and.to.be.an('array')
253 | .and.to.be.deep.equal([onLeave, onLeave]);
254 | expect(match)
255 | .to.have.property('components')
256 | .and.to.be.an('array')
257 | .and.to.be.deep.equal(['a', 'b']);
258 | expect(match)
259 | .to.have.property('attrs')
260 | .and.to.be.an('object')
261 | .and.to.be.deep.equal({ a: false, b: true });
262 | }
263 | );
264 | });
265 |
266 | it('resolves route with async children', () => {
267 | const asyncRoutes = () => {
268 | return Promise.resolve([
269 | {
270 | path: '/',
271 | component: 'b'
272 | }
273 | ]);
274 | };
275 | const route = new Route('/', '', asyncRoutes, () => {}, () => {}, 'a');
276 |
277 | return route.match('/').then(
278 | (match) => {
279 | expect(match).be.an('object');
280 | expect(match)
281 | .to.have.property('pathname')
282 | .and.to.be.equal('/');
283 | expect(match)
284 | .to.have.property('fullPath')
285 | .and.to.be.equal('/');
286 | expect(match)
287 | .to.have.property('vars')
288 | .and.to.be.deep.equal({});
289 | expect(match)
290 | .to.have.property('onEnter')
291 | .and.to.be.an('array')
292 | .and.have.length(2);
293 | expect(match)
294 | .to.have.property('onLeave')
295 | .and.to.be.an('array')
296 | .and.have.length(2);
297 | expect(match)
298 | .to.have.property('components')
299 | .and.to.be.an('array')
300 | .and.to.be.deep.equal(['a', 'b']);
301 | expect(match)
302 | .to.have.property('attrs')
303 | .and.to.be.an('object')
304 | .and.to.be.deep.equal({});
305 | }
306 | );
307 | });
308 |
309 | it('resolves route with sync children', () => {
310 | const routes = [
311 | {
312 | path: '/',
313 | component: 'b'
314 | }
315 | ];
316 | const route = new Route('/', '', routes, () => {}, () => {}, 'a');
317 |
318 | return route.match('/').then(
319 | (match) => {
320 | expect(match).be.an('object');
321 | expect(match)
322 | .to.have.property('pathname')
323 | .and.to.be.equal('/');
324 | expect(match)
325 | .to.have.property('fullPath')
326 | .and.to.be.equal('/');
327 | expect(match)
328 | .to.have.property('vars')
329 | .and.to.be.deep.equal({});
330 | expect(match)
331 | .to.have.property('onEnter')
332 | .and.to.be.an('array')
333 | .and.have.length(2);
334 | expect(match)
335 | .to.have.property('onLeave')
336 | .and.to.be.an('array')
337 | .and.have.length(2);
338 | expect(match)
339 | .to.have.property('components')
340 | .and.to.be.an('array')
341 | .and.to.be.deep.equal(['a', 'b']);
342 | expect(match)
343 | .to.have.property('attrs')
344 | .and.to.be.an('object')
345 | .and.to.be.deep.equal({});
346 | }
347 | );
348 | });
349 |
350 | it('matches route with query params', () => {
351 | const routes = [
352 | {
353 | path: '/',
354 | component: 'b'
355 | }
356 | ];
357 | const route = new Route('/', '', routes, () => {}, () => {}, 'a');
358 |
359 | return route.match('/', { a: 1, b: 2 }).then(
360 | (match) => {
361 | expect(match).be.an('object');
362 | expect(match)
363 | .to.have.property('pathname')
364 | .and.to.be.equal('/');
365 | expect(match)
366 | .to.have.property('fullPath')
367 | .and.to.be.equal('/?a=1&b=2');
368 | expect(match)
369 | .to.have.property('vars')
370 | .and.to.be.deep.equal({});
371 | expect(match)
372 | .to.have.property('onEnter')
373 | .and.to.be.an('array')
374 | .and.have.length(2);
375 | expect(match)
376 | .to.have.property('onLeave')
377 | .and.to.be.an('array')
378 | .and.have.length(2);
379 | expect(match)
380 | .to.have.property('components')
381 | .and.to.be.an('array')
382 | .and.to.be.deep.equal(['a', 'b']);
383 | expect(match)
384 | .to.have.property('attrs')
385 | .and.to.be.an('object')
386 | .and.to.be.deep.equal({});
387 | }
388 | );
389 | });
390 |
391 | it('asynchronously tries to match a route and rejects with an error if not found', (done) => {
392 | const asyncRoutes = () => {
393 | return Promise.resolve([
394 | {
395 | path: '/',
396 | component: 'b'
397 | }
398 | ]);
399 | };
400 | const route = new Route('/', '', asyncRoutes, () => {}, () => {}, 'a');
401 |
402 | route.match('/test').then(
403 | () => done(Error('Should not found')),
404 | () => done()
405 | );
406 | });
407 | });
408 | });
409 |
--------------------------------------------------------------------------------
/test/Router.spec.js:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 | import { spy, stub } from 'sinon';
3 | import { Component } from 'react';
4 | import Router from '../src/Router';
5 | import { createMemoryHistory } from 'history';
6 | import { RouteNotFoundError } from '../src/errors';
7 |
8 | describe('Router', () => {
9 | describe('#constructor()', () => {
10 | it('throws if routes are not an array', () => {
11 | [1, true, 1.0, Date()].forEach((routes) => {
12 | expect(
13 | () => new Router(routes)
14 | ).to.throw(
15 | `Routes should be an array, ${typeof routes} given.`
16 | );
17 | });
18 | });
19 |
20 | it('throws if onTransition is not an function or undefined', () => {
21 | [1, true, 1.0, Date()].forEach((callback) => {
22 | expect(
23 | () => new Router([], createMemoryHistory(), callback)
24 | ).to.throw(
25 | `Router onTransition callback should be a function, ${typeof callback} given.`
26 | );
27 | });
28 | });
29 | });
30 |
31 | describe('#listen()', () => {
32 | it(
33 | 'starts listening to pop state events and replaces state on initial and replaces state if undefined',
34 | (done) => {
35 | const changeStart = spy();
36 | const changeSuccess = spy();
37 | const onEnter = spy();
38 | const history = createMemoryHistory();
39 |
40 | const router = new Router(
41 | [{ path: '/', component: 'A', onEnter }],
42 | history,
43 | (err, resolvedRoute) => {
44 | try {
45 | expect(err).to.be.equal(null);
46 | expect(resolvedRoute).to.be.an('object');
47 | expect(resolvedRoute.pathname).to.be.equal('/');
48 | expect(resolvedRoute.fullPath).to.be.equal('/');
49 | expect(resolvedRoute.components).to.be.eql(['A']);
50 | expect(resolvedRoute.attrs).to.be.eql({});
51 |
52 | expect(history.replaceState.calledOnce).to.be.equal(true);
53 | expect(history.replaceState.getCall(0).args[0]).to.be.equal(resolvedRoute);
54 | expect(history.replaceState.getCall(0).args[1]).to.be.equal('/');
55 |
56 | expect(changeStart.called).to.be.equal(true);
57 | expect(changeSuccess.calledOnce).to.be.equal(true);
58 | expect(onEnter.called).to.be.equal(true);
59 |
60 | done();
61 | } catch (e) {
62 | done(e);
63 | }
64 | }
65 | );
66 |
67 | spy(history, 'replaceState');
68 |
69 | router.addChangeStartListener(changeStart);
70 | router.addChangeSuccessListener(changeSuccess);
71 |
72 | router.listen();
73 | }
74 | );
75 |
76 | it(
77 | 'starts listening to pop state events and calls not found listeners if current location is not mapped to route',
78 | (done) => {
79 | const changeStart = spy();
80 | const changeSuccess = spy();
81 | const notFound = spy();
82 | const history = createMemoryHistory([{ pathname: '/unknown', search: '?a=1&b=0' }]);
83 |
84 | const router = new Router(
85 | [{ path: '/', component: 'A' }],
86 | history,
87 | (err, resolvedRoute) => {
88 | try {
89 | expect(err).not.to.be.equal(null);
90 | expect(resolvedRoute).to.be.equal(undefined);
91 |
92 | expect(history.replaceState.called).to.be.equal(false);
93 |
94 | expect(changeStart.called).to.be.equal(false);
95 | expect(changeSuccess.called).to.be.equal(false);
96 | expect(notFound.calledOnce).to.be.equal(true);
97 | expect(notFound.getCall(0).args[0]).to.be.equal('/unknown');
98 | expect(notFound.getCall(0).args[1]).to.deep.equal({ a: '1', b: '0' });
99 |
100 | done();
101 | } catch (e) {
102 | done(e);
103 | }
104 | }
105 | );
106 |
107 | spy(history, 'replaceState');
108 |
109 | router.addChangeStartListener(changeStart);
110 | router.addChangeSuccessListener(changeSuccess);
111 | router.addNotFoundListener(notFound);
112 |
113 | router.listen();
114 | }
115 | );
116 | });
117 |
118 | describe('#run()', () => {
119 | it('resolves simple route with sync children and calls all callbacks', () => {
120 | const onTransition = spy();
121 | let history;
122 |
123 | const router = new Router(
124 | [
125 | {
126 | path: '/',
127 | component: 'a',
128 | children: () => Promise.resolve([{ path: 'test', component: 'b' }])
129 | }
130 | ],
131 | history = createMemoryHistory(),
132 | onTransition
133 | );
134 |
135 | spy(history, 'pushState');
136 |
137 | const changeStart = spy();
138 | const changeSuccess = spy();
139 |
140 | router.addChangeStartListener(changeStart);
141 | router.addChangeSuccessListener(changeSuccess);
142 |
143 | return router.run('/test', { a: 1, b: 0 }).then(
144 | (resolvedRoute) => {
145 | expect(resolvedRoute).to.be.an('object');
146 | expect(resolvedRoute).to.have.property('pathname').and.be.equal('/test');
147 | expect(resolvedRoute).to.have.property('components').and.be.deep.equal(['a', 'b']);
148 | expect(resolvedRoute).to.have.property('vars').and.be.deep.equal({});
149 | expect(resolvedRoute).to.have.property('query').and.be.deep.equal({ a: 1, b: 0 });
150 | expect(resolvedRoute).to.have.property('attrs').and.be.deep.equal({});
151 | expect(router.currentRoute()).to.be.an('object');
152 | expect(history.pushState.calledOnce).to.be.equal(true);
153 | expect(history.pushState.getCall(0).args[0]).to.be.equal(resolvedRoute);
154 | expect(history.pushState.getCall(0).args[1]).to.be.equal('/test?a=1&b=0');
155 | expect(changeStart.calledOnce).to.be.equal(true);
156 | expect(changeSuccess.calledOnce).to.be.equal(true);
157 | expect(onTransition.calledOnce).to.be.equal(true);
158 | }
159 | );
160 | });
161 |
162 | it('resolves route components asynchronously', () => {
163 | const onTransition = spy();
164 | let history;
165 |
166 | class App extends Component {}
167 |
168 | const router = new Router(
169 | [
170 | {
171 | path: '/',
172 | component: App,
173 | attrs: {
174 | overridden: false
175 | },
176 | children: () => Promise.resolve([
177 | {
178 | path: 'test',
179 | component: () => Promise.resolve(App)
180 | }
181 | ])
182 | }
183 | ],
184 | history = createMemoryHistory(),
185 | onTransition
186 | );
187 |
188 | stub(history);
189 |
190 | const changeStart = spy();
191 | const changeSuccess = spy();
192 |
193 | router.addChangeStartListener(changeStart);
194 | router.addChangeSuccessListener(changeSuccess);
195 |
196 | return router.run('/test').then(
197 | (resolvedRoute) => {
198 | expect(resolvedRoute).to.be.an('object');
199 | expect(resolvedRoute).to.have.property('pathname').and.be.equal('/test');
200 | expect(resolvedRoute).to.have.property('components').and.be.deep.equal([App, App]);
201 | expect(resolvedRoute).to.have.property('vars').and.be.deep.equal({});
202 | expect(resolvedRoute).to.have.property('query').and.be.deep.equal({});
203 | expect(resolvedRoute).to.have.property('attrs').and.be.deep.equal({ overridden: false });
204 | expect(router.currentRoute()).to.be.an('object');
205 | expect(history.pushState.calledOnce).to.be.equal(true);
206 | expect(changeStart.calledOnce).to.be.equal(true);
207 | expect(changeSuccess.calledOnce).to.be.equal(true);
208 | expect(onTransition.calledOnce).to.be.equal(true);
209 | }
210 | );
211 | });
212 |
213 | it('resolves a route with variables and calls all callbacks', () => {
214 | const onTransition = spy();
215 | let history;
216 |
217 | const router = new Router(
218 | [
219 | {
220 | path: '/',
221 | component: 'a',
222 | attrs: {
223 | override: true
224 | },
225 | children: () => Promise.resolve([{ path: 'test/:variable', component: 'b', attrs: { override: false } }])
226 | }
227 | ],
228 | history = createMemoryHistory(),
229 | onTransition
230 | );
231 |
232 | const changeStart = spy();
233 | const changeSuccess = spy();
234 |
235 | stub(history);
236 |
237 | router.addChangeStartListener(changeStart);
238 | router.addChangeSuccessListener(changeSuccess);
239 |
240 | return router.run('/test/10').then(
241 | (resolvedRoute) => {
242 | expect(resolvedRoute).to.be.an('object');
243 | expect(resolvedRoute).to.have.property('pathname').and.be.equal('/test/10');
244 | expect(resolvedRoute).to.have.property('components').and.be.deep.equal(['a', 'b']);
245 | expect(resolvedRoute).to.have.property('vars').and.be.deep.equal({
246 | variable: '10'
247 | });
248 | expect(resolvedRoute).to.have.property('query').and.be.deep.equal({});
249 | expect(resolvedRoute).to.have.property('attrs').and.be.deep.equal({ override: false });
250 | expect(router.currentRoute()).to.be.an('object');
251 | expect(history.pushState.calledOnce).to.be.equal(true);
252 | expect(changeStart.calledOnce).to.be.equal(true);
253 | expect(changeSuccess.calledOnce).to.be.equal(true);
254 | expect(onTransition.calledOnce).to.be.equal(true);
255 | }
256 | );
257 | });
258 |
259 | it('rejects if route is not found and calls callbacks', () => {
260 | const onTransition = spy();
261 | let history;
262 |
263 | const router = new Router(
264 | [
265 | {
266 | path: '/',
267 | component: 'a',
268 | children: () => Promise.resolve([{ path: 'test/:variable{\\d+}', component: 'b' }])
269 | }
270 | ],
271 | history = createMemoryHistory(),
272 | onTransition
273 | );
274 |
275 | const changeStart = spy();
276 | const changeFail = spy();
277 | const notFound = spy();
278 |
279 | stub(history);
280 |
281 | router.addChangeStartListener(changeStart);
282 | router.addChangeFailListener(changeFail);
283 | router.addNotFoundListener(notFound);
284 |
285 | return router.run('/test/abcd').catch(
286 | (err) => {
287 | expect(changeStart.called).to.be.equal(false);
288 | expect(changeFail.called).to.be.equal(false);
289 | expect(notFound.called).to.be.equal(true);
290 | expect(err).to.be.instanceof(RouteNotFoundError);
291 | expect(router.currentRoute()).to.be.equal(null);
292 | }
293 | );
294 | });
295 |
296 | it('resolves simple route and calls pushState on current and subsequent runs', () => {
297 | const onTransition = spy();
298 | let history;
299 |
300 | const router = new Router(
301 | [
302 | {
303 | path: '/',
304 | component: 'a',
305 | children: () => Promise.resolve([
306 | { path: '', component: 'b' },
307 | { path: 'test', component: 'c' }
308 | ])
309 | }
310 | ],
311 | history = createMemoryHistory(),
312 | onTransition
313 | );
314 |
315 | const changeStart = spy();
316 | const changeSuccess = spy();
317 |
318 | spy(history, 'pushState');
319 |
320 | router.addChangeStartListener(changeStart);
321 | router.addChangeSuccessListener(changeSuccess);
322 |
323 | return router.run('/').then(
324 | (resolvedRoute) => {
325 | expect(resolvedRoute).to.be.an('object');
326 | expect(resolvedRoute).to.have.property('pathname').and.be.equal('/');
327 | expect(resolvedRoute).to.have.property('components').and.be.deep.equal(['a', 'b']);
328 | expect(resolvedRoute).to.have.property('vars').and.be.deep.equal({});
329 | expect(resolvedRoute).to.have.property('query').and.be.deep.equal({});
330 | expect(resolvedRoute).to.have.property('attrs').and.be.deep.equal({});
331 | expect(router.currentRoute()).to.be.equal(resolvedRoute);
332 |
333 | expect(history.pushState.calledOnce).to.be.equal(true);
334 | expect(changeStart.calledOnce).to.be.equal(true);
335 | expect(changeSuccess.calledOnce).to.be.equal(true);
336 | expect(onTransition.calledOnce).to.be.equal(true);
337 |
338 | return router.run('/test').then(
339 | (_resolvedRoute) => {
340 | expect(_resolvedRoute).to.be.an('object');
341 | expect(_resolvedRoute).to.have.property('pathname').and.be.equal('/test');
342 | expect(_resolvedRoute).to.have.property('components').and.be.deep.equal(['a', 'c']);
343 | expect(_resolvedRoute).to.have.property('vars').and.be.deep.equal({});
344 | expect(_resolvedRoute).to.have.property('query').and.be.deep.equal({});
345 | expect(_resolvedRoute).to.have.property('attrs').and.be.deep.equal({});
346 | expect(router.currentRoute()).to.be.equal(_resolvedRoute);
347 |
348 | expect(history.pushState.calledTwice).to.be.equal(true);
349 | expect(changeStart.calledTwice).to.be.equal(true);
350 | expect(changeSuccess.calledTwice).to.be.equal(true);
351 | expect(onTransition.calledTwice).to.be.equal(true);
352 | }
353 | );
354 | }
355 | );
356 | });
357 |
358 | it('rejects not found route (and if has previous state, calls fail callback)', () => {
359 | const onTransition = spy();
360 | let history;
361 |
362 | const router = new Router(
363 | [
364 | {
365 | path: '/',
366 | component: 'a',
367 | children: () => Promise.resolve([
368 | { path: '', component: 'b' },
369 | { path: 'test', component: 'c' }
370 | ])
371 | }
372 | ],
373 | history = createMemoryHistory(),
374 | onTransition
375 | );
376 |
377 | const changeStart = spy();
378 | const changeSuccess = spy();
379 | const changeFail = spy();
380 |
381 | spy(history, 'pushState');
382 |
383 | router.addChangeStartListener(changeStart);
384 | router.addChangeSuccessListener(changeSuccess);
385 | router.addChangeFailListener(changeFail);
386 |
387 | return router.run('/').then(
388 | (resolvedRoute) => {
389 | expect(resolvedRoute).to.be.an('object');
390 | expect(resolvedRoute).to.have.property('pathname').and.be.equal('/');
391 | expect(resolvedRoute).to.have.property('components').and.be.deep.equal(['a', 'b']);
392 | expect(resolvedRoute).to.have.property('vars').and.be.deep.equal({});
393 | expect(resolvedRoute).to.have.property('attrs').and.be.deep.equal({});
394 | expect(router.currentRoute()).to.be.an('object');
395 | expect(router.currentRoute()).to.have.property('pathname').and.be.equal('/');
396 | expect(router.currentRoute()).to.have.property('components').and.be.deep.equal(['a', 'b']);
397 | expect(router.currentRoute()).to.have.property('vars').and.be.deep.equal({});
398 | expect(router.currentRoute()).to.have.property('attrs').and.be.deep.equal({});
399 | expect(history.pushState.calledOnce).to.be.equal(true);
400 | expect(changeStart.calledOnce).to.be.equal(true);
401 | expect(changeSuccess.calledOnce).to.be.equal(true);
402 | expect(changeFail.called).to.be.equal(false);
403 | expect(onTransition.calledOnce).to.be.equal(true);
404 |
405 | return router.run('/lalala').catch(
406 | (err) => {
407 | expect(router.currentRoute()).to.be.deep.equal(resolvedRoute);
408 | expect(err).to.be.instanceof(RouteNotFoundError);
409 |
410 | // change listeners should not be called at alle
411 | // because they are called only if route matches
412 | expect(changeStart.calledOnce).to.be.equal(true);
413 | expect(changeFail.called).to.be.equal(false);
414 | expect(changeSuccess.calledOnce).to.be.equal(true);
415 |
416 | // this is called everytime routes finishes
417 | expect(onTransition.calledTwice).to.be.equal(true);
418 |
419 | // we don't expect to change state of history
420 | // because we want user to do something about not found event
421 | expect(history.pushState.calledOnce).to.be.equal(true);
422 | }
423 | );
424 | }
425 | );
426 | });
427 |
428 | it('calls onEnter on route with current route and resolving route', () => {
429 | const onTransition = spy();
430 |
431 | const router = new Router(
432 | [
433 | {
434 | path: 'a',
435 | component: 'dashboard',
436 | children: [
437 | {
438 | path: 'b',
439 | component: 'newmessage'
440 | }
441 | ]
442 | },
443 | {
444 | path: '',
445 | component: 'login'
446 | },
447 | {
448 | path: 'registration',
449 | component: 'registration'
450 | }
451 | ],
452 | createMemoryHistory(),
453 | onTransition
454 | );
455 |
456 | const changeStart = spy();
457 |
458 | router.addChangeStartListener(changeStart);
459 |
460 | expect(router.currentRoute()).to.be.equal(null);
461 |
462 | return router.run('/').then(
463 | (resolvedRoute) => {
464 | expect(changeStart.calledOnce).to.be.equal(true);
465 | expect(router.currentRoute()).not.to.be.equal(null);
466 | expect(resolvedRoute).to.be.equal(router.currentRoute());
467 | const previousRoute = router.currentRoute();
468 |
469 | return router.run('/a/b', { a: 1 }).then(
470 | (newRoute) => {
471 | expect(changeStart.calledTwice).to.be.equal(true);
472 | expect(router.currentRoute()).not.to.be.equal(previousRoute);
473 | expect(newRoute).not.to.be.equal(previousRoute);
474 | expect(newRoute.fullPath).to.be.equal('/a/b?a=1');
475 | expect(router.currentRoute()).to.be.equal(newRoute);
476 | expect(changeStart.getCall(1).args[0]).to.be.deep.equal(previousRoute);
477 | expect(changeStart.getCall(1).args[1]).to.be.deep.equal(newRoute);
478 | }
479 | );
480 | }
481 | );
482 | });
483 |
484 | it('resolves onEnter handlers in order', () => {
485 | const onTransition = spy();
486 | const onEnter1 = spy(() => new Promise(resolve => {
487 | setTimeout(resolve, 150);
488 | }));
489 | const onEnter2 = spy(() => Promise.resolve());
490 |
491 | const router = new Router(
492 | [
493 | {
494 | path: 'a',
495 | component: 'dashboard',
496 | onEnter: onEnter1,
497 | children: [
498 | {
499 | path: 'b',
500 | component: 'newmessage',
501 | onEnter: onEnter2
502 | }
503 | ]
504 | }
505 | ],
506 | createMemoryHistory(),
507 | onTransition
508 | );
509 |
510 | return router.run('/a/b').then(
511 | (resolvedRoute) => {
512 | expect(router.currentRoute()).not.to.be.equal(null);
513 | expect(resolvedRoute).to.be.equal(router.currentRoute());
514 | expect(onEnter1.calledBefore(onEnter2)).to.be.equal(true);
515 | }
516 | );
517 | });
518 |
519 | it('resolves onLeave handlers in order', () => {
520 | const onTransition = spy();
521 | const onLeave1 = spy(() => new Promise(resolve => {
522 | setTimeout(resolve, 150);
523 | }));
524 | const onLeave2 = spy(() => Promise.resolve());
525 |
526 | const router = new Router(
527 | [
528 | {
529 | path: '/',
530 | component: 'a'
531 | },
532 | {
533 | path: 'a',
534 | component: 'dashboard',
535 | onLeave: onLeave1,
536 | children: [
537 | {
538 | path: 'b',
539 | component: 'newmessage',
540 | onLeave: onLeave2
541 | }
542 | ]
543 | }
544 | ],
545 | createMemoryHistory(),
546 | onTransition
547 | );
548 |
549 | return router.run('/a/b').then(
550 | () => {
551 | return router.run('/').then(() => {
552 | expect(onLeave2.calledBefore(onLeave1)).to.be.equal(true);
553 | });
554 | }
555 | );
556 | });
557 | });
558 |
559 | describe('handler wrapping', () => {
560 | function createRouterForWrappers() {
561 | const onAEnterSpy = spy();
562 | const onBLeaveSpy = spy();
563 |
564 | const router = new Router([
565 | { path: '/', component: 'A', onEnter: onAEnterSpy },
566 | { path: '/test', component: 'B', onLeave: onBLeaveSpy }
567 | ], createMemoryHistory());
568 |
569 | return {
570 | router,
571 | onAEnterSpy,
572 | onBLeaveSpy
573 | };
574 | }
575 |
576 | describe('#wrapOnEnterHandler()', () => {
577 | it('wraps route onEnter handler with provided function', () => {
578 | const { router, onAEnterSpy } = createRouterForWrappers();
579 |
580 | const onEnterSpy = spy((onEnter) => {
581 | return onEnter('a', 'b', 'c');
582 | });
583 |
584 | router.wrapOnEnterHandler(onEnterSpy);
585 |
586 | return router.run('/', {}).then(
587 | () => {
588 | expect(onEnterSpy.calledOnce).to.be.equal(true);
589 | expect(onAEnterSpy.calledOnce).to.be.equal(true);
590 |
591 | const call = onAEnterSpy.getCall(0);
592 | const [previous, current, _router, ...rest] = call.args;
593 |
594 | expect(call.args).to.have.length(6);
595 | expect(previous).to.be.equal(null); // previous route
596 | expect(current).to.be.an('object').with.property('pathname').equal('/'); // current route
597 | expect(_router).to.be.equal(router);
598 | expect(rest).to.be.eql(['a', 'b', 'c']);
599 | }
600 | );
601 | });
602 | });
603 |
604 | describe('#wrapOnLeaveHandler()', () => {
605 | it('wraps route onEnter handler with provided function', () => {
606 | const { router, onBLeaveSpy } = createRouterForWrappers();
607 |
608 | const onLeaveSpy = spy((onLeave) => {
609 | return onLeave('a', 'b', 'c');
610 | });
611 |
612 | router.wrapOnLeaveHandler(onLeaveSpy);
613 |
614 | return router.run('/test', {}).then(
615 | () => {
616 | return router.run('/', {}).then(
617 | () => {
618 | expect(onLeaveSpy.calledOnce).to.be.equal(true);
619 | expect(onBLeaveSpy.calledOnce).to.be.equal(true);
620 |
621 | const call = onBLeaveSpy.getCall(0);
622 | const [resolved, _router, ...rest] = call.args;
623 |
624 | expect(call.args).to.have.length(5);
625 | expect(resolved).to.be.an('object').with.property('pathname').equal('/'); // current route
626 | expect(_router).to.be.equal(router);
627 | expect(rest).to.be.eql(['a', 'b', 'c']);
628 | }
629 | );
630 | }
631 | );
632 | });
633 | });
634 | });
635 | });
636 |
--------------------------------------------------------------------------------
/test/createRoutex.spec.js:
--------------------------------------------------------------------------------
1 | import createRoutex from '../src/createRoutex';
2 | import { createMemoryHistory } from 'history';
3 | import { expect } from 'chai';
4 | import Router from '../src/Router';
5 | import { RouteNotFoundError } from '../src/errors';
6 | import { compose, createStore, combineReducers } from 'redux';
7 | import { transitionTo } from '../src/actions';
8 | import { spy } from 'sinon';
9 |
10 | describe('createRoutex()', () => {
11 | let routex;
12 |
13 | beforeEach(() => {
14 | routex = createRoutex([
15 | {
16 | path: '/',
17 | component: 'Index'
18 | },
19 | {
20 | path: '/test',
21 | component: 'Test'
22 | },
23 | {
24 | path: '/rejected',
25 | onEnter: () => Promise.reject()
26 | }
27 | ], createMemoryHistory());
28 | });
29 |
30 | it('exposes public API + router instance', () => {
31 | expect(routex).to.be.an('object');
32 | expect(routex).to.have.property('router').and.to.be.instanceof(Router);
33 | expect(routex).to.have.property('store').and.to.be.a('function');
34 | expect(routex).to.have.property('reducer').and.to.be.a('object');
35 | expect(routex.reducer).to.have.property('router').and.to.be.a('function');
36 | });
37 |
38 | it('exposes redux public API + router instance on redux store', () => {
39 | const store = compose(routex.store)(createStore)(combineReducers(routex.reducer));
40 |
41 | expect(store).to.be.an('object');
42 | expect(store).to.have.keys('dispatch', 'subscribe', 'getState', 'replaceReducer', 'router');
43 | expect(store.router).to.be.instanceof(Router);
44 | });
45 |
46 | it('starts listening to pop state event on initial store creation', () => {
47 | routex.router.listen = spy(routex.router.listen);
48 |
49 | compose(routex.store)(createStore)(combineReducers(routex.reducer));
50 |
51 | expect(routex.router.listen.calledOnce).to.be.equal(true);
52 | });
53 |
54 | it('runs listeners on successful transition dispatch and sets state in reducer', (done) => {
55 | routex.router.run = spy(routex.router.run);
56 |
57 | const store = compose(routex.store)(createStore)(combineReducers(routex.reducer));
58 | const startSpy = spy();
59 | const successSpy = spy();
60 | let indexRoute;
61 |
62 | const unsubscribe = store.router.addChangeSuccessListener((resolvedRoute) => {
63 | indexRoute = resolvedRoute;
64 | unsubscribe(); // unregister previous
65 |
66 | store.router.addChangeStartListener(startSpy);
67 | store.router.addChangeStartListener((currentRoute, nextRoute) => {
68 | try {
69 | expect(store.getState().router.state).to.be.equal('TRANSITIONING');
70 | expect(store.getState().router.route).to.be.equal(indexRoute);
71 | expect(store.getState().router.route).to.be.equal(currentRoute);
72 | expect(store.getState().router.nextRoute).to.be.equal(nextRoute);
73 | expect(currentRoute).to.not.be.equal(nextRoute);
74 | } catch (e) {
75 | done(e);
76 | }
77 | });
78 | store.router.addChangeSuccessListener(successSpy);
79 |
80 | store.dispatch(transitionTo('/test')).then(() => {
81 | try {
82 | expect(startSpy.calledOnce).to.be.equal(true);
83 |
84 | expect(startSpy.getCall(0).args[0]).to.contain.all.keys('pathname', 'components');
85 | expect(startSpy.getCall(0).args[0].pathname).to.be.equal('/');
86 | expect(startSpy.getCall(0).args[0].components).to.be.eql(['Index']);
87 |
88 | expect(startSpy.getCall(0).args[1]).to.contain.all.keys('pathname', 'components');
89 | expect(startSpy.getCall(0).args[1].pathname).to.be.equal('/test');
90 | expect(startSpy.getCall(0).args[1].components).to.be.eql(['Test']);
91 |
92 | expect(successSpy.calledOnce).to.be.equal(true);
93 |
94 | expect(successSpy.getCall(0).args[0]).to.contain.all.keys('pathname', 'components');
95 | expect(successSpy.getCall(0).args[0].pathname).to.be.equal('/test');
96 | expect(successSpy.getCall(0).args[0].components).to.be.eql(['Test']);
97 |
98 | expect(store.getState().router.state).to.be.equal('TRANSITIONED');
99 | expect(store.getState().router.route).to.be.equal(successSpy.getCall(0).args[0]);
100 | expect(store.getState().router).to.not.have.key('nextRoute');
101 |
102 | done();
103 | } catch (e) {
104 | done(e);
105 | }
106 | });
107 | });
108 | });
109 |
110 | it('runs listeners on failed transition dispatch and sets state in reducer', (done) => {
111 | routex.router.run = spy(routex.router.run);
112 |
113 | const store = compose(routex.store)(createStore)(combineReducers(routex.reducer));
114 | const startSpy = spy();
115 | const failSpy = spy();
116 | let indexRoute;
117 |
118 | const unsubscribe = store.router.addChangeSuccessListener((resolvedRoute) => {
119 | indexRoute = resolvedRoute;
120 | unsubscribe(); // unregister previous
121 |
122 | store.router.addChangeStartListener(startSpy);
123 | store.router.addChangeStartListener((currentRoute, nextRoute) => {
124 | try {
125 | expect(store.getState().router.state).to.be.equal('TRANSITIONING');
126 | expect(store.getState().router.route).to.be.equal(indexRoute);
127 | expect(store.getState().router.route).to.be.equal(currentRoute);
128 | expect(store.getState().router.nextRoute).to.be.equal(nextRoute);
129 | expect(currentRoute).to.not.be.equal(nextRoute);
130 | } catch (e) {
131 | done(e);
132 | }
133 | });
134 | store.router.addChangeFailListener(failSpy);
135 |
136 | store.dispatch(transitionTo('/rejected')).catch(() => {
137 | try {
138 | expect(failSpy.calledOnce).to.be.equal(true);
139 |
140 | expect(failSpy.getCall(0).args[1]).to.be.equal(indexRoute);
141 |
142 | expect(store.getState().router.state).to.be.equal('TRANSITIONED');
143 | expect(store.getState().router.route).to.be.equal(failSpy.getCall(0).args[1]);
144 | expect(store.getState().router).to.not.have.key('nextRoute');
145 |
146 | done();
147 | } catch (e) {
148 | done(e);
149 | }
150 | });
151 | });
152 | });
153 |
154 | it('runs only not found listeners on transition dispatch to non existing route', (done) => {
155 | routex.router.run = spy(routex.router.run);
156 |
157 | const store = compose(routex.store)(createStore)(combineReducers(routex.reducer));
158 | const startSpy = spy();
159 | const successSpy = spy();
160 | const failSpy = spy();
161 | const notFoundSpy = spy();
162 |
163 | const unsubscribe = store.router.addChangeSuccessListener((resolvedRoute) => {
164 | unsubscribe(); // unregister previous
165 |
166 | try {
167 | expect(store.getState().router.state).to.be.equal('TRANSITIONED');
168 | expect(store.getState().router.route).to.be.equal(resolvedRoute);
169 | } catch (e) {
170 | done(e);
171 | }
172 |
173 | store.router.addChangeStartListener(startSpy);
174 | store.router.addChangeFailListener(failSpy);
175 | store.router.addChangeSuccessListener(successSpy);
176 | store.router.addNotFoundListener(notFoundSpy);
177 |
178 | store.dispatch(transitionTo('/not-existing')).catch((err) => {
179 | try {
180 | expect(err).to.be.instanceof(RouteNotFoundError);
181 |
182 | expect(startSpy.called).to.be.equal(false);
183 | expect(failSpy.called).to.be.equal(false);
184 | expect(successSpy.called).to.be.equal(false);
185 | expect(notFoundSpy.calledOnce).to.be.equal(true);
186 |
187 | expect(notFoundSpy.getCall(0).args[0]).to.be.equal('/not-existing');
188 | expect(notFoundSpy.getCall(0).args[1]).to.be.eql({});
189 |
190 | // state is untouched
191 | expect(store.getState().router.state).to.be.equal('TRANSITIONED');
192 | expect(store.getState().router.route).to.be.equal(resolvedRoute);
193 |
194 | done();
195 | } catch (e) {
196 | done(e);
197 | }
198 | });
199 | });
200 | });
201 | });
202 |
--------------------------------------------------------------------------------
/test/react/Link.spec.js:
--------------------------------------------------------------------------------
1 | /* eslint func-names:0 */
2 | import { expect } from 'chai';
3 | import jsdom from 'mocha-jsdom';
4 | import { createStore, combineReducers, compose } from 'redux';
5 | import { createMemoryHistory } from 'history';
6 | import createRoutex from '../../src/createRoutex.js';
7 | import React from 'react';
8 | import { Provider } from 'react-redux';
9 | import { Link } from '../../src/react';
10 | import { renderToStaticMarkup } from 'react-dom/server';
11 | import { transitionTo } from '../../src/actions';
12 |
13 | describe('React', () => {
14 | describe('Link', () => {
15 | jsdom();
16 |
17 | let store;
18 |
19 | beforeEach(() => {
20 | const routex = createRoutex([
21 | {
22 | path: '/path/:id',
23 | component: 'b',
24 | children: [
25 | {
26 | path: 'messages',
27 | component: 'a'
28 | }
29 | ]
30 | },
31 | {
32 | path: '/',
33 | component: 'index',
34 | children: [
35 | {
36 | path: '/',
37 | component: 'nested-index'
38 | },
39 | {
40 | path: '/:id',
41 | component: 'nested-var-index'
42 | }
43 | ]
44 | }
45 | ], createMemoryHistory());
46 |
47 | store = compose(
48 | routex.store
49 | )(createStore)(
50 | combineReducers(routex.reducer)
51 | );
52 | });
53 |
54 | it('renders anchor with simple path', function() {
55 | const tree = renderToStaticMarkup(
56 |
57 |
58 |
59 | );
60 |
61 | expect(tree).to.be.equal('');
62 | });
63 |
64 | it('renders anchor with href and query string', function() {
65 | const tree = renderToStaticMarkup(
66 |
67 |
68 |
69 | );
70 |
71 | expect(tree).to.be.equal('');
72 | });
73 |
74 | describe('adds props from stateProps by current state', () => {
75 | it('short route', function(done) {
76 | const stateProps = {
77 | active: { className: 'active' },
78 | inactive: { className: 'inactive' }
79 | };
80 |
81 | store
82 | .dispatch(transitionTo('/path/123'))
83 | .then(
84 | () => {
85 | try {
86 | const tree = renderToStaticMarkup(
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 | );
95 |
96 | expect(tree).to.be.equal(
97 | '' +
98 | '
' +
99 | '
' +
100 | '
' +
101 | '
'
102 | );
103 |
104 | done();
105 | } catch (e) {
106 | done(e);
107 | }
108 | },
109 | () => done(new Error('Route not found'))
110 | );
111 | });
112 |
113 | it('longer route', function(done) {
114 | const stateProps = {
115 | active: { className: 'active' },
116 | inactive: { className: 'inactive' }
117 | };
118 |
119 | store
120 | .dispatch(transitionTo('/path/123/messages'))
121 | .then(
122 | () => {
123 | try {
124 | const tree = renderToStaticMarkup(
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 | );
133 |
134 | expect(tree).to.be.equal(
135 | '' +
136 | '
' +
137 | '
' +
138 | '
' +
139 | '
'
140 | );
141 |
142 | done();
143 | } catch (e) {
144 | done(e);
145 | }
146 | },
147 | () => done(new Error('Route not found'))
148 | );
149 | });
150 |
151 | it('possible conflict in routes', function(done) {
152 | const stateProps = {
153 | active: { className: 'active' },
154 | inactive: { className: 'inactive' }
155 | };
156 |
157 | store
158 | .dispatch(transitionTo('/path/12'))
159 | .then(
160 | () => {
161 | try {
162 | const tree = renderToStaticMarkup(
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 | );
171 |
172 | expect(tree).to.be.equal(
173 | '' +
174 | '
' +
175 | '
' +
176 | '
' +
177 | '
'
178 | );
179 |
180 | done();
181 | } catch (e) {
182 | done(e);
183 | }
184 | },
185 | () => done(new Error('Route not found'))
186 | );
187 | });
188 |
189 | it('nested routes', function(done) {
190 | const stateProps = {
191 | active: { className: 'active' },
192 | inactive: { className: 'inactive' }
193 | };
194 |
195 | store
196 | .dispatch(transitionTo('/haha'))
197 | .then(
198 | () => {
199 | try {
200 | const tree = renderToStaticMarkup(
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 | );
209 |
210 | expect(tree).to.be.equal(
211 | '' +
212 | '
' +
213 | '
' +
214 | '
' +
215 | '
'
216 | );
217 |
218 | done();
219 | } catch (e) {
220 | done(e);
221 | }
222 | },
223 | () => done(new Error('Route not found'))
224 | );
225 | });
226 | });
227 | });
228 | });
229 |
--------------------------------------------------------------------------------
/test/react/View.spec.js:
--------------------------------------------------------------------------------
1 | /* eslint func-names:0, react/prop-types:0, react/no-multi-comp:0 */
2 | global.navigator = {
3 | userAgent: 'node.js'
4 | };
5 |
6 | import { expect } from 'chai';
7 | import { createStore, compose, combineReducers } from 'redux';
8 | import TestUtils from 'react-addons-test-utils';
9 | import jsdom from 'mocha-jsdom';
10 | import { createRoutex, actions } from '../../src';
11 | import { createMemoryHistory } from 'history';
12 | import React, { Component } from 'react';
13 | import ReactDOM from 'react-dom/server';
14 | import { Provider } from 'react-redux';
15 | import { View } from '../../src/react';
16 |
17 | describe('React', () => {
18 | function createRoutexStore(routes, initialState, onTransition) {
19 | const routex = createRoutex(routes, createMemoryHistory('/'), onTransition);
20 |
21 | return compose(routex.store)(createStore)(combineReducers(routex.reducer), initialState);
22 | }
23 |
24 | describe('View', () => {
25 | jsdom();
26 |
27 | class App extends Component {
28 | render() {
29 | return {this.props.children || 'Pom'}
;
30 | }
31 | }
32 |
33 | class Child extends Component {
34 | render() {
35 | return Child
;
36 | }
37 | }
38 |
39 | it('renders matched route on initial load when state is not provided (default state)', function(done) {
40 | const store = createRoutexStore(
41 | [
42 | {
43 | path: '/',
44 | component: App
45 | }
46 | ],
47 | undefined,
48 | (err) => {
49 | if (err) done(err);
50 |
51 | const tree = TestUtils.renderIntoDocument(
52 |
53 |
54 |
55 | );
56 |
57 | TestUtils.findRenderedComponentWithType(tree, App);
58 | done();
59 | });
60 | });
61 |
62 | it('renders matched route on initial load (rehydrated)', function(done) {
63 | const store = createRoutexStore(
64 | [
65 | {
66 | path: '/',
67 | component: App
68 | }
69 | ],
70 | {
71 | router: { state: 'TRANSITIONED', route: { pathname: '/', query: {}, vars: {} }}
72 | },
73 | (err) => {
74 | if (err) done(err);
75 |
76 | const tree = TestUtils.renderIntoDocument(
77 |
78 |
79 |
80 | );
81 |
82 | TestUtils.findRenderedComponentWithType(tree, App);
83 | done();
84 | }
85 | );
86 | });
87 |
88 | it('renders route components on successful transition', function(done) {
89 | let started = false;
90 |
91 | const store = createRoutexStore(
92 | [
93 | {
94 | path: '/',
95 | component: App,
96 | children: [
97 | {
98 | path: '/child',
99 | component: Child
100 | }
101 | ]
102 | }
103 | ],
104 | {
105 | router: { state: 'TRANSITIONED', route: { pathname: '/', query: {}, vars: {} }}
106 | },
107 | () => {
108 | if (started) {
109 | return;
110 | }
111 |
112 | started = true;
113 |
114 | try {
115 | expect(
116 | ReactDOM.renderToString(
117 |
118 |
119 |
120 | )
121 | ).to.match(/Pom/);
122 | } catch (e) {
123 | done(e);
124 | }
125 |
126 | setImmediate(
127 | () => {
128 | store.dispatch(actions.transitionTo('/child')).then(
129 | () => {
130 | try {
131 | expect(store.getState().router.route.pathname).to.be.equal('/child');
132 | expect(
133 | ReactDOM.renderToString(
134 |
135 |
136 |
137 | )
138 | ).to.match(/Child/);
139 | done();
140 | } catch (e) {
141 | done(e);
142 | }
143 | },
144 | done.bind(this, Error('Should transition to /child'))
145 | );
146 | }
147 | );
148 |
149 | }
150 | );
151 | });
152 | });
153 | });
154 |
--------------------------------------------------------------------------------
/test/routex.spec.js:
--------------------------------------------------------------------------------
1 | import { createStore, combineReducers, compose } from 'redux';
2 | import createRoutex from '../src/createRoutex';
3 | import { createMemoryHistory } from 'history';
4 | import * as actions from '../src/actions';
5 | import { spy } from 'sinon';
6 | import { expect } from 'chai';
7 |
8 | describe('routex', () => {
9 | let history;
10 | let store;
11 |
12 | const transitionTo = actions.transitionTo;
13 |
14 | function createRoutexStore(_history, onTransition, initialState) {
15 | const routex = createRoutex(
16 | [
17 | { path: '/', component: 'A' },
18 | { path: '/child', component: 'Child' },
19 | { path: '/rejected-on-enter', onEnter: () => Promise.reject(), component: 'RejectedOnEnter' },
20 | { path: '/rejected-on-leave', onLeave: () => Promise.reject(), component: 'RejectedOnLeave' },
21 | { path: '/with-variables/:user/:id{\\d+}', component: 'WithVariables' }
22 | ],
23 | _history,
24 | onTransition
25 | );
26 |
27 | return compose(routex.store)(createStore)(combineReducers(routex.reducer), initialState);
28 | }
29 |
30 | function stripRouteInfo(route) {
31 | const { pathname, query, vars, components } = route;
32 |
33 | return {
34 | pathname,
35 | query,
36 | vars,
37 | components
38 | };
39 | }
40 |
41 | function stepper(steps, done) {
42 | let currentStep = 0;
43 |
44 | return function nextStep() {
45 | try {
46 | steps[currentStep++]();
47 | } catch (e) {
48 | done(e);
49 | }
50 | };
51 | }
52 |
53 | beforeEach(() => {
54 | history = createMemoryHistory();
55 | });
56 |
57 | it('replaces state in history on initial load if router state is initial', (done) => {
58 | spy(history, 'replaceState');
59 |
60 | const onTransition = spy(() => {
61 | expect(onTransition.called).to.be.equal(true);
62 | expect(history.replaceState.called).to.be.equal(true);
63 | expect(store.getState().router.state).to.be.equal('TRANSITIONED');
64 | expect(stripRouteInfo(store.getState().router.route)).to.be.deep.equal({
65 | pathname: '/',
66 | query: {},
67 | vars: {},
68 | components: ['A']
69 | });
70 |
71 | done();
72 | });
73 | store = createRoutexStore(history, onTransition);
74 | });
75 |
76 | it('replaces state in history on initial load if current state is null (in browser after load)', (done) => {
77 | spy(history, 'replaceState');
78 |
79 | const onTransition = spy(() => {
80 | expect(onTransition.called).to.be.equal(true);
81 | expect(history.replaceState.called).to.be.equal(true);
82 | expect(store.getState().router.state).to.be.equal('TRANSITIONED');
83 | expect(stripRouteInfo(store.getState().router.route)).to.be.deep.equal({
84 | pathname: '/',
85 | query: {},
86 | vars: {},
87 | components: ['A']
88 | });
89 |
90 | done();
91 | });
92 |
93 | store = createRoutexStore(history, onTransition, {
94 | router: {
95 | state: 'TRANSITIONED',
96 | route: {
97 | pathname: '/',
98 | query: {},
99 | vars: {}
100 | }
101 | }
102 | });
103 | });
104 |
105 | it('pushes state to history on successful transition (from known state to another)', (done) => {
106 | let _stepper;
107 | spy(history, 'pushState');
108 |
109 | const steps = [
110 | () => {
111 | expect(store.getState().router.state).to.be.equal('TRANSITIONED');
112 | expect(stripRouteInfo(store.getState().router.route)).to.be.deep.equal({
113 | pathname: '/',
114 | query: {},
115 | vars: {},
116 | components: ['A']
117 | });
118 |
119 | store.dispatch(transitionTo('/child', {}));
120 | },
121 | () => {
122 | expect(_stepper.calledTwice).to.be.equal(true);
123 | expect(history.pushState.called).to.be.equal(true);
124 | expect(stripRouteInfo(store.getState().router.route)).to.deep.equal({
125 | pathname: '/child',
126 | query: {},
127 | vars: {},
128 | components: ['Child']
129 | });
130 |
131 | done();
132 | }
133 | ];
134 |
135 | _stepper = spy(stepper(steps, done));
136 |
137 | store = createRoutexStore(history, _stepper, {
138 | router: {
139 | state: 'TRANSITIONED',
140 | route: {
141 | pathname: '/',
142 | query: {},
143 | vars: {}
144 | }
145 | }
146 | });
147 | });
148 |
149 | it('changes state using change success action if pop state event is emitted', (done) => {
150 | const childState = {
151 | pathname: '/child',
152 | query: {},
153 | vars: {},
154 | components: ['Child']
155 | };
156 |
157 | const indexState = {
158 | pathname: '/',
159 | query: {},
160 | vars: {},
161 | components: ['A']
162 | };
163 |
164 | const steps = [
165 | () => {
166 | expect(store.getState().router.state).to.be.equal('TRANSITIONED');
167 | expect(stripRouteInfo(store.getState().router.route)).to.deep.equal(indexState);
168 |
169 | store.dispatch(transitionTo('/child', {}));
170 | },
171 | () => {
172 | expect(store.getState().router.state).to.be.equal('TRANSITIONED');
173 | expect(stripRouteInfo(store.getState().router.route)).to.deep.equal(childState);
174 |
175 | // call on pop state with state from history and return back
176 | // this dispatches ROUTE_CHANGE_SUCCESS immediately
177 | history.goBack();
178 | },
179 | () => {
180 | expect(store.getState().router.state).to.be.equal('TRANSITIONED');
181 | expect(stripRouteInfo(store.getState().router.route)).to.deep.equal(indexState);
182 |
183 | // go forward
184 | history.goForward();
185 | },
186 | () => {
187 | expect(store.getState().router.state).to.be.equal('TRANSITIONED');
188 | expect(stripRouteInfo(store.getState().router.route)).to.deep.equal(childState);
189 |
190 | done();
191 | }
192 | ];
193 |
194 | store = createRoutexStore(history, stepper(steps, done), {
195 | router: {
196 | state: 'TRANSITIONED',
197 | route: {
198 | pathname: '/',
199 | query: {},
200 | vars: {}
201 | }
202 | }
203 | });
204 | });
205 |
206 | it('cancels transition if one of onEnter handlers rejects', (done) => {
207 | const indexState = {
208 | pathname: '/',
209 | query: {},
210 | vars: {},
211 | components: ['A']
212 | };
213 |
214 | const steps = [
215 | () => {
216 | expect(store.getState().router.state).to.be.equal('TRANSITIONED');
217 | expect(stripRouteInfo(store.getState().router.route)).to.deep.equal(indexState);
218 |
219 | store.dispatch(transitionTo('/rejected-on-enter', {}));
220 | },
221 | () => {
222 | expect(store.getState().router.state).to.be.equal('TRANSITIONED');
223 | expect(store.getState().router.error).to.be.eql(Error('onEnter handlers on route rejected-on-enter are not resolved.'));
224 | expect(stripRouteInfo(store.getState().router.route)).to.deep.equal(indexState);
225 |
226 | done();
227 | }
228 | ];
229 |
230 | const _stepper = stepper(steps, done);
231 |
232 | store = createRoutexStore(createMemoryHistory(), _stepper, {
233 | router: {
234 | state: 'TRANSITIONED',
235 | route: {
236 | pathname: '/',
237 | query: {},
238 | vars: {}
239 | }
240 | }
241 | });
242 | });
243 |
244 | it('cancels transition if one of onLeave handlers rejects', (done) => {
245 | const indexState = {
246 | pathname: '/rejected-on-leave',
247 | query: {},
248 | vars: {},
249 | components: ['RejectedOnLeave']
250 | };
251 |
252 | const steps = [
253 | () => {
254 | expect(store.getState().router.state).to.be.equal('TRANSITIONED');
255 | expect(stripRouteInfo(store.getState().router.route)).to.deep.equal(indexState);
256 |
257 | store.dispatch(transitionTo('/', {}));
258 | },
259 | () => {
260 | expect(store.getState().router.state).to.be.equal('TRANSITIONED');
261 | expect(store.getState().router.error).to.be.eql(Error('onLeave handlers on route rejected-on-leave are not resolved.'));
262 | expect(stripRouteInfo(store.getState().router.route)).to.deep.equal(indexState);
263 |
264 | done();
265 | }
266 | ];
267 |
268 | const _stepper = stepper(steps, done);
269 |
270 | store = createRoutexStore(createMemoryHistory(['/rejected-on-leave']), _stepper, {
271 | router: {
272 | state: 'TRANSITIONED',
273 | route: {
274 | pathname: '/rejected-on-leave',
275 | query: {},
276 | vars: {}
277 | }
278 | }
279 | });
280 | });
281 |
282 | });
283 |
--------------------------------------------------------------------------------
/test/utils/urlUtils.spec.js:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 | import { createHref } from '../../src/utils/urlUtils';
3 |
4 | describe('utils', () => {
5 |
6 | describe('createHref()', () => {
7 | it('creates simple href', () => {
8 | expect(createHref('/', { a: 1 })).to.be.equal('/?a=1');
9 | expect(createHref('/', { a: [1, 0] })).to.be.equal('/?a%5B%5D=1&a%5B%5D=0');
10 | });
11 |
12 | it('parses existing query string and merges with new query params', () => {
13 | expect(createHref('/?b=1', { a: [1, 0] })).to.be.equal('/?b=1&a%5B%5D=1&a%5B%5D=0');
14 | expect(createHref('/?', { a: 1 })).to.be.equal('/?a=1');
15 | });
16 |
17 | it('strips question mark if query string of given path is empty', () => {
18 | expect(createHref('/?')).to.be.equal('/');
19 | });
20 | });
21 |
22 | });
23 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | var webpack = require('webpack');
4 |
5 | var plugins = [
6 | new webpack.optimize.OccurenceOrderPlugin(),
7 | new webpack.DefinePlugin({
8 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV)
9 | })
10 | ];
11 |
12 | if (process.env.NODE_ENV === 'production') {
13 | plugins.push(
14 | new webpack.optimize.UglifyJsPlugin({
15 | compressor: {
16 | screw_ie8: true,
17 | warnings: false
18 | }
19 | })
20 | );
21 | }
22 |
23 | var reactExternal = {
24 | root: 'React',
25 | commonjs2: 'react',
26 | commonjs: 'react',
27 | amd: 'react'
28 | };
29 |
30 | var reduxExternal = {
31 | root: 'Redux',
32 | commonjs2: 'redux',
33 | commonjs: 'redux',
34 | amd: 'redux'
35 | };
36 |
37 | module.exports = {
38 | externals: {
39 | 'react': reactExternal,
40 | 'react-native': reactExternal,
41 | 'redux': reduxExternal
42 | },
43 | module: {
44 | loaders: [{
45 | test: /\.js$/,
46 | loaders: ['babel-loader'],
47 | exclude: /node_modules/
48 | }]
49 | },
50 | output: {
51 | library: !!process.env.MODULE_NAME ? process.env.MODULE_NAME : 'routex',
52 | libraryTarget: 'umd'
53 | },
54 | plugins: plugins,
55 | resolve: {
56 | extensions: ['', '.js']
57 | }
58 | };
59 |
--------------------------------------------------------------------------------