├── .coveralls.yml
├── .gitignore
├── .jshintignore
├── .npmignore
├── .travis.yml
├── CONTRIBUTING.md
├── LICENSE.md
├── README.md
├── actions
└── navigate.js
├── docs
├── navigateAction.md
├── navlink.md
└── router-mixin.md
├── examples
└── simple-flux.md
├── index.js
├── lib
├── History.js
├── NavLink.js
└── RouterMixin.js
├── package.json
├── tests
├── mocks
│ ├── MockAppComponent.js
│ └── mockWindow.js
└── unit
│ ├── actions
│ └── navigate.js
│ ├── lib
│ ├── History-test.js
│ ├── NavLink-test.js
│ └── RouterMixin-test.js
│ └── utils
│ └── HistoryWithHash-test.js
└── utils
├── HistoryWithHash.js
└── index.js
/.coveralls.yml:
--------------------------------------------------------------------------------
1 | service_name: travis-ci
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /artifacts/
2 | /build/
3 | node_modules
4 | npm-debug.log
5 |
--------------------------------------------------------------------------------
/.jshintignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | /artifacts/
2 | /examples/
3 | /tests/
4 | /build/
5 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: false
2 | language: node_js
3 | matrix:
4 | allow_failures:
5 | - node_js: "0.13"
6 | node_js:
7 | - "iojs"
8 | - "0.13"
9 | - "0.12"
10 | - "0.10"
11 | after_success:
12 | - "npm run cover"
13 | - "cat artifacts/lcov.info | ./node_modules/coveralls/bin/coveralls.js"
14 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | Contributing Code to `flux-router-component`
2 | ---------------------------------------------
3 |
4 | Please be sure to sign our [CLA][] before you submit pull requests or otherwise contribute to `flux-router-component`. This protects developers, who rely on [BSD license][].
5 |
6 | [BSD license]: https://github.com/yahoo/flux-router-component/blob/master/LICENSE.md
7 | [CLA]: https://yahoocla.herokuapp.com/
8 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Software License Agreement (BSD License)
2 | ========================================
3 |
4 | Copyright (c) 2014, Yahoo! Inc. All rights reserved.
5 | ----------------------------------------------------
6 |
7 | Redistribution and use of this software in source and binary forms, with or
8 | without modification, are permitted provided that the following conditions are
9 | met:
10 |
11 | * Redistributions of source code must retain the above copyright notice, this
12 | list of conditions and the following disclaimer.
13 | * Redistributions in binary form must reproduce the above copyright notice,
14 | this list of conditions and the following disclaimer in the documentation
15 | and/or other materials provided with the distribution.
16 | * Neither the name of Yahoo! Inc. nor the names of YUI's contributors may be
17 | used to endorse or promote products derived from this software without
18 | specific prior written permission of Yahoo! Inc.
19 |
20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
21 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
22 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
24 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
25 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
26 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
27 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
28 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
29 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # flux-router-component
2 |
3 | ***Notice: This package is deprecated in favor of [`fluxible-router`](https://github.com/yahoo/fluxible-router).***
4 |
5 | [](http://badge.fury.io/js/flux-router-component)
6 | [](https://travis-ci.org/yahoo/flux-router-component)
7 | [](https://david-dm.org/yahoo/flux-router-component)
8 | [](https://david-dm.org/yahoo/flux-router-component#info=devDependencies)
9 | [](https://coveralls.io/r/yahoo/flux-router-component?branch=master)
10 |
11 | Provides navigational React components (`NavLink`), router mixin (`RouterMixin`), and action `navigateAction` for applications built with [Flux](http://facebook.github.io/react/docs/flux-overview.html) architecture. Please check out [examples](https://github.com/yahoo/flux-router-component/tree/master/examples) of how to use these components.
12 |
13 | ## Context and Expected Context Methods
14 |
15 | Before we explain how to use `NavLink` and `RouterMixin`, lets start with two methods they expect:
16 |
17 | * `executeAction(navigateAction, payload)` - This executes navigate action, switches the app to the new route, and update the url.
18 | * `makePath(routeName, routeParams)` - This is used to generate url for a given route.
19 |
20 | These two methods need to be available in:
21 |
22 | * the React context of the component (access via `this.context` in the component), or
23 | * the `context` prop of the component (`this.props.context`)
24 | * If exists in both `this.context` and `this.props.context`, the one in `this.context` takes higher precedence.
25 |
26 | An example of such context is the `ComponentContext` provided by [fluxible-plugin-routr](https://github.com/yahoo/fluxible-plugin-routr/blob/master/lib/routr-plugin.js#L36), which is a plugin for [fluxible](https://github.com/yahoo/fluxible). We have a more sophisticated example application, [fluxible-router](https://github.com/yahoo/flux-examples/tree/master/fluxible-router), showing how everything works together.
27 |
28 | **Note** that React context is an undocumented feature, so its API could change without notice. Here is [a blog from Dave King](https://www.tildedave.com/2014/11/15/introduction-to-contexts-in-react-js.html) that explains what it is and how to use it.
29 |
30 |
31 | ## NavLink
32 |
33 | [Docs](https://github.com/yahoo/flux-router-component/blob/master/docs/navlink.md)
34 |
35 | ## RouterMixin
36 |
37 | [Docs](https://github.com/yahoo/flux-router-component/blob/master/docs/router-mixin.md)
38 |
39 | ## navigateAction
40 |
41 | [Docs](https://github.com/yahoo/flux-router-component/blob/master/docs/navigateAction.md)
42 |
43 |
44 | ## History Management (Browser Support and Hash-Based Routing)
45 | Considering different application needs and [different browser support levels for pushState](http://caniuse.com/#search=pushstate), this library provides the following options for browser history management:
46 |
47 | * Use `History` provided by this library (Default)
48 | * Use `HistoryWithHash` provided by this library
49 | * In addition, you can also customize it to use your own
50 |
51 | ### History
52 | This is the default `History` implementation `RouterMixin` uses. It is a straight-forward implementation that:
53 | * uses `pushState`/`replaceState` when they are available in the browser.
54 | * For the browsers without pushState support, `History` simply refreshes the page by setting `window.location.href = url` for `pushState`, and calling `window.location.replace(url)` for `replaceState`.
55 |
56 | ### HistoryWithHash
57 | Using hash-based url for client side routing has a lot of known issues. [History.js describes those issues pretty well](https://github.com/browserstate/history.js/wiki/Intelligent-State-Handling).
58 |
59 | But as always, there will be some applications out there that have to use it. This implementation provides a solution.
60 |
61 | If you do decide to use hash route, it is recommended to enable `checkRouteOnPageLoad`. Because hash fragment (that contains route) does not get sent to the server side, `RouterMixin` will compare the route info from server and route in the hash fragment. On route mismatch, it will dispatch a navigate action on browser side to load the actual page content for the route represented by the hash fragment.
62 |
63 | #### useHashRoute Config
64 | You can decide when to use hash-based routing through the `useHashRoute` option:
65 |
66 | * `useHashRoute=true` to force to use hash routing for all browsers, by setting `useHashRoute` to true when creating the `HistoryWithHash` instance;
67 | * `unspecified`, i.e. omitting the setting, to only use hash route for browsers without native pushState support;
68 | * `useHashRoute=false` to turn off hash routing for all browsers.
69 |
70 | | | useHashRoute = true | useHashRoute = false | useHashRoute unspecified |
71 | |--------------------------------------|-------------------------------------------------|---------------------------------------|--------------------------------|
72 | | Browsers *with* pushState support | history.pushState with /home#/path/to/pageB | history.pushState with /path/to/pageB | Same as `useHashRoute = false` |
73 | | Browsers *without* pushState support | page refresh to /home#/path/to/pageB | page refresh to /path/to/pageB | Same as `useHashRoute = true` |
74 |
75 | #### Custom Transformer for Hash Fragment
76 | By default, the hash fragments are just url paths. With `HistoryWithHash`, you can transform it to whatever syntax you need by passing `props.hashRouteTransformer` to the base React component that `RouterMixin` is mixed into. See the example below for how to configure it.
77 |
78 | #### Example
79 | This is an example of how you can use and configure `HistoryWithHash`:
80 |
81 | ```js
82 | var RouterMixin = require('flux-router-component').RouterMixin;
83 | var HistoryWithHash = require('flux-router-component/utils').HistoryWithHash;
84 |
85 | var Application = React.createClass({
86 | mixins: [RouterMixin],
87 | ...
88 | });
89 |
90 | var appComponent = Application({
91 | ...
92 | historyCreator: function historyCreator() {
93 | return new HistoryWithHash({
94 | // optional. Defaults to true if browser does not support pushState; false otherwise.
95 | useHashRoute: true,
96 | // optional. Defaults to '/'. Used when url has no hash fragment
97 | defaultHashRoute: '/default',
98 | // optional. Transformer for custom hash route syntax
99 | hashRouteTransformer: {
100 | transform: function (original) {
101 | // transform url hash fragment from '/new/path' to 'new-path'
102 | var transformed = original.replace('/', '-').replace(/^(\-+)/, '');
103 | return transformed;
104 | },
105 | reverse: function (transformed) {
106 | // reverse transform from 'new-path' to '/new/path'
107 | var original = '/' + (transformed && transformed.replace('-', '/'));
108 | return original;
109 | }
110 | }
111 | });
112 | }
113 | });
114 |
115 | ```
116 |
117 | ### Provide Your Own History Manager
118 | If none of the history managers provided in this library works for your application, you can also customize the RouterMixin to use your own history manager implementation. Please follow the same API as `History`.
119 |
120 | #### API
121 | Please use `History.js` and `HistoryWithHash.js` as examples.
122 |
123 | * on(listener)
124 | * off(listener)
125 | * getUrl()
126 | * getState()
127 | * pushState(state, title, url)
128 | * replaceState(state, title, url)
129 |
130 | #### Example:
131 |
132 | ```js
133 | var RouterMixin = require('flux-router-component').RouterMixin;
134 | var MyHistory = require('MyHistoryManagerIsAwesome');
135 |
136 | var Application = React.createClass({
137 | mixins: [RouterMixin],
138 | ...
139 | });
140 |
141 | var appComponent = Application({
142 | ...
143 | historyCreator: function historyCreator() {
144 | return new MyHistory();
145 | }
146 | });
147 |
148 | ```
149 |
150 | ## Scroll Position Management
151 |
152 | `RouterMixin` has a built-in mechanism for managing scroll position upon page navigation, for modern browsers that support native history state:
153 |
154 | * reset scroll position to `(0, 0)` when user clicks on a link and navigates to a new page, and
155 | * restore scroll position to last visited state when user clicks forward and back buttons to navigate between pages.
156 |
157 | If you want to disable this behavior, you can set `enableScroll` prop to `false` for `RouterMixin`. This is an example of how it can be done:
158 |
159 | ```js
160 | var RouterMixin = require('flux-router-component').RouterMixin;
161 |
162 | var Application = React.createClass({
163 | mixins: [RouterMixin],
164 | ...
165 | });
166 |
167 | var appComponent = Application({
168 | ...
169 | enableScroll: false
170 | });
171 |
172 | ```
173 |
174 | ## onbeforeunload Support
175 |
176 | The `History` API does not allow `popstate` events to be cancelled, which results in `window.onbeforeunload()` methods not being triggered. This is problematic for users, since application state could be lost when they navigate to a certain page without knowing the consequences.
177 |
178 | Our solution is to check for a `window.onbeforeunload()` method, prompt the user with `window.confirm()`, and then navigate to the correct route based on the confirmation. If a route is cancelled by the user, we reset the page URL back to the original URL by using the `History` `pushState()` method.
179 |
180 | To implement the `window.onbeforeunload()` method, you need to set it within the components that need user verification before leaving a page. Here is an example:
181 |
182 | ```javascript
183 | componentDidMount: function() {
184 | window.onbeforeunload = function () {
185 | return 'Make sure to save your changes before leaving this page!';
186 | }
187 | }
188 | ```
189 |
190 |
191 | ## Polyfills
192 | `addEventListener` and `removeEventListener` polyfills are provided by:
193 |
194 | * Compatibility code example on [Mozilla Developer Network](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget.addEventListener)
195 | * A few DOM polyfill libaries listed on [Modernizer Polyfill wiki page](https://github.com/Modernizr/Modernizr/wiki/HTML5-Cross-Browser-Polyfills#dom).
196 |
197 | `Array.prototype.reduce` and `Array.prototype.map` (used by dependent library, query-string) polyfill examples are provided by:
198 |
199 | * [Mozilla Developer Network Array.prototype.reduce polyfill](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce#Polyfill)
200 | * [Mozilla Developer Network Array.prototype.map polyfill](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map#Polyfill)
201 |
202 | You can also look into this [polyfill.io polyfill service](https://cdn.polyfill.io/v1/).
203 |
204 | ## Compatible React Versions
205 |
206 | | Compatible React Version | flux-router-component Version |
207 | |--------------------------|-------------------------------|
208 | | 0.12 | >= 0.4.1 |
209 | | 0.11 | < 0.4 |
210 |
211 | ## License
212 | This software is free to use under the Yahoo! Inc. BSD license.
213 | See the [LICENSE file][] for license text and copyright information.
214 |
215 | [LICENSE file]: https://github.com/yahoo/flux-router-component/blob/master/LICENSE.md
216 |
217 | Third-pary open source code used are listed in our [package.json file]( https://github.com/yahoo/flux-router-component/blob/master/package.json).
218 |
219 |
--------------------------------------------------------------------------------
/actions/navigate.js:
--------------------------------------------------------------------------------
1 | var debug = require('debug')('navigateAction');
2 | var queryString = require('query-string');
3 | var searchPattern = /\?([^\#]*)/;
4 |
5 | function parseQueryString(url) {
6 | var search;
7 | var matches = url.match(searchPattern);
8 | if (matches) {
9 | search = matches[1];
10 | }
11 | return (search && queryString.parse(search)) || {};
12 | }
13 |
14 | module.exports = function (context, payload, done) {
15 | if (!context.router || !context.router.getRoute) {
16 | debug('no router available for navigate handling');
17 | done(new Error('missing router'));
18 | return;
19 | }
20 | debug('executing', payload);
21 |
22 | var options = {
23 | navigate: payload,
24 | method: payload.method
25 | };
26 |
27 | var route = context.router.getRoute(payload.url, options);
28 |
29 | if (!route) {
30 | var err = new Error('Url does not exist');
31 | err.status = 404;
32 | done(err);
33 | return;
34 | }
35 |
36 | // add parsed query parameter object to route object,
37 | // and make it part of CHANGE_ROUTE_XXX action payload.
38 | route.query = parseQueryString(route.url);
39 |
40 | debug('dispatching CHANGE_ROUTE', route);
41 | context.dispatch('CHANGE_ROUTE_START', route);
42 | var action = route.config && route.config.action;
43 |
44 | if ('string' === typeof action && context.getAction) {
45 | action = context.getAction(action);
46 | }
47 |
48 | if (!action || 'function' !== typeof action) {
49 | debug('route has no action, dispatching without calling action');
50 | context.dispatch('CHANGE_ROUTE_SUCCESS', route);
51 | done();
52 | return;
53 | }
54 |
55 | debug('executing route action');
56 | context.executeAction(action, route, function (err) {
57 | if (err) {
58 | context.dispatch('CHANGE_ROUTE_FAILURE', route);
59 | } else {
60 | context.dispatch('CHANGE_ROUTE_SUCCESS', route);
61 | }
62 | done(err);
63 | });
64 | };
65 |
--------------------------------------------------------------------------------
/docs/navigateAction.md:
--------------------------------------------------------------------------------
1 | # navigateAction
2 |
3 | `navigateAction` is an action which will load a new route and update the URL of your application. You can call this action directly to update the URL of your application. It is also invoked indirectly when a user clicks on a [`NavLink`](./navlink.md).
4 |
5 | `navigateAction` expects a `{method, url}` object as a payload, where `method` is the HTTP method used to retrieve the URL (e.g. 'get'.)
6 |
7 | If no matching route is found, `navigateAction` will call the callback with an error where `err.status` is set to 404.
8 |
9 | If a route is successfully matched, `navigateAction` will first dispatch a `CHANGE_ROUTE_START` event, with route data as the payload (see below). `navigateAction` will then try to find an action associated with the route from the route config; this can either be an action function or the name of an action function (retrieved with `context.getAction(name)`.) If an action is found, it is executed, with route data as the payload. `navigateAction` finally will dispatch a `CHANGE_ROUTE_SUCCESS` event, or `CHANGE_ROUTE_FAILURE` event if the route's action returns an error.
10 |
11 | ## Route Data
12 |
13 | `navigateAction` passes a route data structure as the payload for all events and as the payload for any called actions. This consists of:
14 |
15 | | Field Name | Description |
16 | |------------|-----------------------------------------|
17 | | name | The name of the matched route. |
18 | | url | The actual URL that was matched. |
19 | | params | Parameters parsed from the route. |
20 | | query | Query parameters parsed from the URL. Note that if a query parameter occurs multiple times, the value in this hash will be an array. |
21 | | config | The configuation for the route. |
22 | | navigate | The payload passed to `navigateAction`. |
23 |
--------------------------------------------------------------------------------
/docs/navlink.md:
--------------------------------------------------------------------------------
1 | # NavLink
2 | `NavLink` is the a React component for navigational links. When the link is clicked, NavLink will execute a [navigateAction]('./navigateAction.md'). Stores can register for `CHANGE_ROUTE_SUCCESS` handlers if they are interested
3 | in navigation events.
4 |
5 | | Prop Name | Prop Type | Description |
6 | |------------|---------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
7 | | href | String | The url string |
8 | | routeName | String | Not used if `href` is specified. This is the name of the target route, which should be defined in your app's routes. |
9 | | navParams | Object | If `href` prop is not available, `navParams` object will be used together with `routeName` to generate the href for the link. This object needs to contain route params the route path needs. Eg. for a route path `/article/:id`, `navParams.id` will be the article ID. |
10 | | followLink | boolean, default to false | If set to true, client side navigation will be disabled. NavLink will just act like a regular anchor link. |
11 | | replaceState | boolean, default to false | If set to true, replaceState is being used instead of pushState |
12 | | preserveScrollPosition | boolean, default to false | If set to true, the page will maintain its scroll position on route change. |
13 |
14 |
15 | ## Example Usage
16 |
17 | Here are two examples of generating `NavLink` using `href` property, and using `routeName` property. Using `href` property is better than using `routeName`, because:
18 |
19 | * Using `href` makes your code more readible, as it shows exactly how the `href` is generated.
20 | * Using `routeName` assumes `this.context` or `this.props.context` has a `makePath()` function, which will be used to generate the `href` from the `routeName` and `navParams` props.
21 | * Using `routeName` could be more limited, especially when it comes to query string and hash fragment, if the `makePath()` function does not support query string and hash fragment.
22 |
23 | ### Example of Using `href` Property (Recommended)
24 |
25 | If the url is static, or you can generate the url outside of `Navlink`, you can simply pass the url to `NavLink` as a prop. Here is an example:
26 |
27 | ```js
28 | var NavLink = require('flux-router-component').NavLink;
29 |
30 | var Nav = React.createClass({
31 | render: function () {
32 | // This example is using this.props.context for Nav and NavLink components.
33 | // You can also use the React context, as described in the Context section of this doc.
34 | var pages,
35 | links,
36 | context = this.props.context;
37 | pages = [
38 | {
39 | name: 'home',
40 | url: '/home',
41 | text: 'Home'
42 | },
43 | {
44 | name: 'about',
45 | url: '/about',
46 | text: 'About Us'
47 | }
48 | ];
49 | links = pages.map(function (page) {
50 | return (
51 |
52 |
53 | {page.text}
54 |
55 |
56 | );
57 | });
58 | return (
59 |
62 | );
63 |
64 | }
65 | });
66 | ```
67 |
68 | ### Example of Using `routeName` Property
69 |
70 | Before you continue with this example, you should know that you can always generate the url yourself outside of `NavLink` and pass it to `NavLink` as `href` prop just like the example above. Your code will be more straight-forward that way, and you will have more control over how to generate `href` (see more explanations in [the Example Usage section](#example-usage)).
71 |
72 | If you choose not to generate `href` yourself and the `context` prop you pass to `NavLink` provides `makePath(routeName, routeParams)`, you can also use the `routeName` prop (and the optional `navParams` prop). If the `href` prop is not present, `NavLink` will use `makePath(this.props.routeName, this.props.navParams)` from either `this.context` or `this.props.context` to generate the `href` for the anchor element. The `navParams` prop is useful for dynamic routes. It should be a hash object containing the route parameters and their values.
73 |
74 |
75 | Here is a quick example code showcasing how to use `routeName` prop along with `navParams` prop:
76 |
77 | ```js
78 | // assume routes are defined somewhere like this:
79 | // var routes = {
80 | // home: {
81 | // path: '/',
82 | // page: 'home'
83 | // },
84 | // article: {
85 | // path: '/article/:id',
86 | // page: 'article'
87 | // }
88 | // };
89 | var pages = [
90 | {
91 | routeName: 'home',
92 | text: 'Home'
93 | },
94 | {
95 | routeName: 'article',
96 | routeParams: {
97 | id: 'a'
98 | }
99 | text: 'Article A'
100 | }
101 | ];
102 | var Nav = React.createClass({
103 | render: function () {
104 | // context should provide executeAction() and makePath().
105 | // This example is using this.props.context for Nav and NavLink components.
106 | // You can also use the React context, as described in the Context section of this doc.
107 | var context = this.props.context;
108 | var links = pages.map(function (page) {
109 | return (
110 |
111 |
112 | {page.text}
113 |
114 |
115 | );
116 | });
117 | return (
118 |
121 | );
122 | }
123 | });
124 | ```
125 |
--------------------------------------------------------------------------------
/docs/router-mixin.md:
--------------------------------------------------------------------------------
1 | # RouterMixin
2 | `RouterMixin` is a React mixin to be used by application's top level React component to:
3 |
4 | * [manage browser history](#history-management-browser-support-and-hash-based-routing) when route changes, and
5 | * execute navigate action and then dispatch `CHANGE_ROUTE_START` and `CHANGE_ROUTE_SUCCESS` or `CHANGE_ROUTE_FAILURE` events via flux dispatcher on window `popstate` events
6 | * [manage scroll position](#scroll-position-management) when navigating between pages
7 |
8 | Note that the `RouterMixin` reads your component's `state.route`; your component is responsible for setting this value. Typically this would be done by setting up a store which listens for 'CHANGE_ROUTE_SUCCESS' events.
9 |
10 | ## Example Usage
11 | ```js
12 | // AppStateStore.js
13 | var createStore = require('fluxible/addons').createStore;
14 | module.exports = createStore({
15 | storeName: 'AppStateStore',
16 | handlers: {
17 | 'CHANGE_ROUTE_SUCCESS': 'onNavigate'
18 | },
19 | initialize: function() {
20 | this.route = null;
21 | },
22 | dehydrate: function() {
23 | return this.route;
24 | },
25 | rehydrate: function(state) {
26 | this.route = state;
27 | },
28 | onNavigate: function(route) {
29 | this.route = route;
30 | return this.emitChange();
31 | }
32 | });
33 | ```
34 |
35 | ```js
36 | // Application.jsx
37 | var RouterMixin = require('flux-router-component').RouterMixin;
38 | var AppStateStore = require('../stores/AppStateStore');
39 |
40 | var Application = React.createClass({
41 | mixins: [FluxibleMixin, RouterMixin],
42 |
43 | statics: {
44 | storeListeners: [AppStateStore]
45 | },
46 |
47 | getInitialState: function() {
48 | return {route: this.getStore(AppStateStore).route};
49 | };
50 |
51 | onChange: function() {
52 | this.setState({
53 | route: this.getStore(AppStateStore).route
54 | });
55 | };
56 |
57 | ...
58 | });
59 | ```
60 |
--------------------------------------------------------------------------------
/examples/simple-flux.md:
--------------------------------------------------------------------------------
1 | [This flux-examples Github repo](https://github.com/yahoo/flux-examples) has a simple [Fluxible](http://www.fluxible.io/) application, [`fluxible-router`](https://github.com/yahoo/flux-examples/tree/master/fluxible-router), that works on both client and server.
2 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2014, Yahoo! Inc.
3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms.
4 | */
5 | 'use strict';
6 |
7 | module.exports = {
8 | NavLink: require('./lib/NavLink'),
9 | RouterMixin: require('./lib/RouterMixin'),
10 | navigateAction: require('./actions/navigate'),
11 | History: require('./lib/History')
12 | };
13 |
--------------------------------------------------------------------------------
/lib/History.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2014, Yahoo! Inc.
3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms.
4 | */
5 | /*global window */
6 | 'use strict';
7 |
8 | var EVENT_POPSTATE = 'popstate';
9 |
10 | function isUndefined(v) {
11 | return v === undefined;
12 | }
13 |
14 | /**
15 | * This only supports pushState for the browsers with native pushState support.
16 | * For other browsers (mainly IE8 and IE9), it will refresh the page upon pushState()
17 | * and replaceState().
18 | * @class History
19 | * @constructor
20 | * @param {Object} [options] The options object
21 | * @param {Window} [options.win=window] The window object
22 | */
23 | function History(options) {
24 | this.win = (options && options.win) || window;
25 | this._hasPushState = !!(this.win && this.win.history && this.win.history.pushState);
26 | }
27 |
28 | History.prototype = {
29 | /**
30 | * Add the given listener for 'popstate' event (nothing happens for browsers that
31 | * don't support popstate event).
32 | * @method on
33 | * @param {Function} listener
34 | */
35 | on: function (listener) {
36 | if (this._hasPushState) {
37 | this.win.addEventListener(EVENT_POPSTATE, listener);
38 | }
39 | },
40 |
41 | /**
42 | * Remove the given listener for 'popstate' event (nothing happens for browsers that
43 | * don't support popstate event).
44 | * @method off
45 | * @param {Function} listener
46 | */
47 | off: function (listener) {
48 | if (this._hasPushState) {
49 | this.win.removeEventListener(EVENT_POPSTATE, listener);
50 | }
51 | },
52 |
53 | /**
54 | * @method getState
55 | * @return {Object|null} The state object in history
56 | */
57 | getState: function () {
58 | return (this.win.history && this.win.history.state) || null;
59 | },
60 |
61 | /**
62 | * Gets the path string, including the pathname and search query (if it exists).
63 | * @method getUrl
64 | * @return {String} The url string that denotes current route path and query
65 | */
66 | getUrl: function () {
67 | var location = this.win.location;
68 | return location.pathname + location.search;
69 | },
70 |
71 | /**
72 | * Same as HTML5 pushState API, but with old browser support
73 | * @method pushState
74 | * @param {Object} state The state object
75 | * @param {String} title The title string
76 | * @param {String} url The new url
77 | */
78 | pushState: function (state, title, url) {
79 | var win = this.win;
80 | if (this._hasPushState) {
81 | title = isUndefined(title) ? win.document.title : title;
82 | url = isUndefined(url) ? win.location.href : url;
83 | win.history.pushState(state, title, url);
84 | this.setTitle(title);
85 | } else if (url) {
86 | win.location.href = url;
87 | }
88 | },
89 |
90 | /**
91 | * Same as HTML5 replaceState API, but with old browser support
92 | * @method replaceState
93 | * @param {Object} state The state object
94 | * @param {String} title The title string
95 | * @param {String} url The new url
96 | */
97 | replaceState: function (state, title, url) {
98 | var win = this.win;
99 | if (this._hasPushState) {
100 | title = isUndefined(title) ? win.document.title : title;
101 | url = isUndefined(url) ? win.location.href : url;
102 | win.history.replaceState(state, title, url);
103 | this.setTitle(title);
104 | } else if (url) {
105 | win.location.replace(url);
106 | }
107 | },
108 |
109 | /**
110 | * Sets document title. No-op if title is empty.
111 | * @param {String} title The title string.
112 | */
113 | setTitle: function (title) {
114 | if (title) {
115 | this.win.document.title = title;
116 | }
117 | }
118 | };
119 |
120 | module.exports = History;
121 |
--------------------------------------------------------------------------------
/lib/NavLink.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2014, Yahoo! Inc.
3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms.
4 | */
5 | /*global window */
6 | 'use strict';
7 |
8 | var React = require('react');
9 | var NavLink;
10 | var navigateAction = require('../actions/navigate');
11 | var debug = require('debug')('NavLink');
12 | var objectAssign = require('object-assign');
13 |
14 | function isLeftClickEvent (e) {
15 | return e.button === 0;
16 | }
17 |
18 | function isModifiedEvent (e) {
19 | return !!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey);
20 | }
21 |
22 | NavLink = React.createClass({
23 | displayName: 'NavLink',
24 | contextTypes: {
25 | executeAction: React.PropTypes.func,
26 | makePath: React.PropTypes.func
27 | },
28 | propTypes: {
29 | context: React.PropTypes.object,
30 | href: React.PropTypes.string,
31 | routeName: React.PropTypes.string,
32 | navParams: React.PropTypes.object,
33 | followLink: React.PropTypes.bool,
34 | preserveScrollPosition: React.PropTypes.bool,
35 | replaceState: React.PropTypes.bool
36 | },
37 | getInitialState: function () {
38 | return {
39 | href: this._getHrefFromProps(this.props)
40 | };
41 | },
42 | componentWillReceiveProps: function (nextProps) {
43 | this.setState({
44 | href: this._getHrefFromProps(nextProps)
45 | });
46 | },
47 | _getHrefFromProps: function (props) {
48 | var href = props.href;
49 | var routeName = props.routeName;
50 | if (!href && routeName) {
51 | var context = props.context || this.context;
52 | href = context.makePath(routeName, props.navParams);
53 | }
54 | if (!href) {
55 | throw new Error('NavLink created without href or unresolvable routeName \'' + routeName + '\'');
56 | }
57 | return href;
58 | },
59 | dispatchNavAction: function (e) {
60 | var navType = this.props.replaceState ? 'replacestate' : 'click';
61 | debug('dispatchNavAction: action=NAVIGATE', this.props.href, this.props.followLink, this.props.navParams);
62 |
63 | if (this.props.followLink) {
64 | return;
65 | }
66 |
67 | if (isModifiedEvent(e) || !isLeftClickEvent(e)) {
68 | // this is a click with a modifier or not a left-click
69 | // let browser handle it natively
70 | return;
71 | }
72 |
73 | var href = this.state.href;
74 |
75 | if (href[0] === '#') {
76 | // this is a hash link url for page's internal links.
77 | // Do not trigger navigate action. Let browser handle it natively.
78 | return;
79 | }
80 |
81 | if (href[0] !== '/') {
82 | // this is not a relative url. check for external urls.
83 | var location = window.location;
84 | var origin = location.origin || (location.protocol + '//' + location.host);
85 |
86 | if (href.indexOf(origin) !== 0) {
87 | // this is an external url, do not trigger navigate action.
88 | // let browser handle it natively.
89 | return;
90 | }
91 |
92 | href = href.substring(origin.length) || '/';
93 | }
94 |
95 | var context = this.props.context || this.context;
96 | if (!context || !context.executeAction) {
97 | console.warn('NavLink does not have access to executeAction. Link using browser default.');
98 | return;
99 | }
100 |
101 | e.preventDefault();
102 | e.stopPropagation();
103 |
104 | var onBeforeUnloadText = typeof window.onbeforeunload === 'function' ? window.onbeforeunload() : '';
105 | var confirmResult = onBeforeUnloadText ? window.confirm(onBeforeUnloadText) : true;
106 |
107 | if (confirmResult) {
108 | // Removes the window.onbeforeunload method so that the next page will not be affected
109 | window.onbeforeunload = null;
110 |
111 | context.executeAction(navigateAction, {
112 | type: navType,
113 | url: href,
114 | preserveScrollPosition: this.props.preserveScrollPosition,
115 | params: this.props.navParams
116 | });
117 | }
118 | },
119 | render: function() {
120 | return React.createElement(
121 | 'a',
122 | objectAssign({}, {
123 | onClick: this.dispatchNavAction
124 | }, this.props, {
125 | href: this.state.href
126 | }),
127 | this.props.children
128 | );
129 | }
130 | });
131 |
132 | module.exports = NavLink;
133 |
--------------------------------------------------------------------------------
/lib/RouterMixin.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2014-2015, Yahoo! Inc.
3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms.
4 | */
5 | /*global window */
6 | 'use strict';
7 |
8 | var debug = require('debug')('RouterMixin');
9 | var navigateAction = require('../actions/navigate');
10 | var History = require('./History');
11 | var React = require('react');
12 | var TYPE_CLICK = 'click';
13 | var TYPE_PAGELOAD = 'pageload';
14 | var TYPE_REPLACESTATE = 'replacestate';
15 | var TYPE_POPSTATE = 'popstate';
16 | var TYPE_DEFAULT = 'default'; // default value if navigation type is missing, for programmatic navigation
17 | var RouterMixin;
18 |
19 | require('setimmediate');
20 |
21 | function routesEqual(route1, route2) {
22 | route1 = route1 || {};
23 | route2 = route2 || {};
24 | return (route1.url === route2.url);
25 | }
26 |
27 | function saveScrollPosition(e, history) {
28 | var historyState = (history.getState && history.getState()) || {};
29 | historyState.scroll = {x: window.scrollX, y: window.scrollY};
30 | debug('remember scroll position', historyState.scroll);
31 | history.replaceState(historyState);
32 | }
33 |
34 | RouterMixin = {
35 | contextTypes: {
36 | executeAction: React.PropTypes.func,
37 | makePath: React.PropTypes.func
38 | },
39 | childContextTypes: {
40 | makePath: React.PropTypes.func
41 | },
42 | getChildContext: function(){
43 | var context = {};
44 | Object.keys(RouterMixin.childContextTypes).forEach(function (key) {
45 | context[key] = (this.props.context && this.props.context[key]) || this.context[key];
46 | }, this);
47 | return context;
48 | },
49 | componentDidMount: function() {
50 | var self = this;
51 | var context;
52 | var urlFromHistory;
53 | var urlFromState;
54 |
55 | if (self.context && self.context.executeAction) {
56 | context = self.context;
57 | } else if (self.props.context && self.props.context.executeAction) {
58 | context = self.props.context;
59 | }
60 |
61 | self._history = ('function' === typeof self.props.historyCreator) ? self.props.historyCreator() : new History();
62 | self._enableScroll = (self.props.enableScroll !== false);
63 |
64 | if (self.props.checkRouteOnPageLoad) {
65 | // You probably want to enable checkRouteOnPageLoad, if you use a history implementation
66 | // that supports hash route:
67 | // At page load, for browsers without pushState AND hash is present in the url,
68 | // since hash fragment is not sent to the server side, we need to
69 | // dispatch navigate action on browser side to load the actual page content
70 | // for the route represented by the hash fragment.
71 |
72 | urlFromHistory = self._history.getUrl();
73 | urlFromState = self.state && self.state.route && self.state.route.url;
74 |
75 | if (context && (urlFromHistory !== urlFromState)) {
76 | // put it in setImmediate, because we need the base component to have
77 | // store listeners attached, before navigateAction is executed.
78 | debug('pageload navigate to actual route', urlFromHistory, urlFromState);
79 | setImmediate(function navigateToActualRoute() {
80 | context.executeAction(navigateAction, {type: TYPE_PAGELOAD, url: urlFromHistory});
81 | });
82 | }
83 | }
84 |
85 | self._historyListener = function (e) {
86 | debug('history listener invoked', e, url, self.state.route.url);
87 |
88 | if (context) {
89 | var state = self.state || {};
90 | var url = self._history.getUrl();
91 | var currentUrl = state.route.url;
92 | var route = state.route || {};
93 | var onBeforeUnloadText = typeof window.onbeforeunload === 'function' ? window.onbeforeunload() : '';
94 | var confirmResult = onBeforeUnloadText ? window.confirm(onBeforeUnloadText) : true;
95 | var nav = route.navigate || {};
96 | var navParams = nav.params || {};
97 | var enableScroll = self._enableScroll && nav.preserveScrollPosition;
98 | var historyState = {
99 | params: (nav.params || {}),
100 | scroll: {
101 | x: (enableScroll ? window.scrollX : 0),
102 | y: (enableScroll ? window.scrollY : 0)
103 | }
104 | };
105 | var pageTitle = navParams.pageTitle || null;
106 |
107 | if (!confirmResult) {
108 | // Pushes the previous history state back on top to set the correct url
109 | self._history.pushState(historyState, pageTitle, currentUrl);
110 | } else {
111 | if (url !== currentUrl) {
112 | // Removes the window.onbeforeunload method so that the next page will not be affected
113 | window.onbeforeunload = null;
114 |
115 | context.executeAction(navigateAction, {type: TYPE_POPSTATE, url: url, params: (e.state && e.state.params)});
116 | }
117 | }
118 | }
119 | };
120 | self._history.on(self._historyListener);
121 |
122 | if (self._enableScroll) {
123 | var scrollTimer;
124 | self._scrollListener = function (e) {
125 | if (scrollTimer) {
126 | window.clearTimeout(scrollTimer);
127 | }
128 | scrollTimer = window.setTimeout(saveScrollPosition.bind(self, e, self._history), 150);
129 | };
130 | window.addEventListener('scroll', self._scrollListener);
131 | }
132 | },
133 | componentWillUnmount: function() {
134 | this._history.off(this._historyListener);
135 | this._historyListener = null;
136 |
137 | if (this._enableScroll) {
138 | window.removeEventListener('scroll', this._scrollListener);
139 | this._scrollListener = null;
140 | }
141 |
142 | this._history = null;
143 | },
144 | componentDidUpdate: function (prevProps, prevState) {
145 | debug('component did update', prevState, this.state);
146 |
147 | var newState = this.state;
148 | if (routesEqual(prevState && prevState.route, newState && newState.route)) {
149 | return;
150 | }
151 |
152 | var nav = newState.route.navigate || {};
153 | var navType = nav.type || TYPE_DEFAULT;
154 | var historyState;
155 | var pageTitle;
156 |
157 | switch (navType) {
158 | case TYPE_CLICK:
159 | case TYPE_DEFAULT:
160 | case TYPE_REPLACESTATE:
161 | historyState = {params: (nav.params || {})};
162 | if(this._enableScroll) {
163 | if (nav.preserveScrollPosition) {
164 | historyState.scroll = {x: window.scrollX, y: window.scrollY};
165 | } else {
166 | window.scrollTo(0, 0);
167 | historyState.scroll = {x: 0, y: 0};
168 | debug('on click navigation, reset scroll position to (0, 0)');
169 | }
170 | }
171 | pageTitle = nav.params && nav.params.pageTitle || null;
172 | if(navType == TYPE_REPLACESTATE) {
173 | this._history.replaceState(historyState, pageTitle, newState.route.url);
174 | } else {
175 | this._history.pushState(historyState, pageTitle, newState.route.url);
176 | }
177 | break;
178 | case TYPE_POPSTATE:
179 | if (this._enableScroll) {
180 | historyState = (this._history.getState && this._history.getState()) || {};
181 | var scroll = (historyState && historyState.scroll) || {};
182 | debug('on popstate navigation, restore scroll position to ', scroll);
183 | window.scrollTo(scroll.x || 0, scroll.y || 0);
184 | }
185 | break;
186 | }
187 | }
188 | };
189 |
190 | module.exports = RouterMixin;
191 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "flux-router-component",
3 | "version": "0.6.3",
4 | "description": "Router-related React component and mixin for applications with Fluxible architecture",
5 | "main": "index.js",
6 | "repository": {
7 | "type": "git",
8 | "url": "git://github.com/yahoo/flux-router-component.git"
9 | },
10 | "scripts": {
11 | "cover": "node node_modules/istanbul/lib/cli.js cover --dir artifacts -- ./node_modules/mocha/bin/_mocha tests/unit/ --recursive --reporter spec",
12 | "lint": "jshint lib tests",
13 | "test": "mocha tests/unit/ --recursive --reporter spec"
14 | },
15 | "author": "Lingyan Zhu ",
16 | "licenses": [
17 | {
18 | "type": "BSD",
19 | "url": "https://github.com/yahoo/flux-router-component/blob/master/LICENSE.md"
20 | }
21 | ],
22 | "dependencies": {
23 | "debug": "^2.0.0",
24 | "object-assign": "^2.0.0",
25 | "query-string": "^1.0.0",
26 | "setimmediate": "^1.0.2"
27 | },
28 | "peerDependencies": {
29 | "react": "0.13.x"
30 | },
31 | "devDependencies": {
32 | "chai": "^2.0.0",
33 | "coveralls": "^2.11.1",
34 | "istanbul": "^0.3.2",
35 | "jsdom": "^3.0.2",
36 | "jshint": "^2.5.1",
37 | "lodash": "^3.2.0",
38 | "mocha": "^2.0.1",
39 | "precommit-hook": "^1.0.2",
40 | "react": "0.13.x",
41 | "react-tools": "0.13.x"
42 | },
43 | "jshintConfig": {
44 | "node": true
45 | },
46 | "keywords": [
47 | "flux",
48 | "history",
49 | "navigation",
50 | "react",
51 | "router"
52 | ]
53 | }
54 |
--------------------------------------------------------------------------------
/tests/mocks/MockAppComponent.js:
--------------------------------------------------------------------------------
1 | var React = require('react/addons');
2 | var RouterMixin = require('../../').RouterMixin;
3 |
4 | var MockAppComponent = React.createClass({
5 |
6 | mixins: [RouterMixin],
7 |
8 | childContextTypes: {
9 | executeAction: React.PropTypes.func,
10 | getStore: React.PropTypes.func
11 | },
12 | getChildContext: function () {
13 | return {
14 | executeAction: this.props.context.executeAction,
15 | getStore: this.props.context.getStore
16 | };
17 | },
18 |
19 | render: function () {
20 | return React.addons.cloneWithProps(this.props.children, {});
21 | }
22 | });
23 |
24 | module.exports = MockAppComponent;
25 |
--------------------------------------------------------------------------------
/tests/mocks/mockWindow.js:
--------------------------------------------------------------------------------
1 | module.exports = function mockWindow(testResult) {
2 | return {
3 | HTML5: {
4 | document: {},
5 | history: {
6 | pushState: function (state, title, url) {
7 | testResult.pushState = {
8 | state: state,
9 | title: title,
10 | url: url
11 | };
12 | },
13 | replaceState: function (state, title, url) {
14 | testResult.replaceState = {
15 | state: state,
16 | title: title,
17 | url: url
18 | };
19 | }
20 | },
21 | addEventListener: function (evt, listener) {
22 | testResult.addEventListener = {
23 | evt: evt,
24 | listener: listener
25 | };
26 | },
27 | removeEventListener: function (evt, listener) {
28 | testResult.removeEventListener = {
29 | evt: evt,
30 | listener: listener
31 | };
32 | }
33 | },
34 | Firefox: {
35 | document: {},
36 | history: {
37 | pushState: function (state, title, url) {
38 | if (arguments.length < 3) {
39 | throw new TypeError("Not enough arguments to History.pushState.");
40 | }
41 | testResult.pushState = {
42 | state: state,
43 | title: title,
44 | url: url
45 | };
46 | },
47 | replaceState: function (state, title, url) {
48 | if (arguments.length < 3) {
49 | throw new TypeError("Not enough arguments to History.replaceState.");
50 | }
51 | testResult.pushState = {
52 | state: state,
53 | title: title,
54 | url: url
55 | };
56 | testResult.replaceState = {
57 | state: state,
58 | title: title,
59 | url: url
60 | };
61 | }
62 | },
63 | addEventListener: function (evt, listener) {
64 | testResult.addEventListener = {
65 | evt: evt,
66 | listener: listener
67 | };
68 | },
69 | removeEventListener: function (evt, listener) {
70 | testResult.removeEventListener = {
71 | evt: evt,
72 | listener: listener
73 | };
74 | }
75 | },
76 | OLD: {
77 | document: {},
78 | addEventListener: function (evt, listener) {
79 | testResult.addEventListener = {
80 | evt: evt,
81 | listener: listener
82 | };
83 | },
84 | removeEventListener: function (evt, listener) {
85 | testResult.removeEventListener = {
86 | evt: evt,
87 | listener: listener
88 | };
89 | }
90 | }
91 | };
92 |
93 | };
94 |
--------------------------------------------------------------------------------
/tests/unit/actions/navigate.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2014, Yahoo! Inc.
3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms.
4 | */
5 | /*globals describe,it,before,beforeEach */
6 | var expect = require('chai').expect,
7 | navigateAction = require('../../../actions/navigate'),
8 | lodash = require('lodash'),
9 | urlParser = require('url');
10 |
11 | describe('navigateAction', function () {
12 | var mockContext,
13 | actionCalls,
14 | homeRoute,
15 | actionRoute,
16 | failedRoute,
17 | stringActionRoute,
18 | fooAction;
19 |
20 | beforeEach(function () {
21 | homeRoute = {};
22 | fooAction = function () {};
23 | actionRoute = {
24 | config: {
25 | action: function () {}
26 | }
27 | };
28 | failedRoute = {
29 | config: {
30 | action: function () {}
31 | }
32 | };
33 | stringActionRoute = {
34 | config: {
35 | action: 'foo'
36 | }
37 | };
38 | postMethodRoute = {
39 | config: {
40 | action: function () {},
41 | method: 'post'
42 | }
43 | };
44 | actionCalls = [];
45 | mockContext = {
46 | routerCalls: [],
47 | router: {
48 | getRoute: function (url, options) {
49 | mockContext.routerCalls.push(arguments);
50 | var parsed = url && urlParser.parse(url);
51 | var pathname = parsed && parsed.pathname;
52 | var route;
53 | if ('/' === pathname) {
54 | route = lodash.clone(homeRoute);
55 | } else if ('/action' === pathname) {
56 | route = lodash.clone(actionRoute);
57 | } else if ('/fail' === pathname) {
58 | route = lodash.clone(failedRoute);
59 | } else if ('/string' === pathname) {
60 | route = lodash.clone(stringActionRoute);
61 | } else if ('/post' === pathname && postMethodRoute.config.method === options.method) {
62 | route = lodash.clone(postMethodRoute);
63 | }
64 | if (route) {
65 | route.url = url;
66 | }
67 | return route || null;
68 | }
69 | },
70 | executeActionCalls: [],
71 | getAction: function () {
72 | return fooAction;
73 | },
74 | executeAction: function(action, route, done) {
75 | mockContext.executeActionCalls.push(arguments);
76 | if (failedRoute.config.action === action) {
77 | done(new Error('test'));
78 | return;
79 | }
80 | done();
81 | },
82 | dispatchCalls: [],
83 | dispatch: function () {
84 | mockContext.dispatchCalls.push(arguments);
85 | }
86 | };
87 | });
88 |
89 | it ('should not call anything if the router is not set', function () {
90 | mockContext.router = undefined;
91 | navigateAction(mockContext, {
92 | url: '/'
93 | }, function () {
94 | expect(mockContext.routerCalls.length).to.equal(0);
95 | expect(mockContext.executeActionCalls.length).to.equal(0);
96 | expect(mockContext.dispatchCalls.length).to.equal(0);
97 | });
98 | });
99 |
100 | it ('should dispatch on route match', function () {
101 | navigateAction(mockContext, {
102 | url: '/'
103 | }, function (err) {
104 | expect(err).to.equal(undefined);
105 | expect(mockContext.routerCalls.length).to.equal(1);
106 | expect(mockContext.dispatchCalls.length).to.equal(2);
107 | expect(mockContext.dispatchCalls[0][0]).to.equal('CHANGE_ROUTE_START');
108 | expect(mockContext.dispatchCalls[0][1].url).to.equal('/');
109 | expect(mockContext.dispatchCalls[0][1].query).to.eql({});
110 | expect(mockContext.dispatchCalls[1][0]).to.equal('CHANGE_ROUTE_SUCCESS');
111 | expect(mockContext.dispatchCalls[1][1].url).to.equal('/');
112 | });
113 | });
114 |
115 | it ('should include query param on route match', function () {
116 | var url = '/?foo=bar&a=b&a=c&bool#abcd=fff';
117 | navigateAction(mockContext, {
118 | url: url
119 | }, function (err) {
120 | expect(err).to.equal(undefined);
121 | expect(mockContext.routerCalls.length).to.equal(1);
122 | expect(mockContext.dispatchCalls.length).to.equal(2);
123 | expect(mockContext.dispatchCalls[0][0]).to.equal('CHANGE_ROUTE_START');
124 | var route = mockContext.dispatchCalls[0][1];
125 | expect(route.url).to.equal(url);
126 | expect(route.query).to.eql({foo: 'bar', a: ['b', 'c'], bool: null}, 'query added to route payload for CHANGE_ROUTE_START' + JSON.stringify(route));
127 | expect(mockContext.dispatchCalls[1][0]).to.equal('CHANGE_ROUTE_SUCCESS');
128 | route = mockContext.dispatchCalls[1][1];
129 | expect(route.url).to.equal(url);
130 | expect(route.query).to.eql({foo: 'bar', a: ['b', 'c'], bool: null}, 'query added to route payload for CHANGE_ROUTE_SUCCESS');
131 | });
132 | });
133 |
134 | it ('should not call execute action if there is no action', function () {
135 | navigateAction(mockContext, {
136 | url: '/'
137 | }, function () {
138 | expect(mockContext.executeActionCalls.length).to.equal(0);
139 | });
140 | });
141 |
142 | it ('should call execute action if there is a action', function () {
143 | navigateAction(mockContext, {
144 | url: '/action'
145 | }, function (err) {
146 | expect(err).to.equal(undefined);
147 | expect(mockContext.dispatchCalls.length).to.equal(2);
148 | expect(mockContext.dispatchCalls[1][0]).to.equal('CHANGE_ROUTE_SUCCESS');
149 | expect(mockContext.dispatchCalls[1][1].url).to.equal('/action');
150 | expect(mockContext.executeActionCalls.length).to.equal(1);
151 | expect(mockContext.executeActionCalls[0][0]).to.equal(actionRoute.config.action);
152 | expect(mockContext.executeActionCalls[0][1].url).to.equal('/action');
153 | expect(mockContext.executeActionCalls[0][2]).to.be.a('function');
154 | });
155 | });
156 |
157 | it ('should call execute action if there is an action as a string', function () {
158 | navigateAction(mockContext, {
159 | url: '/string'
160 | }, function (err) {
161 | expect(err).to.equal(undefined);
162 | expect(mockContext.dispatchCalls.length).to.equal(2);
163 | expect(mockContext.dispatchCalls[1][0]).to.equal('CHANGE_ROUTE_SUCCESS');
164 | expect(mockContext.dispatchCalls[1][1].url).to.equal('/string');
165 | expect(mockContext.executeActionCalls.length).to.equal(1);
166 | expect(mockContext.executeActionCalls[0][0]).to.equal(fooAction);
167 | expect(mockContext.executeActionCalls[0][1].url).to.equal('/string');
168 | expect(mockContext.executeActionCalls[0][2]).to.be.a('function');
169 | });
170 | });
171 |
172 | it ('should dispatch failure if action failed', function () {
173 | navigateAction(mockContext, {
174 | url: '/fail'
175 | }, function (err) {
176 | expect(err).to.be.an('object');
177 | expect(mockContext.dispatchCalls.length).to.equal(2);
178 | expect(mockContext.dispatchCalls[1][0]).to.equal('CHANGE_ROUTE_FAILURE');
179 | expect(mockContext.dispatchCalls[1][1].url).to.equal('/fail');
180 | });
181 | });
182 |
183 | it ('should call back with a 404 error if route not found', function () {
184 | navigateAction(mockContext, {
185 | url: '/404'
186 | }, function (err) {
187 | expect(mockContext.routerCalls.length).to.equal(1);
188 | expect(err).to.be.an('object');
189 | expect(err.status).to.equal(404);
190 | });
191 | });
192 |
193 | it ('should call back with a 404 error if url matches but not method', function () {
194 | navigateAction(mockContext, {
195 | url: '/post',
196 | method: 'get'
197 | }, function (err) {
198 | expect(mockContext.routerCalls.length).to.equal(1);
199 | expect(err).to.be.an('object');
200 | expect(err.status).to.equal(404);
201 | });
202 | });
203 |
204 | it ('should dispatch if both url and method matches', function () {
205 | navigateAction(mockContext, {
206 | url: '/post',
207 | method: 'post'
208 | }, function (err) {
209 | expect(err).to.equal(undefined);
210 | expect(mockContext.routerCalls.length).to.equal(1);
211 | expect(mockContext.dispatchCalls.length).to.equal(2);
212 | expect(mockContext.dispatchCalls[0][0]).to.equal('CHANGE_ROUTE_START');
213 | expect(mockContext.dispatchCalls[0][1].url).to.equal('/post');
214 | expect(mockContext.dispatchCalls[0][1].query).to.eql({});
215 | expect(mockContext.dispatchCalls[1][0]).to.equal('CHANGE_ROUTE_SUCCESS');
216 | expect(mockContext.dispatchCalls[1][1].url).to.equal('/post');
217 | });
218 | });
219 | });
220 |
--------------------------------------------------------------------------------
/tests/unit/lib/History-test.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2014, Yahoo! Inc.
3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms.
4 | */
5 | /*globals describe,it,before,beforeEach */
6 | var History = require('../../../lib/History'),
7 | expect = require('chai').expect,
8 | _ = require('lodash'),
9 | windowMock,
10 | testResult;
11 |
12 |
13 | describe('History', function () {
14 |
15 | beforeEach(function () {
16 | testResult = {};
17 | windowMock = require('../../mocks/mockWindow')(testResult);
18 | });
19 |
20 | describe('constructor', function () {
21 | it ('has pushState', function () {
22 | var history = new History({win: windowMock.HTML5});
23 | expect(history.win).to.equal(windowMock.HTML5);
24 | expect(history._hasPushState).to.equal(true);
25 | });
26 | it ('no pushState', function () {
27 | var history = new History({win: windowMock.OLD});
28 | expect(history.win).to.equal(windowMock.OLD);
29 | expect(history._hasPushState).to.equal(false);
30 | });
31 | });
32 |
33 | describe('on', function () {
34 | it ('has pushState', function () {
35 | var history = new History({win: windowMock.HTML5});
36 | var listener = function () {};
37 | history.on(listener);
38 | expect(testResult.addEventListener).to.eql({evt: 'popstate', listener: listener});
39 | });
40 | it ('no pushState', function () {
41 | var history = new History({win: windowMock.OLD});
42 | var listener = function () {};
43 | history.on(listener);
44 | expect(testResult.addEventListener).to.eql(undefined);
45 | });
46 | });
47 |
48 | describe('off', function () {
49 | it ('has pushState', function () {
50 | var history = new History({win: windowMock.HTML5});
51 | var listener = function () {};
52 | history.off(listener);
53 | expect(testResult.removeEventListener).to.eql({evt: 'popstate', listener: listener});
54 | });
55 | it ('no pushState', function () {
56 | var history = new History({win: windowMock.OLD});
57 | var listener = function () {};
58 | history.off(listener);
59 | expect(testResult.removeEventListener).to.eql(undefined);
60 | });
61 | });
62 |
63 | describe('getState', function () {
64 | it ('has pushState', function () {
65 | var history = new History({win: _.merge({history: {state: {foo: 'bar'}}}, windowMock.HTML5)});
66 | expect(history.getState()).to.eql({foo: 'bar'});
67 | });
68 | it ('no pushState', function () {
69 | var history = new History({win: windowMock.OLD});
70 | expect(history.getState()).to.eql(null);
71 | });
72 | });
73 |
74 | describe('getUrl', function () {
75 | it ('has pushState', function () {
76 | var win = _.extend(windowMock.HTML5, {
77 | location: {
78 | pathname: '/path/to/page',
79 | search: '',
80 | hash: '#/path/to/abc'
81 | }
82 | });
83 | var history = new History({win: win});
84 | var url = history.getUrl();
85 | expect(url).to.equal('/path/to/page');
86 | });
87 | it ('has pushState with query', function () {
88 | var win = _.extend(windowMock.HTML5, {
89 | location: {
90 | pathname: '/path/to/page',
91 | search: '?foo=bar&x=y'
92 | }
93 | });
94 | var history = new History({win: win});
95 | var url = history.getUrl();
96 | expect(url).to.equal('/path/to/page?foo=bar&x=y');
97 | });
98 | it ('no pushState', function () {
99 | var win, history, url;
100 | win = _.extend(windowMock.OLD, {
101 | location: {
102 | pathname: '/path/to/page',
103 | hash: '#/path/to/abc',
104 | search: ''
105 | }
106 | });
107 | history = new History({win: win});
108 | url = history.getUrl();
109 | expect(url).to.equal('/path/to/page');
110 |
111 | win = _.extend(windowMock.OLD, {
112 | location: {
113 | pathname: '/path/to/page',
114 | hash: '#',
115 | search: ''
116 | }
117 | });
118 | history = new History({win: win});
119 | url = history.getUrl();
120 | expect(url).to.equal('/path/to/page', 'hash=#');
121 |
122 | win = _.extend(windowMock.OLD, {
123 | location: {
124 | pathname: '/path/to/page',
125 | hash: '',
126 | search: ''
127 | }
128 | });
129 | history = new History({win: win});
130 | url = history.getUrl();
131 | expect(url).to.equal('/path/to/page');
132 | });
133 | it ('no pushState, with query', function () {
134 | var win, history, url;
135 | win = _.extend(windowMock.OLD, {
136 | location: {
137 | pathname: '/path/to/page',
138 | search: '?foo=bar&x=y',
139 | hash: '#xyz'
140 | }
141 | });
142 | history = new History({win: win});
143 | url = history.getUrl();
144 | expect(url).to.equal('/path/to/page?foo=bar&x=y');
145 | });
146 | });
147 |
148 | describe('pushState', function () {
149 | it ('has pushState', function () {
150 | var win = _.extend(windowMock.HTML5, {
151 | 'document': {
152 | title: 'current title'
153 | },
154 | location: {
155 | href: '/currentUrl'
156 | }
157 | });
158 | var history = new History({win: win});
159 |
160 | history.pushState({foo: 'bar'});
161 | expect(testResult.pushState.state).to.eql({foo: 'bar'});
162 | expect(testResult.pushState.title).to.equal('current title');
163 | expect(testResult.pushState.url).to.equal('/currentUrl');
164 |
165 | history.pushState({foo: 'bar'}, 't');
166 | expect(testResult.pushState.state).to.eql({foo: 'bar'});
167 | expect(testResult.pushState.title).to.equal('t');
168 | expect(testResult.pushState.url).to.equal('/currentUrl');
169 |
170 | history.pushState({foo: 'bar'}, 't', '/url');
171 | expect(testResult.pushState.state).to.eql({foo: 'bar'});
172 | expect(testResult.pushState.title).to.equal('t');
173 | expect(testResult.pushState.url).to.equal('/url');
174 | expect(windowMock.HTML5.document.title).to.equal('t');
175 |
176 | history.pushState({foo: 'bar'}, 'tt', '/url?a=b&x=y');
177 | expect(testResult.pushState.state).to.eql({foo: 'bar'});
178 | expect(testResult.pushState.title).to.equal('tt');
179 | expect(testResult.pushState.url).to.equal('/url?a=b&x=y');
180 | expect(windowMock.HTML5.document.title).to.equal('tt');
181 | });
182 | it ('has pushState, Firefox', function () {
183 | var win = _.extend(windowMock.Firefox, {
184 | 'document': {
185 | title: 'current title'
186 | },
187 | location: {
188 | href: '/currentUrl'
189 | }
190 | });
191 | var history = new History({win: win});
192 |
193 | history.pushState({foo: 'bar'});
194 | expect(testResult.pushState.state).to.eql({foo: 'bar'});
195 | expect(testResult.pushState.title).to.equal('current title');
196 | expect(testResult.pushState.url).to.equal('/currentUrl');
197 |
198 | history.pushState({foo: 'bar'}, 't');
199 | expect(testResult.pushState.state).to.eql({foo: 'bar'});
200 | expect(testResult.pushState.title).to.equal('t');
201 | expect(testResult.pushState.url).to.equal('/currentUrl');
202 |
203 | history.pushState({foo: 'bar'}, 't', '/url');
204 | expect(testResult.pushState.state).to.eql({foo: 'bar'});
205 | expect(testResult.pushState.title).to.equal('t');
206 | expect(testResult.pushState.url).to.equal('/url');
207 | });
208 | it ('no pushState', function () {
209 | var win = _.extend(windowMock.OLD, {
210 | location: {}
211 | });
212 | var history = new History({win: win});
213 |
214 | history.pushState({foo: 'bar'}, 't', '/url');
215 | expect(win.location.href).to.equal('/url');
216 |
217 | history.pushState({foo: 'bar'}, 't', '/url?a=b&x=y');
218 | expect(win.location.href).to.equal('/url?a=b&x=y');
219 |
220 | history.pushState({foo: 'bar'});
221 | expect(win.location.href).to.equal('/url?a=b&x=y');
222 | });
223 | });
224 |
225 | describe('replaceState', function () {
226 | it ('has pushState', function () {
227 | var win = _.extend(windowMock.HTML5, {
228 | 'document': {
229 | title: 'current title'
230 | },
231 | location: {
232 | href: '/currentUrl'
233 | }
234 | });
235 | var history = new History({win: win});
236 |
237 | history.replaceState({foo: 'bar'});
238 | expect(testResult.replaceState.state).to.eql({foo: 'bar'});
239 | expect(testResult.replaceState.title).to.equal('current title');
240 | expect(testResult.replaceState.url).to.equal('/currentUrl');
241 |
242 | history.replaceState({foo: 'bar'}, 't');
243 | expect(testResult.replaceState.state).to.eql({foo: 'bar'});
244 | expect(testResult.replaceState.title).to.equal('t');
245 | expect(testResult.replaceState.url).to.equal('/currentUrl');
246 |
247 | history.replaceState({foo: 'bar'}, 't', '/url');
248 | expect(testResult.replaceState.state).to.eql({foo: 'bar'});
249 | expect(testResult.replaceState.title).to.equal('t');
250 | expect(testResult.replaceState.url).to.equal('/url');
251 | expect(windowMock.HTML5.document.title).to.equal('t');
252 |
253 | history.replaceState({foo: 'bar'}, 'tt', '/url?a=b&x=y');
254 | expect(testResult.replaceState.state).to.eql({foo: 'bar'});
255 | expect(testResult.replaceState.title).to.equal('tt');
256 | expect(testResult.replaceState.url).to.equal('/url?a=b&x=y', 'url has query');
257 | expect(windowMock.HTML5.document.title).to.equal('tt');
258 | });
259 | it ('has pushState, Firefox', function () {
260 | var win = _.extend(windowMock.Firefox, {
261 | 'document': {
262 | title: 'current title'
263 | },
264 | location: {
265 | href: '/currentUrl'
266 | }
267 | });
268 | var history = new History({win: win});
269 |
270 | history.replaceState({foo: 'bar'});
271 | expect(testResult.replaceState.state).to.eql({foo: 'bar'});
272 | expect(testResult.replaceState.title).to.equal('current title');
273 | expect(testResult.replaceState.url).to.equal('/currentUrl');
274 |
275 | history.replaceState({foo: 'bar'}, 't');
276 | expect(testResult.replaceState.state).to.eql({foo: 'bar'});
277 | expect(testResult.replaceState.title).to.equal('t');
278 | expect(testResult.replaceState.url).to.equal('/currentUrl');
279 |
280 | history.replaceState({foo: 'bar'}, 't', '/url');
281 | expect(testResult.replaceState.state).to.eql({foo: 'bar'});
282 | expect(testResult.replaceState.title).to.equal('t');
283 | expect(testResult.replaceState.url).to.equal('/url');
284 | });
285 | it ('no pushState', function () {
286 | var win = _.extend(windowMock.OLD, {
287 | location: {
288 | replace: function(url) {
289 | testResult.locationReplace = {url: url};
290 | }
291 | }
292 | });
293 | var history = new History({win: win});
294 | history.replaceState({foo: 'bar'}, 't', '/url');
295 | expect(testResult.locationReplace.url).to.equal('/url');
296 | history.replaceState({foo: 'bar'}, 't', '/url?a=b&x=y');
297 | expect(testResult.locationReplace.url).to.equal('/url?a=b&x=y');
298 | testResult.locationReplace.url = null;
299 | history.replaceState({foo: 'bar'});
300 | expect(testResult.locationReplace.url).to.equal(null);
301 | });
302 | });
303 |
304 | });
305 |
--------------------------------------------------------------------------------
/tests/unit/lib/NavLink-test.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2014, Yahoo! Inc.
3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms.
4 | */
5 | /*globals describe,it,before,beforeEach */
6 | var React;
7 | var NavLink;
8 | var ReactTestUtils;
9 | var jsdom = require('jsdom');
10 | var expect = require('chai').expect;
11 | var contextMock;
12 | var onClickMock;
13 | var routerMock;
14 | var testResult;
15 |
16 | onClickMock = function () {
17 | testResult.onClickMockInvoked = true;
18 | };
19 |
20 | routerMock = {
21 | makePath: function (name, params) {
22 | var paths = ['/' + name];
23 | if (params) {
24 | Object.keys(params).sort().forEach(function (key) {
25 | paths.push('/' + key + '/' + params[key]);
26 | });
27 | }
28 | return paths.join('');
29 | }
30 | };
31 |
32 | contextMock = {
33 | executeAction: function (action, payload) {
34 | testResult.dispatch = {
35 | action: 'NAVIGATE',
36 | payload: payload
37 | };
38 | },
39 | makePath: routerMock.makePath.bind(routerMock)
40 | };
41 |
42 | describe('NavLink', function () {
43 |
44 | beforeEach(function () {
45 | global.document = jsdom.jsdom('');
46 | global.window = global.document.parentWindow;
47 | global.navigator = global.window.navigator;
48 | React = require('react/addons');
49 | ReactTestUtils = React.addons.TestUtils;
50 | NavLink = React.createFactory(require('../../../lib/NavLink'));
51 | Wrapper = React.createFactory(require('../../mocks/MockAppComponent'));
52 | testResult = {};
53 | });
54 |
55 | afterEach(function () {
56 | delete global.window;
57 | delete global.document;
58 | delete global.navigator;
59 | });
60 |
61 | describe('render()', function () {
62 | it ('href defined', function () {
63 | var link = ReactTestUtils.renderIntoDocument(NavLink( {href:"/foo", context:contextMock}, React.DOM.span(null, "bar")));
64 | expect(link.props.href).to.equal('/foo');
65 | expect(link.getDOMNode().textContent).to.equal('bar');
66 | });
67 | it ('both href and routeName defined', function () {
68 | var link = ReactTestUtils.renderIntoDocument(NavLink( {routeName:"fooo", href:"/foo", context:contextMock}, React.DOM.span(null, "bar")));
69 | expect(link.props.href).to.equal('/foo');
70 | });
71 | it ('only routeName defined', function () {
72 | var navParams = {a: 1, b: 2};
73 | var link = React.renderToString(NavLink( {routeName:"foo", navParams:navParams, context:contextMock}, React.DOM.span(null, "bar")));
74 | expect(link).to.contain('href="/foo/a/1/b/2"');
75 | });
76 | it ('only routeName defined; use this.context.makePath', function (done) {
77 | var navParams = {a: 1, b: 2};
78 | var link = React.renderToString(Wrapper({
79 | context: contextMock
80 | }, NavLink({routeName:"foo", navParams:navParams})));
81 | expect(link).to.contain('href="/foo/a/1/b/2"');
82 | done();
83 | });
84 | it ('none defined', function () {
85 | var navParams = {a: 1, b: 2};
86 | expect(function () {
87 | ReactTestUtils.renderIntoDocument(NavLink( {navParams:navParams, context:contextMock}, React.DOM.span(null, "bar")));
88 | }).to.throw();
89 | });
90 | });
91 |
92 | describe('dispatchNavAction()', function () {
93 | it ('use react context', function (done) {
94 | var navParams = {a: 1, b: true};
95 | var link = ReactTestUtils.renderIntoDocument(NavLink({
96 | href:"/foo",
97 | preserveScrollPosition: true,
98 | navParams: navParams
99 | }, React.DOM.span(null, "bar")));
100 | link.context = contextMock;
101 | ReactTestUtils.Simulate.click(link.getDOMNode(), {button: 0});
102 | window.setTimeout(function () {
103 | expect(testResult.dispatch.action).to.equal('NAVIGATE');
104 | expect(testResult.dispatch.payload.type).to.equal('click');
105 | expect(testResult.dispatch.payload.url).to.equal('/foo');
106 | expect(testResult.dispatch.payload.preserveScrollPosition).to.equal(true);
107 | expect(testResult.dispatch.payload.params).to.eql({a: 1, b: true});
108 | done();
109 | }, 10);
110 | });
111 | it ('context.executeAction called for relative urls', function (done) {
112 | var navParams = {a: 1, b: true};
113 | var link = ReactTestUtils.renderIntoDocument(NavLink( {href:"/foo", navParams:navParams}, React.DOM.span(null, "bar")));
114 | link.context = contextMock;
115 | ReactTestUtils.Simulate.click(link.getDOMNode(), {button: 0});
116 | window.setTimeout(function () {
117 | expect(testResult.dispatch.action).to.equal('NAVIGATE');
118 | expect(testResult.dispatch.payload.type).to.equal('click');
119 | expect(testResult.dispatch.payload.url).to.equal('/foo');
120 | expect(testResult.dispatch.payload.params).to.eql({a: 1, b: true});
121 | done();
122 | }, 10);
123 | });
124 | it ('context.executeAction called for routeNames', function (done) {
125 | var link = ReactTestUtils.renderIntoDocument(NavLink( {routeName:"foo", context: contextMock}, React.DOM.span(null, "bar")));
126 | link.context = contextMock;
127 | ReactTestUtils.Simulate.click(link.getDOMNode(), {button: 0});
128 | window.setTimeout(function () {
129 | expect(testResult.dispatch.action).to.equal('NAVIGATE');
130 | expect(testResult.dispatch.payload.type).to.equal('click');
131 | expect(testResult.dispatch.payload.url).to.equal('/foo');
132 | done();
133 | }, 10);
134 | });
135 | it ('context.executeAction called for absolute urls from same origin', function (done) {
136 | var navParams = {a: 1, b: true};
137 | var origin = window.location.origin;
138 | var link = ReactTestUtils.renderIntoDocument(NavLink( {href: origin + "/foo?x=y", navParams:navParams}, React.DOM.span(null, "bar")));
139 | link.context = contextMock;
140 | ReactTestUtils.Simulate.click(link.getDOMNode(), {button: 0});
141 | window.setTimeout(function () {
142 | expect(testResult.dispatch.action).to.equal('NAVIGATE');
143 | expect(testResult.dispatch.payload.type).to.equal('click');
144 | expect(testResult.dispatch.payload.url).to.equal('/foo?x=y');
145 | expect(testResult.dispatch.payload.params).to.eql({a: 1, b: true});
146 | done();
147 | }, 10);
148 | });
149 | it ('context.executeAction not called if context does not exist', function (done) {
150 | var navParams = {a: 1, b: true};
151 | var link = ReactTestUtils.renderIntoDocument(NavLink( {href:"/foo", navParams:navParams}, React.DOM.span(null, "bar")));
152 | ReactTestUtils.Simulate.click(link.getDOMNode(), {button: 0});
153 | window.setTimeout(function () {
154 | expect(testResult.dispatch).to.equal(undefined);
155 | done();
156 | }, 10);
157 | });
158 | it ('context.executeAction not called for external urls', function (done) {
159 | var navParams = {a: 1, b: true};
160 | var link = ReactTestUtils.renderIntoDocument(NavLink( {href:"http://domain.does.not.exist/foo", navParams:navParams}, React.DOM.span(null, "bar")));
161 | ReactTestUtils.Simulate.click(link.getDOMNode(), {button: 0});
162 | window.setTimeout(function () {
163 | expect(testResult.dispatch).to.equal(undefined);
164 | done();
165 | }, 10);
166 | });
167 | it ('context.executeAction not called for # urls', function (done) {
168 | var navParams = {a: 1, b: true};
169 | var link = ReactTestUtils.renderIntoDocument(NavLink( {href:"#here", navParams:navParams}, React.DOM.span(null, "bar")));
170 | ReactTestUtils.Simulate.click(link.getDOMNode(), {button: 0});
171 | window.setTimeout(function () {
172 | expect(testResult.dispatch).to.equal(undefined);
173 | done();
174 | }, 10);
175 | });
176 | it ('context.executeAction not called if followLink=true', function (done) {
177 | var navParams = {a: 1, b: true};
178 | var link = ReactTestUtils.renderIntoDocument(NavLink( {href:"/somepath", navParams:navParams, followLink:true}, React.DOM.span(null, "bar")));
179 | link.context = contextMock;
180 | ReactTestUtils.Simulate.click(link.getDOMNode(), {button: 0});
181 | window.setTimeout(function () {
182 | expect(testResult.dispatch).to.equal(undefined);
183 | done();
184 | }, 1000);
185 | });
186 | it ('context.executeAction called if followLink=false', function (done) {
187 | var navParams = {a: 1, b: true};
188 | var link = ReactTestUtils.renderIntoDocument(NavLink( {href:"/foo", navParams:navParams, followLink:false}, React.DOM.span(null, "bar")));
189 | link.context = contextMock;
190 | ReactTestUtils.Simulate.click(link.getDOMNode(), {button: 0});
191 | window.setTimeout(function () {
192 | expect(testResult.dispatch.action).to.equal('NAVIGATE');
193 | expect(testResult.dispatch.payload.type).to.equal('click');
194 | expect(testResult.dispatch.payload.url).to.equal('/foo');
195 | expect(testResult.dispatch.payload.params).to.eql({a: 1, b: true});
196 | done();
197 | }, 10);
198 | });
199 |
200 | describe('window.onbeforeunload', function () {
201 | beforeEach(function () {
202 | global.window.confirm = function () { return false; };
203 | global.window.onbeforeunload = function () {
204 | return 'this is a test';
205 | };
206 | });
207 |
208 | it ('should not call context.executeAction when a user does not confirm the onbeforeunload method', function (done) {
209 | var navParams = {a: 1, b: true};
210 | var link = ReactTestUtils.renderIntoDocument(NavLink( {href:"/foo", navParams:navParams}, React.DOM.span(null, "bar")));
211 | link.context = contextMock;
212 | ReactTestUtils.Simulate.click(link.getDOMNode(), {button: 0});
213 | window.setTimeout(function () {
214 | expect(testResult).to.deep.equal({});
215 | done();
216 | }, 10);
217 | });
218 | });
219 | });
220 |
221 | describe('click type', function () {
222 | it('navigates on regular click', function (done) {
223 | var origin = window.location.origin;
224 | var link = ReactTestUtils.renderIntoDocument(NavLink( {href: origin, context:contextMock}, React.DOM.span(null, "bar")));
225 | ReactTestUtils.Simulate.click(link.getDOMNode(), {button: 0});
226 | window.setTimeout(function () {
227 | expect(testResult.dispatch.action).to.equal('NAVIGATE');
228 | expect(testResult.dispatch.payload.type).to.equal('click');
229 | done();
230 | }, 10);
231 | });
232 |
233 | it('navigates on regular click using replaceState', function (done) {
234 | var origin = window.location.origin;
235 | var link = ReactTestUtils.renderIntoDocument(
236 | NavLink(
237 | {href: origin, replaceState: true, context:contextMock},
238 | React.DOM.span(null, "bar")
239 | )
240 | );
241 | ReactTestUtils.Simulate.click(link.getDOMNode(), {button: 0});
242 | window.setTimeout(function () {
243 | expect(testResult.dispatch.action).to.equal('NAVIGATE');
244 | expect(testResult.dispatch.payload.type).to.equal('replacestate');
245 | done();
246 | }, 10);
247 | });
248 |
249 | ['metaKey', 'altKey', 'ctrlKey', 'shiftKey'].map(function (key) {
250 | it('does not navigate on modified ' + key, function (done) {
251 | var eventData = {button: 0};
252 | eventData[key] = true;
253 | var origin = window.location.origin;
254 | var link = ReactTestUtils.renderIntoDocument(NavLink( {href: origin, context:contextMock}, React.DOM.span(null, "bar")));
255 | ReactTestUtils.Simulate.click(link.getDOMNode(), eventData);
256 | window.setTimeout(function () {
257 | expect(testResult.dispatch).to.equal(undefined);
258 | done();
259 | }, 10);
260 | });
261 | });
262 |
263 | });
264 |
265 | it('allow overriding onClick', function (done) {
266 | var navParams = {a: 1, b: true};
267 | var link = ReactTestUtils.renderIntoDocument(NavLink( {href:"/foo", context:contextMock, navParams:navParams, onClick: onClickMock}, React.DOM.span(null, "bar")));
268 | expect(testResult.onClickMockInvoked).to.equal(undefined);
269 | ReactTestUtils.Simulate.click(link.getDOMNode(), {button: 0});
270 | window.setTimeout(function () {
271 | expect(testResult.dispatch).to.equal(undefined);
272 | expect(testResult.onClickMockInvoked).to.equal(true);
273 | done();
274 | }, 10);
275 | });
276 | });
277 |
--------------------------------------------------------------------------------
/tests/unit/lib/RouterMixin-test.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2014, Yahoo! Inc.
3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms.
4 | */
5 | /*globals describe,it,before,beforeEach */
6 | var expect = require('chai').expect;
7 | var routerMixin;
8 | var contextMock;
9 | var historyMock;
10 | var scrollToMock;
11 | var jsdom = require('jsdom');
12 | var lodash = require('lodash');
13 | var React = require('react');
14 | var testResult;
15 |
16 | contextMock = {
17 | executeAction: function (action, payload) {
18 | testResult.dispatch = {
19 | action: action,
20 | payload: payload
21 | };
22 | },
23 | makePath: function(){}
24 | };
25 |
26 | historyMock = function (url, state) {
27 | return {
28 | getUrl: function () {
29 | return url || '/the_path_from_history';
30 | },
31 | getState: function () {
32 | return state;
33 | },
34 | on: function (listener) {
35 | testResult.historyMockOn = listener;
36 | },
37 | pushState: function (state, title, url) {
38 | testResult.pushState = {
39 | state: state,
40 | title: title,
41 | url: url
42 | };
43 | },
44 | replaceState: function (state, title, url) {
45 | testResult.replaceState = {
46 | state: state,
47 | title: title,
48 | url: url
49 | };
50 | }
51 | };
52 | };
53 |
54 | scrollToMock = function (x, y) {
55 | testResult.scrollTo = {x: x, y: y};
56 | };
57 |
58 | describe ('RouterMixin', function () {
59 |
60 | beforeEach(function () {
61 | routerMixin = require('../../../lib/RouterMixin');
62 | routerMixin.props = {context: contextMock};
63 | routerMixin.state = {
64 | route: {}
65 | };
66 | global.document = jsdom.jsdom('');
67 | global.window = global.document.parentWindow;
68 | global.navigator = global.window.navigator;
69 | global.window.scrollTo = scrollToMock;
70 | testResult = {};
71 | });
72 |
73 | afterEach(function () {
74 | delete global.window;
75 | delete global.document;
76 | delete global.navigator;
77 | });
78 |
79 | it('contextTypes defined', function () {
80 | expect(routerMixin.contextTypes.executeAction).to.equal(React.PropTypes.func);
81 | expect(routerMixin.contextTypes.makePath).to.equal(React.PropTypes.func);
82 | });
83 |
84 | it('childContextTypes defined', function () {
85 | expect(routerMixin.childContextTypes.makePath).to.equal(React.PropTypes.func);
86 | });
87 |
88 | describe('getChildContext()', function() {
89 | it ('exposes childContextTypes to child context', function() {
90 | routerMixin.props = {context: contextMock };
91 | var childContext = routerMixin.getChildContext();
92 | Object.keys(routerMixin.childContextTypes).forEach(function (key) {
93 | expect(childContext[key]).to.be.a('function');
94 | }, this);
95 | });
96 | });
97 |
98 | describe('componentDidMount()', function () {
99 | it ('listen to popstate event', function () {
100 | routerMixin.componentDidMount();
101 | expect(routerMixin._historyListener).to.be.a('function');
102 | window.dispatchEvent({_type: 'popstate', state: {params: {a: 1}}});
103 | expect(testResult.dispatch.action).to.be.a('function');
104 | expect(testResult.dispatch.payload.type).to.equal('popstate');
105 | expect(testResult.dispatch.payload.url).to.equal(window.location.pathname);
106 | expect(testResult.dispatch.payload.params).to.eql({a: 1});
107 | });
108 | it ('listen to scroll event', function (done) {
109 | routerMixin.props = {context: contextMock, historyCreator: function() { return historyMock('/foo'); }};
110 | routerMixin.componentDidMount();
111 | expect(routerMixin._scrollListener).to.be.a('function');
112 | window.dispatchEvent({_type: 'scroll'});
113 | window.dispatchEvent({_type: 'scroll'});
114 | window.setTimeout(function() {
115 | expect(testResult.replaceState).to.eql({state: {scroll: {x: 0, y: 0}}, title: undefined, url: undefined});
116 | done();
117 | }, 250);
118 | });
119 | it ('dispatch navigate event for pages that url does not match', function (done) {
120 | routerMixin.props = {context: contextMock, checkRouteOnPageLoad: true, historyCreator: function() { return historyMock(); }};
121 | var origPushState = window.history.pushState;
122 | routerMixin.state = {
123 | route: {
124 | url: '/the_path_from_state'
125 | }
126 | };
127 | routerMixin.componentDidMount();
128 | window.setTimeout(function() {
129 | expect(testResult.dispatch.action).to.be.a('function');
130 | expect(testResult.dispatch.payload.type).to.equal('pageload');
131 | expect(testResult.dispatch.payload.url).to.equal('/the_path_from_history');
132 | done();
133 | }, 10);
134 | });
135 | it ('dispatch navigate event for pages that url does not match; executeAction on this.context', function (done) {
136 | routerMixin.props = {checkRouteOnPageLoad: true, historyCreator: function() { return historyMock(); }};
137 | routerMixin.context = contextMock;
138 | var origPushState = window.history.pushState;
139 | routerMixin.state = {
140 | route: {
141 | url: '/the_path_from_state'
142 | }
143 | };
144 | routerMixin.componentDidMount();
145 | window.setTimeout(function() {
146 | expect(testResult.dispatch.action).to.be.a('function');
147 | expect(testResult.dispatch.payload.type).to.equal('pageload');
148 | expect(testResult.dispatch.payload.url).to.equal('/the_path_from_history');
149 | done();
150 | }, 10);
151 | });
152 | it ('does not dispatch navigate event for pages with matching url', function (done) {
153 | routerMixin.props = {context: contextMock, historyCreator: function() { return historyMock(); }};
154 | var origPushState = window.history.pushState;
155 | routerMixin.state = {
156 | route: {
157 | url: '/the_path_from_history'
158 | }
159 | };
160 | routerMixin.componentDidMount();
161 | window.setTimeout(function() {
162 | expect(testResult.dispatch).to.equal(undefined, JSON.stringify(testResult.dispatch));
163 | done();
164 | }, 10);
165 | });
166 |
167 | describe('window.onbeforeunload', function () {
168 | beforeEach(function () {
169 | global.window.confirm = function () { return false; };
170 | global.window.onbeforeunload = function () {
171 | return 'this is a test';
172 | };
173 | });
174 |
175 | it ('should change the url back to the oldRoute if there is a window.onbeforeunload method', function (done) {
176 | routerMixin.props = {context: contextMock, historyCreator: function() { return historyMock(); }};
177 | var origPushState = window.history.pushState;
178 | routerMixin.state = {
179 | route: {
180 | url: '/the_path_from_history'
181 | }
182 | };
183 | routerMixin.componentDidMount();
184 | window.setTimeout(function() {
185 | expect(testResult.dispatch).to.equal(undefined, JSON.stringify(testResult.dispatch));
186 | done();
187 | }, 10);
188 | });
189 | });
190 |
191 | });
192 |
193 | describe('componentWillUnmount()', function () {
194 | it ('stop listening to popstate event', function () {
195 | routerMixin.componentDidMount();
196 | expect(routerMixin._historyListener).to.be.a('function');
197 | routerMixin.componentWillUnmount();
198 | expect(routerMixin._historyListener).to.equal(null);
199 | window.dispatchEvent({_type: 'popstate', state: {params: {a: 1}}});
200 | expect(testResult.dispatch).to.equal(undefined);
201 | });
202 | });
203 |
204 | describe('componentDidUpdate()', function () {
205 | it ('no-op on same route', function () {
206 | var prevRoute = {url: '/foo'},
207 | newRoute = {url: '/foo'};
208 | routerMixin.props = {context: contextMock, historyCreator: function() { return historyMock('/foo'); }};
209 | routerMixin.state = {route: newRoute};
210 | routerMixin.componentDidMount();
211 | routerMixin.componentDidUpdate({}, {route: prevRoute});
212 | expect(testResult.pushState).to.equal(undefined);
213 | });
214 | it ('do not pushState, navigate.type=popstate', function () {
215 | var oldRoute = {url: '/foo'},
216 | newRoute = {url: '/bar', navigate: {type: 'popstate'}};
217 | routerMixin.props = {context: contextMock, historyCreator: function() { return historyMock('/foo'); }};
218 | routerMixin.state = {route: newRoute};
219 | routerMixin.componentDidMount();
220 | routerMixin.componentDidUpdate({}, {route: oldRoute});
221 | expect(testResult.pushState).to.equal(undefined);
222 | });
223 | it ('update with different route, navigate.type=click, reset scroll position', function () {
224 | var oldRoute = {url: '/foo'},
225 | newRoute = {url: '/bar', navigate: {type: 'click'}};
226 | routerMixin.props = {
227 | context: contextMock,
228 | historyCreator: function() {
229 | return historyMock('/foo');
230 | }
231 | };
232 | routerMixin.state = {route: newRoute};
233 | routerMixin.componentDidMount();
234 | routerMixin.componentDidUpdate({}, {route: oldRoute});
235 | expect(testResult.pushState).to.eql({state: {params: {}, scroll: {x: 0, y: 0}}, title: null, url: '/bar'});
236 | expect(testResult.scrollTo).to.eql({x: 0, y: 0});
237 | });
238 | it ('update with different route, navigate.type=click, enableScroll=false, do not reset scroll position', function () {
239 | var oldRoute = {url: '/foo'},
240 | newRoute = {url: '/bar', navigate: {type: 'click'}};
241 | routerMixin.props = {
242 | context: contextMock,
243 | enableScroll: false,
244 | historyCreator: function() {
245 | return historyMock('/foo');
246 | }
247 | };
248 | routerMixin.state = {route: newRoute};
249 | routerMixin.componentDidMount();
250 | routerMixin.componentDidUpdate({}, {route: oldRoute});
251 | expect(testResult.pushState).to.eql({state: {params: {}}, title: null, url: '/bar'});
252 | expect(testResult.scrollTo).to.equal(undefined);
253 | });
254 | it ('update with different route, navigate.type=replacestate, enableScroll=false, do not reset scroll position', function () {
255 | var oldRoute = {url: '/foo'},
256 | newRoute = {url: '/bar', navigate: {type: 'replacestate'}};
257 | routerMixin.props = {
258 | context: contextMock,
259 | enableScroll: false,
260 | historyCreator: function() {
261 | return historyMock('/foo');
262 | }
263 | };
264 | routerMixin.state = {route: newRoute};
265 | routerMixin.componentDidMount();
266 | routerMixin.componentDidUpdate({}, {route: oldRoute});
267 | expect(testResult.replaceState).to.eql({state: {params: {}}, title: null, url: '/bar'});
268 | expect(testResult.scrollTo).to.equal(undefined);
269 | });
270 | it ('update with different route, navigate.type=default, reset scroll position', function () {
271 | var oldRoute = {url: '/foo'},
272 | newRoute = {url: '/bar'};
273 | routerMixin.props = {
274 | context: contextMock,
275 | historyCreator: function() {
276 | return historyMock('/foo');
277 | }
278 | };
279 | routerMixin.state = {route: newRoute};
280 | routerMixin.componentDidMount();
281 | routerMixin.componentDidUpdate({}, {route: oldRoute});
282 | expect(testResult.pushState).to.eql({state: {params: {}, scroll: {x: 0, y: 0}}, title: null, url: '/bar'});
283 | expect(testResult.scrollTo).to.eql({x: 0, y: 0});
284 | });
285 | it ('update with different route, navigate.type=default, enableScroll=false, do not reset scroll position', function () {
286 | var oldRoute = {url: '/foo'},
287 | newRoute = {url: '/bar'};
288 | routerMixin.props = {
289 | context: contextMock,
290 | enableScroll: false,
291 | historyCreator: function() {
292 | return historyMock('/foo');
293 | }
294 | };
295 | routerMixin.state = {route: newRoute};
296 | routerMixin.componentDidMount();
297 | routerMixin.componentDidUpdate({}, {route: oldRoute});
298 | expect(testResult.pushState).to.eql({state: {params: {}}, title: null, url: '/bar'});
299 | expect(testResult.scrollTo).to.equal(undefined);
300 | });
301 | it ('do not pushState, navigate.type=popstate, restore scroll position', function () {
302 | var oldRoute = {url: '/foo'},
303 | newRoute = {url: '/bar', navigate: {type: 'popstate'}};
304 | routerMixin.props = {
305 | context: contextMock,
306 | historyCreator: function() {
307 | return historyMock('/foo', {scroll: {x: 12, y: 200}});
308 | }
309 | };
310 | routerMixin.state = {route: newRoute};
311 | routerMixin.componentDidMount();
312 | routerMixin.componentDidUpdate({}, {route: oldRoute});
313 | expect(testResult.pushState).to.equal(undefined);
314 | expect(testResult.scrollTo).to.eql({x: 12, y: 200});
315 | });
316 | it ('do not pushState, navigate.type=popstate, enableScroll=false, restore scroll position', function () {
317 | var oldRoute = {url: '/foo'},
318 | newRoute = {url: '/bar', navigate: {type: 'popstate'}};
319 | routerMixin.props = {
320 | context: contextMock,
321 | enableScroll: false,
322 | historyCreator: function() {
323 | return historyMock('/foo', {scroll: {x: 12, y: 200}});
324 | }
325 | };
326 | routerMixin.state = {route: newRoute};
327 | routerMixin.componentDidMount();
328 | routerMixin.componentDidUpdate({}, {route: oldRoute});
329 | expect(testResult.pushState).to.equal(undefined);
330 | expect(testResult.scrollTo).to.eql(undefined);
331 | });
332 | it ('update with different route, navigate.type=click, with params', function () {
333 | var oldRoute = {url: '/foo'},
334 | newRoute = {url: '/bar', navigate: {type: 'click', params: {foo: 'bar'}}};
335 | routerMixin.props = {context: contextMock, historyCreator: function() { return historyMock('/foo'); }};
336 | routerMixin.state = {route: newRoute};
337 | routerMixin.componentDidMount();
338 | routerMixin.componentDidUpdate({}, {route: oldRoute});
339 | expect(testResult.pushState).to.eql({state: {params: {foo: 'bar'}, scroll: {x: 0, y:0}}, title: null, url: '/bar'});
340 | });
341 | it ('update with same path and different hash, navigate.type=click, with params', function () {
342 | var oldRoute = {url: '/foo#hash1'},
343 | newRoute = {url: '/foo#hash2', navigate: {type: 'click', params: {foo: 'bar'}}};
344 | routerMixin.props = {context: contextMock, historyCreator: function() { return historyMock('/foo#hash1'); }};
345 | routerMixin.state = {route: newRoute};
346 | routerMixin.componentDidMount();
347 | routerMixin.componentDidUpdate({}, {route: oldRoute});
348 | expect(testResult.pushState).to.eql({state: {params: {foo: 'bar'}, scroll: {x: 0, y:0}}, title: null, url: '/foo#hash2'});
349 | });
350 | it ('update with different route, navigate.type=replacestate, with params', function () {
351 | var oldRoute = {url: '/foo'},
352 | newRoute = {url: '/bar', navigate: {type: 'replacestate', params: {foo: 'bar'}}};
353 | routerMixin.props = {context: contextMock, historyCreator: function() { return historyMock('/foo'); }};
354 | routerMixin.state = {route: newRoute};
355 | routerMixin.componentDidMount();
356 | routerMixin.componentDidUpdate({}, {route: oldRoute});
357 | expect(testResult.replaceState).to.eql({state: {params: {foo: 'bar'}, scroll: {x: 0, y: 0}}, title: null, url: '/bar'});
358 | });
359 | it ('update with different route, navigate.type=replacestate', function () {
360 | var oldRoute = {url: '/foo'},
361 | newRoute = {url: '/bar', navigate: {type: 'replacestate'}};
362 | routerMixin.props = {context: contextMock, historyCreator: function() { return historyMock('/foo', {scroll: {x: 42, y: 3}}); }};
363 | routerMixin.state = {route: newRoute};
364 | routerMixin.componentDidMount();
365 | routerMixin.componentDidUpdate({}, {route: oldRoute});
366 | expect(testResult.replaceState).to.eql({state: {params: {}, scroll: {x: 0, y: 0}}, title: null, url: '/bar'});
367 | });
368 | it ('update with different route, navigate.type=pushstate, preserve scroll state', function () {
369 | var oldRoute = {url: '/foo'},
370 | newRoute = {url: '/bar', navigate: {type: 'click', preserveScrollPosition: true}};
371 | global.window.scrollX = 42;
372 | global.window.scrollY = 3;
373 | routerMixin.props = {context: contextMock, historyCreator: function() { return historyMock('/foo'); }};
374 | routerMixin.state = {route: newRoute};
375 | routerMixin.componentDidMount();
376 | routerMixin.componentDidUpdate({}, {route: oldRoute});
377 | expect(testResult.pushState).to.eql({state: {params: {}, scroll: {x: 42, y: 3}}, title: null, url: '/bar'});
378 | });
379 | it ('update with different route, navigate.type=replacestate, preserve scroll state', function () {
380 | var oldRoute = {url: '/foo'},
381 | newRoute = {url: '/bar', navigate: {type: 'replacestate', preserveScrollPosition: true}};
382 | global.window.scrollX = 42;
383 | global.window.scrollY = 3;
384 | routerMixin.props = {context: contextMock, historyCreator: function() { return historyMock('/foo'); }};
385 | routerMixin.state = {route: newRoute};
386 | routerMixin.componentDidMount();
387 | routerMixin.componentDidUpdate({}, {route: oldRoute});
388 | expect(testResult.replaceState).to.eql({state: {params: {}, scroll: {x: 42, y: 3}}, title: null, url: '/bar'});
389 | });
390 | });
391 |
392 | });
393 |
--------------------------------------------------------------------------------
/tests/unit/utils/HistoryWithHash-test.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2014, Yahoo! Inc.
3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms.
4 | */
5 | /*globals describe,it,before,beforeEach */
6 | var HistoryWithHash = require('../../../utils/HistoryWithHash'),
7 | expect = require('chai').expect,
8 | _ = require('lodash'),
9 | windowMock,
10 | testResult;
11 |
12 | describe('HistoryWithHash', function () {
13 |
14 | beforeEach(function () {
15 | testResult = {};
16 | windowMock = require('../../mocks/mockWindow')(testResult);
17 | });
18 |
19 | describe('constructor', function () {
20 | it ('no useHashRoute; has pushState', function () {
21 | var history = new HistoryWithHash({win: windowMock.HTML5});
22 | expect(history.win).to.equal(windowMock.HTML5);
23 | expect(history._hasPushState).to.equal(true);
24 | expect(history._popstateEvt).to.equal('popstate');
25 | expect(history._useHashRoute).to.equal(false);
26 | });
27 | it ('no useHashRoute; no pushState', function () {
28 | var history = new HistoryWithHash({win: windowMock.OLD});
29 | expect(history.win).to.equal(windowMock.OLD);
30 | expect(history._hasPushState).to.equal(false);
31 | expect(history._popstateEvt).to.equal('hashchange');
32 | expect(history._useHashRoute).to.equal(true);
33 | });
34 | it ('useHashRoute=true; has pushState', function () {
35 | var history = new HistoryWithHash({win: windowMock.HTML5, useHashRoute: true});
36 | expect(history.win).to.equal(windowMock.HTML5);
37 | expect(history._hasPushState).to.equal(true);
38 | expect(history._useHashRoute).to.equal(true);
39 | });
40 | it ('useHashRoute=false; no pushState', function () {
41 | var history = new HistoryWithHash({win: windowMock.OLD, useHashRoute: false});
42 | expect(history.win).to.equal(windowMock.OLD);
43 | expect(history._hasPushState).to.equal(false);
44 | expect(history._useHashRoute).to.equal(false);
45 | });
46 | });
47 |
48 | describe('on', function () {
49 | it ('has pushState', function () {
50 | var history = new HistoryWithHash({win: windowMock.HTML5});
51 | var listener = function () {};
52 | history.on(listener);
53 | expect(testResult.addEventListener).to.eql({evt: 'popstate', listener: listener});
54 | });
55 | it ('no pushState', function () {
56 | var history = new HistoryWithHash({win: windowMock.OLD});
57 | var listener = function () {};
58 | history.on(listener);
59 | expect(testResult.addEventListener).to.eql({evt: 'hashchange', listener: listener});
60 | });
61 | });
62 |
63 | describe('off', function () {
64 | it ('has pushState', function () {
65 | var history = new HistoryWithHash({win: windowMock.HTML5});
66 | var listener = function () {};
67 | history.off(listener);
68 | expect(testResult.removeEventListener).to.eql({evt: 'popstate', listener: listener});
69 | });
70 | it ('no pushState', function () {
71 | var history = new HistoryWithHash({win: windowMock.OLD});
72 | var listener = function () {};
73 | history.off(listener);
74 | expect(testResult.removeEventListener).to.eql({evt: 'hashchange', listener: listener});
75 | });
76 | });
77 |
78 | describe('getState', function () {
79 | it ('has pushState', function () {
80 | var history = new HistoryWithHash({win: _.merge({history: {state: {foo: 'bar'}}}, windowMock.HTML5)});
81 | expect(history.getState()).to.eql({foo: 'bar'});
82 | });
83 | it ('no pushState', function () {
84 | var history = new HistoryWithHash({win: windowMock.OLD});
85 | expect(history.getState()).to.eql(null);
86 | });
87 | });
88 |
89 | describe('getUrl', function () {
90 | it ('has pushState', function () {
91 | var win = _.extend({}, windowMock.HTML5, {
92 | location: {
93 | pathname: '/path/to/page',
94 | search: '',
95 | hash: '#/path/to/abc'
96 | }
97 | });
98 | var history = new HistoryWithHash({win: win});
99 | var url = history.getUrl();
100 | expect(url).to.equal('/path/to/page');
101 | });
102 | it ('has pushState with query', function () {
103 | var win = _.extend({}, windowMock.HTML5, {
104 | location: {
105 | pathname: '/path/to/page',
106 | search: '?foo=bar&x=y'
107 | }
108 | });
109 | var history = new HistoryWithHash({win: win});
110 | var url = history.getUrl();
111 | expect(url).to.equal('/path/to/page?foo=bar&x=y');
112 | });
113 | it ('no pushState', function () {
114 | var win, history, path;
115 | win = _.extend({}, windowMock.OLD, {
116 | location: {
117 | pathname: '/path/to/page',
118 | hash: '#/path/to/abc',
119 | search: ''
120 | }
121 | });
122 | history = new HistoryWithHash({win: win});
123 | url = history.getUrl();
124 | expect(url).to.equal('/path/to/abc');
125 |
126 | win = _.extend({}, windowMock.OLD, {
127 | location: {
128 | pathname: '/path/to/page',
129 | hash: '#',
130 | search: ''
131 | }
132 | });
133 | history = new HistoryWithHash({win: win});
134 | url = history.getUrl();
135 | expect(url).to.equal('/', 'hash=#');
136 |
137 | win = _.extend({}, windowMock.OLD, {
138 | location: {
139 | pathname: '/path/to/page',
140 | hash: '',
141 | search: ''
142 | }
143 | });
144 | history = new HistoryWithHash({win: win});
145 | url = history.getUrl();
146 | expect(url).to.equal('/');
147 |
148 | history = new HistoryWithHash({win: win, defaultHashRoute: '/default'});
149 | url = history.getUrl();
150 | expect(url).to.equal('/default');
151 | });
152 | it ('no pushState, with query', function () {
153 | var win, history, url;
154 | win = _.extend({}, windowMock.OLD, {
155 | location: {
156 | pathname: '/path/to/page',
157 | hash: '#/path/to/abc?foo=bar&x=y'
158 | }
159 | });
160 | history = new HistoryWithHash({win: win});
161 | url = history.getUrl();
162 | expect(url).to.equal('/path/to/abc?foo=bar&x=y');
163 |
164 | win = _.extend({}, windowMock.OLD, {
165 | location: {
166 | pathname: '/path/to/page',
167 | hash: '#/?foo=bar&x=y'
168 | }
169 | });
170 | history = new HistoryWithHash({win: win});
171 | url = history.getUrl();
172 | expect(url).to.equal('/?foo=bar&x=y');
173 | });
174 | });
175 |
176 | describe('pushState', function () {
177 | it ('useHashRoute=false; has pushState', function () {
178 | var win = _.extend(windowMock.HTML5, {
179 | 'document': {
180 | title: 'current title'
181 | },
182 | location: {
183 | href: '/currentUrl'
184 | }
185 | });
186 | var history = new HistoryWithHash({win: win});
187 |
188 | history.pushState({foo: 'bar'});
189 | expect(testResult.pushState.state).to.eql({foo: 'bar'});
190 | expect(testResult.pushState.title).to.equal('current title');
191 | expect(testResult.pushState.url).to.equal('/currentUrl');
192 |
193 | history.pushState({foo: 'bar'}, 't', '/url');
194 | expect(testResult.pushState.state).to.eql({foo: 'bar'});
195 | expect(testResult.pushState.title).to.equal('t');
196 | expect(testResult.pushState.url).to.equal('/url');
197 | expect(windowMock.HTML5.document.title).to.equal('t');
198 |
199 | history.pushState({foo: 'bar'}, 'tt', '/url?a=b&x=y');
200 | expect(testResult.pushState.state).to.eql({foo: 'bar'});
201 | expect(testResult.pushState.title).to.equal('tt');
202 | expect(testResult.pushState.url).to.equal('/url?a=b&x=y');
203 | expect(windowMock.HTML5.document.title).to.equal('tt');
204 | });
205 | it ('useHashRoute=false; has pushState; Firefox', function () {
206 | var win = _.extend(windowMock.Firefox, {
207 | 'document': {
208 | title: 'current title'
209 | },
210 | location: {
211 | href: '/currentUrl'
212 | }
213 | });
214 | var history = new HistoryWithHash({win: win});
215 |
216 | history.pushState({foo: 'bar'});
217 | expect(testResult.pushState.state).to.eql({foo: 'bar'});
218 | expect(testResult.pushState.title).to.equal('current title');
219 | expect(testResult.pushState.url).to.equal('/currentUrl');
220 |
221 | history.pushState({foo: 'bar'}, 't', '/url');
222 | expect(testResult.pushState.state).to.eql({foo: 'bar'});
223 | expect(testResult.pushState.title).to.equal('t');
224 | expect(testResult.pushState.url).to.equal('/url');
225 | });
226 | it ('useHashRoute=false; no pushState', function () {
227 | var win = _.extend({}, windowMock.OLD, {location: {}});
228 | var history = new HistoryWithHash({
229 | win: win,
230 | useHashRoute: false
231 | });
232 |
233 | history.pushState({foo: 'bar'}, 't', '/url');
234 | expect(win.location.href).to.equal('/url');
235 |
236 | history.pushState({foo: 'bar'}, 't', '/url?a=b&x=y');
237 | expect(win.location.href).to.equal('/url?a=b&x=y');
238 | });
239 | it ('useHashRoute=true; has pushState', function () {
240 | var win = _.extend({}, windowMock.HTML5, {
241 | location: {
242 | pathname: '/path',
243 | search: '?a=b'
244 | }
245 | });
246 | var history = new HistoryWithHash({win: win, useHashRoute: true});
247 |
248 | history.pushState({foo: 'bar'}, 't', '/url');
249 | expect(testResult.pushState.state).to.eql({foo: 'bar'});
250 | expect(testResult.pushState.title).to.equal('t');
251 | expect(testResult.pushState.url).to.equal('/path?a=b#/url');
252 |
253 | history.pushState({foo: 'bar'}, 't', '/url?a=b&x=y');
254 | expect(testResult.pushState.state).to.eql({foo: 'bar'});
255 | expect(testResult.pushState.title).to.equal('t');
256 | expect(testResult.pushState.url).to.equal('/path?a=b#/url?a=b&x=y');
257 | });
258 | it ('useHashRoute=true; has pushState; has hashRouteTransformer', function () {
259 | var win = _.extend({}, windowMock.HTML5, {
260 | location: {
261 | pathname: '/path',
262 | search: '?a=b'
263 | }
264 | });
265 | var history = new HistoryWithHash({
266 | win: win,
267 | useHashRoute: true,
268 | hashRouteTransformer: {
269 | transform: function (hash) {
270 | return hash.replace(/\//g, '-');
271 | }
272 | }
273 | });
274 |
275 | history.pushState({foo: 'bar'}, 't', '/url');
276 | expect(testResult.pushState.state).to.eql({foo: 'bar'});
277 | expect(testResult.pushState.title).to.equal('t');
278 | expect(testResult.pushState.url).to.equal('/path?a=b#-url');
279 |
280 | history.pushState({foo: 'bar'}, 't', '/url?a=b&x=y');
281 | expect(testResult.pushState.state).to.eql({foo: 'bar'});
282 | expect(testResult.pushState.title).to.equal('t');
283 | expect(testResult.pushState.url).to.equal('/path?a=b#-url?a=b&x=y');
284 | });
285 | it ('useHashRoute=true; no pushState', function () {
286 | var win = _.extend({}, windowMock.OLD, {
287 | location: {}
288 | });
289 | var history = new HistoryWithHash({win: win, useHashRoute: true});
290 |
291 | history.pushState({foo: 'bar'}, 't', '/url');
292 | expect(win.location.hash).to.equal('#/url');
293 |
294 | history.pushState({foo: 'bar'}, 't', '/url?a=b&x=y');
295 | expect(win.location.hash).to.equal('#/url?a=b&x=y');
296 | });
297 | });
298 |
299 | describe('replaceState', function () {
300 | it ('useHashRouter=false; has pushState', function () {
301 | // var history = new HistoryWithHash({win: windowMock.HTML5});
302 | var win = _.extend(windowMock.HTML5, {
303 | 'document': {
304 | title: 'current title'
305 | },
306 | location: {
307 | href: '/currentUrl'
308 | }
309 | });
310 | var history = new HistoryWithHash({win: win});
311 |
312 | history.replaceState({foo: 'bar'});
313 | expect(testResult.replaceState.state).to.eql({foo: 'bar'});
314 | expect(testResult.replaceState.title).to.equal('current title');
315 | expect(testResult.replaceState.url).to.equal('/currentUrl');
316 |
317 | history.replaceState({foo: 'bar'}, 't', '/url');
318 | expect(testResult.replaceState.state).to.eql({foo: 'bar'});
319 | expect(testResult.replaceState.title).to.equal('t');
320 | expect(testResult.replaceState.url).to.equal('/url');
321 | expect(windowMock.HTML5.document.title).to.equal('t');
322 |
323 | history.replaceState({foo: 'bar'}, 'tt', '/url?a=b&x=y');
324 | expect(testResult.replaceState.state).to.eql({foo: 'bar'});
325 | expect(testResult.replaceState.title).to.equal('tt');
326 | expect(testResult.replaceState.url).to.equal('/url?a=b&x=y', 'url has query');
327 | expect(windowMock.HTML5.document.title).to.equal('tt');
328 | });
329 | it ('useHashRouter=false; has pushState; Firefox', function () {
330 | var win = _.extend(windowMock.Firefox, {
331 | 'document': {
332 | title: 'current title'
333 | },
334 | location: {
335 | href: '/currentUrl'
336 | }
337 | });
338 | var history = new HistoryWithHash({win: win});
339 |
340 | history.replaceState({foo: 'bar'});
341 | expect(testResult.replaceState.state).to.eql({foo: 'bar'});
342 | expect(testResult.replaceState.title).to.equal('current title');
343 | expect(testResult.replaceState.url).to.equal('/currentUrl');
344 |
345 | history.replaceState({foo: 'bar'}, 't', '/url');
346 | expect(testResult.replaceState.state).to.eql({foo: 'bar'});
347 | expect(testResult.replaceState.title).to.equal('t');
348 | expect(testResult.replaceState.url).to.equal('/url');
349 | });
350 | it ('useHashRouter=false; no pushState', function () {
351 | var win = _.extend({}, windowMock.OLD, {
352 | location: {
353 | pathname: '/path',
354 | search: '?foo=bar',
355 | replace: function(url) {
356 | testResult.locationReplace = {url: url};
357 | }
358 | }
359 | });
360 | var history = new HistoryWithHash({
361 | win: win,
362 | useHashRoute: false
363 | });
364 | history.replaceState({foo: 'bar'}, 't', '/url');
365 | expect(testResult.locationReplace.url).to.equal('/url');
366 |
367 | history.replaceState({foo: 'bar'}, 't', '/url?a=b&x=y');
368 | expect(testResult.locationReplace.url).to.equal('/url?a=b&x=y');
369 |
370 | testResult.locationReplace.url = null;
371 | history.replaceState({foo: 'bar'});
372 | expect(testResult.locationReplace.url).to.equal(null);
373 | });
374 | it ('useHashRouter=true; has pushState', function () {
375 | var win = _.extend({}, windowMock.HTML5, {
376 | location: {
377 | pathname: '/path',
378 | search: '?foo=bar'
379 | }
380 | });
381 | var history = new HistoryWithHash({win: win, useHashRoute: true});
382 | history.replaceState({foo: 'bar'}, 't', '/url');
383 | expect(testResult.replaceState.state).to.eql({foo: 'bar'});
384 | expect(testResult.replaceState.title).to.equal('t');
385 | expect(testResult.replaceState.url).to.equal('/path?foo=bar#/url');
386 |
387 | history.replaceState({foo: 'bar'}, 't', '/url?a=b&x=y');
388 | expect(testResult.replaceState.state).to.eql({foo: 'bar'});
389 | expect(testResult.replaceState.title).to.equal('t');
390 | expect(testResult.replaceState.url).to.equal('/path?foo=bar#/url?a=b&x=y', 'url has query');
391 |
392 | });
393 | it ('useHashRouter=true; has pushState; has hashRouteTransformer', function () {
394 | var win = _.extend({}, windowMock.HTML5, {
395 | location: {
396 | pathname: '/path',
397 | search: '?foo=bar'
398 | }
399 | });
400 | var history = new HistoryWithHash({
401 | win: win,
402 | useHashRoute: true,
403 | hashRouteTransformer: {
404 | transform: function (hash) {
405 | return hash.replace(/\//g, '-').replace(/\?/g, '+');
406 | }
407 | }
408 | });
409 | history.replaceState({foo: 'bar'}, 't', '/url');
410 | expect(testResult.replaceState.state).to.eql({foo: 'bar'});
411 | expect(testResult.replaceState.title).to.equal('t');
412 | expect(testResult.replaceState.url).to.equal('/path?foo=bar#-url');
413 |
414 | history.replaceState({foo: 'bar'}, 't', '/url?a=b&x=y');
415 | expect(testResult.replaceState.state).to.eql({foo: 'bar'});
416 | expect(testResult.replaceState.title).to.equal('t');
417 | expect(testResult.replaceState.url).to.equal('/path?foo=bar#-url+a=b&x=y', 'url has query');
418 |
419 | });
420 | it ('useHashRoute=true; no pushState', function () {
421 | var win = _.extend({}, windowMock.OLD, {
422 | location: {
423 | pathname: '/path',
424 | search: '?foo=bar',
425 | replace: function(url) {
426 | testResult.locationReplace = {url: url};
427 | }
428 | }
429 | });
430 | var history = new HistoryWithHash({win: win, useHashRoute: true});
431 | history.replaceState({foo: 'bar'}, 't', '/url');
432 | expect(testResult.locationReplace.url).to.equal('/path?foo=bar#/url');
433 | history.replaceState({foo: 'bar'}, 't', '/url?a=b&x=y');
434 | expect(testResult.locationReplace.url).to.equal('/path?foo=bar#/url?a=b&x=y');
435 | testResult.locationReplace.url = null;
436 | history.replaceState({foo: 'bar'});
437 | expect(testResult.locationReplace.url).to.equal(null);
438 | });
439 | });
440 |
441 | });
442 |
--------------------------------------------------------------------------------
/utils/HistoryWithHash.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2014, Yahoo! Inc.
3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms.
4 | */
5 | /*global window */
6 | 'use strict';
7 |
8 | function isUndefined(v) {
9 | return v === undefined;
10 | }
11 |
12 | /**
13 | * @class HistoryWithHash
14 | * @constructor
15 | * @param {Object} [options] The options object
16 | * @param {Window} [options.win=window] The window object
17 | * @param {Boolean} [options.useHashRoute] Whether to use hash for routing url.
18 | * If nothing specified, it will be evaluated as true if pushState feature
19 | * is not available in the window object's history object; false otherwise.
20 | * @param {String} [options.defaultHashRoute='/'] Only used when options.useHashRoute is enabled and
21 | the location url does not have any hash fragment.
22 | * @param {Object} [options.hashRouteTransformer] A custom transformer can be provided
23 | * to transform the hash to the desired syntax.
24 | * @param {Function} [options.hashRouteTransformer.transform] transforms hash route string
25 | * to custom syntax to be used in setting browser history state or url.
26 | * E.g. transforms from '/about/this/path' to 'about-this-path'
27 | * @param {Function} [options.hashRouteTransformer.reverse] reverse-transforms
28 | * hash route string of custom syntax back to standard url syntax.
29 | * E.g. transforms 'about-this-path' back to '/about/this/path'
30 | */
31 | function HistoryWithHash(options) {
32 | options = options || {};
33 | this.win = options.win || window;
34 |
35 | this._hasPushState = !!(this.win && this.win.history && this.win.history.pushState);
36 | this._popstateEvt = this._hasPushState ? 'popstate' : 'hashchange';
37 |
38 | // check whether to use hash for routing
39 | if (typeof options.useHashRoute === 'boolean') {
40 | this._useHashRoute = options.useHashRoute;
41 | } else {
42 | // default behavior is to check whether browser has pushState support
43 | this._useHashRoute = !this._hasPushState;
44 | }
45 | this._defaultHashRoute = options.defaultHashRoute || '/';
46 |
47 | // allow custom syntax for hash
48 | if (options.hashRouteTransformer) {
49 | this._hashRouteTransformer = options.hashRouteTransformer;
50 | }
51 | }
52 |
53 | HistoryWithHash.prototype = {
54 | /**
55 | * Add the given listener for 'popstate' event (fall backs to 'hashchange' event
56 | * for browsers don't support popstate event).
57 | * @method on
58 | * @param {Function} listener
59 | */
60 | on: function (listener) {
61 | this.win.addEventListener(this._popstateEvt, listener);
62 | },
63 |
64 | /**
65 | * Remove the given listener for 'popstate' event (fall backs to 'hashchange' event
66 | * for browsers don't support popstate event).
67 | * @method off
68 | * @param {Function} listener
69 | */
70 | off: function (listener) {
71 | this.win.removeEventListener(this._popstateEvt, listener);
72 | },
73 |
74 | /**
75 | * Returns the hash fragment in current window location.
76 | * @method _getHashRoute
77 | * @return {String} The hash fragment string (without the # prefix).
78 | * @private
79 | */
80 | _getHashRoute: function () {
81 | var hash = this.win.location.hash,
82 | transformer = this._hashRouteTransformer;
83 |
84 | // remove the '#' prefix
85 | hash = hash.substring(1) || this._defaultHashRoute;
86 |
87 | return (transformer && transformer.reverse) ? transformer.reverse(hash) : hash;
88 | },
89 |
90 | /**
91 | * @method getState
92 | * @return {Object|null} The state object in history
93 | */
94 | getState: function () {
95 | return (this.win.history && this.win.history.state) || null;
96 | },
97 |
98 | /**
99 | * Gets the path string (or hash fragment if the history object is
100 | * configured to use hash for routing),
101 | * including the pathname and search query (if it exists).
102 | * @method getUrl
103 | * @return {String} The url string that denotes current path and query
104 | */
105 | getUrl: function () {
106 | var location = this.win.location,
107 | path = location.pathname + location.search;
108 |
109 | if (this._useHashRoute) {
110 | return this._getHashRoute();
111 | }
112 | return path;
113 | },
114 |
115 | /**
116 | * Same as HTML5 pushState API, but with old browser support
117 | * @method pushState
118 | * @param {Object} state The state object
119 | * @param {String} title The title string
120 | * @param {String} url The new url
121 | */
122 | pushState: function (state, title, url) {
123 | var win = this.win,
124 | history = win.history,
125 | location = win.location,
126 | hash,
127 | transformer = this._hashRouteTransformer;
128 |
129 | if (this._useHashRoute) {
130 | hash = (transformer && transformer.transform) ? transformer.transform(url) : url;
131 | if (hash) {
132 | hash = '#' + hash;
133 | }
134 | if (this._hasPushState) {
135 | url = hash ? location.pathname + location.search + hash : null;
136 | history.pushState(state, title, url);
137 | this.setTitle(title);
138 | } else if (hash) {
139 | location.hash = hash;
140 | }
141 | } else {
142 | if (this._hasPushState) {
143 | title = isUndefined(title) ? win.document.title : title;
144 | url = isUndefined(url) ? win.location.href : url;
145 | history.pushState(state, title, url);
146 | this.setTitle(title);
147 | } else if (url) {
148 | location.href = url;
149 | }
150 | }
151 | },
152 |
153 | /**
154 | * Same as HTML5 replaceState API, but with old browser support
155 | * @method replaceState
156 | * @param {Object} state The state object
157 | * @param {String} title The title string
158 | * @param {String} url The new url
159 | */
160 | replaceState: function (state, title, url) {
161 | var win = this.win,
162 | history = win.history,
163 | location = win.location,
164 | hash,
165 | transformer = this._hashRouteTransformer;
166 |
167 | if (this._useHashRoute) {
168 | hash = (transformer && transformer.transform) ? transformer.transform(url) : url;
169 | if (hash) {
170 | hash = '#' + hash;
171 | }
172 | if (this._hasPushState) {
173 | url = hash ? (location.pathname + location.search + hash) : null;
174 | history.replaceState(state, title, url);
175 | this.setTitle(title);
176 | } else if (url) {
177 | url = location.pathname + location.search + hash;
178 | location.replace(url);
179 | }
180 | } else {
181 | if (this._hasPushState) {
182 | title = isUndefined(title) ? win.document.title : title;
183 | url = isUndefined(url) ? win.location.href : url;
184 | history.replaceState(state, title, url);
185 | this.setTitle(title);
186 | } else if (url) {
187 | location.replace(url);
188 | }
189 | }
190 | },
191 |
192 | /**
193 | * Sets document title. No-op if title is empty.
194 | * @param {String} title The title string.
195 | */
196 | setTitle: function (title) {
197 | if (title) {
198 | this.win.document.title = title;
199 | }
200 | }
201 | };
202 |
203 | module.exports = HistoryWithHash;
204 |
--------------------------------------------------------------------------------
/utils/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2014, Yahoo! Inc.
3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms.
4 | */
5 | 'use strict';
6 |
7 | module.exports = {
8 | HistoryWithHash: require('./HistoryWithHash')
9 | };
10 |
--------------------------------------------------------------------------------