├── .babelrc ├── src ├── actionTypes │ └── index.js ├── components │ ├── index.js │ ├── Base.js │ ├── Provider.js │ ├── Fragment.js │ └── Link.js ├── error.js ├── reducer.js ├── index.js ├── parsers │ ├── locationToState.js │ ├── util.js │ ├── routeToLocation.js │ └── locationToRoute.js ├── actions │ └── index.js ├── router.js ├── constants.js ├── middleware.js └── store-enhancer.js ├── examples ├── src │ ├── index.css │ ├── components │ │ ├── CurrentRoute │ │ │ ├── CurrentRoute.css │ │ │ └── index.js │ │ ├── App │ │ │ ├── App.test.js │ │ │ ├── App.css │ │ │ └── App.js │ │ └── Lorem │ │ │ └── index.js │ ├── index.js │ ├── store │ │ └── index.js │ └── routes.js ├── public │ ├── favicon.ico │ └── index.html ├── .gitignore └── package.json ├── test ├── index.spec.js ├── helpers │ └── setup.js ├── parsers │ └── util.spec.js └── mock │ └── routes.js ├── .travis.yml ├── .npmignore ├── .gitignore ├── package.json ├── CHANGELOG.md ├── .eslintrc.js └── README.md /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["stage-0", "es2015", "react"] 3 | } -------------------------------------------------------------------------------- /src/actionTypes/index.js: -------------------------------------------------------------------------------- 1 | export { ACTION_TYPES as default } from '../constants'; -------------------------------------------------------------------------------- /examples/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 20px; 4 | font-family: sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /examples/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/auru/redux-unity-router/HEAD/examples/public/favicon.ico -------------------------------------------------------------------------------- /test/index.spec.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import router from '../src/'; 3 | 4 | test.failing('no tests', t => { 5 | t.fail() 6 | }); -------------------------------------------------------------------------------- /src/components/index.js: -------------------------------------------------------------------------------- 1 | export { default as Link } from './Link'; 2 | export { default as RouterProvider } from './Provider'; 3 | export { default as Fragment } from './Fragment'; -------------------------------------------------------------------------------- /test/helpers/setup.js: -------------------------------------------------------------------------------- 1 | const JSDOM = require('jsdom').JSDOM; 2 | 3 | global.document = new JSDOM(''); 4 | global.window = document.window; 5 | global.navigator = window.navigator; -------------------------------------------------------------------------------- /src/error.js: -------------------------------------------------------------------------------- 1 | function RouterError(message) { 2 | this.name = 'RouterError'; 3 | this.message = (message || ''); 4 | } 5 | RouterError.prototype = Error.prototype; 6 | 7 | export default RouterError; -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" 4 | cache: 5 | directories: 6 | - node_modules 7 | branches: 8 | only: 9 | - master 10 | after_success: 11 | - 'npm run coverage:send' 12 | -------------------------------------------------------------------------------- /examples/src/components/CurrentRoute/CurrentRoute.css: -------------------------------------------------------------------------------- 1 | .current-route 2 | { 3 | margin: 0 0 12px; 4 | } 5 | 6 | .current-route__info 7 | { 8 | font-size: 18px; 9 | } 10 | 11 | .current-route__item 12 | { 13 | margin: 6px 0; 14 | } -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # testing 7 | coverage 8 | 9 | # production 10 | build 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | npm-debug.log 16 | -------------------------------------------------------------------------------- /examples/src/components/App/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | }); 9 | -------------------------------------------------------------------------------- /examples/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './components/App/App'; 4 | import './index.css'; 5 | 6 | import store from './store'; 7 | import { Provider } from 'react-redux'; 8 | 9 | ReactDOM.render( 10 | 11 | 12 | , 13 | document.getElementById('root') 14 | ); 15 | -------------------------------------------------------------------------------- /src/reducer.js: -------------------------------------------------------------------------------- 1 | import { ACTION_TYPES } from './constants'; 2 | 3 | export default ({ locationParser, immutable }) => (state = immutable ? require('immutable').fromJS({}) : {}, { type, payload }) => { 4 | 5 | if (type === ACTION_TYPES.LOCATION_CHANGED) { 6 | 7 | const result = locationParser(payload); 8 | 9 | return immutable ? require('immutable').fromJS(result) : result; 10 | } 11 | 12 | return state; 13 | }; -------------------------------------------------------------------------------- /examples/src/components/App/App.css: -------------------------------------------------------------------------------- 1 | .App 2 | { 3 | display: flex; 4 | } 5 | 6 | .app__navigation 7 | { 8 | flex: 2; 9 | } 10 | 11 | .app__navigation a 12 | { 13 | display: block; 14 | margin: 10px 0; 15 | } 16 | 17 | .app__content 18 | { 19 | flex: 8; 20 | margin: 10px 0; 21 | padding: 0 30px; 22 | } 23 | 24 | .app__content h2 25 | { 26 | margin-top: 0; 27 | } 28 | 29 | .link__active 30 | { 31 | font-weight: bold; 32 | } -------------------------------------------------------------------------------- /test/parsers/util.spec.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { flattenRoutes } from '../../src/parsers/util'; 3 | 4 | import { initialRoutes, expectedRoutes } from '../mock/routes'; 5 | 6 | test('flattenRoutes', t => { 7 | const flatRoutes = flattenRoutes(initialRoutes); 8 | 9 | flatRoutes.map( (flatRoute, index) => { 10 | t.deepEqual( 11 | flatRoutes[index], 12 | expectedRoutes[index], 13 | `should work for route with id ${expectedRoutes[index].id}` 14 | ); 15 | }) 16 | 17 | }); -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory, createHashHistory, createMemoryHistory } from 'history'; 2 | 3 | export const History = { 4 | createBrowserHistory, 5 | createHashHistory, 6 | createMemoryHistory 7 | }; 8 | 9 | export { default as ACTION_TYPES } from './actionTypes'; 10 | export { default as actionTypes } from './actionTypes'; 11 | export { default as createRouter } from './router'; 12 | export * as actions from './actions'; 13 | export { Link } from './components'; 14 | export { RouterProvider } from './components'; 15 | export { Fragment } from './components'; 16 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-unity-router-examples", 3 | "private": true, 4 | "devDependencies": { 5 | "react-scripts": "0.6.1" 6 | }, 7 | "dependencies": { 8 | "immutable": "^3.8.1", 9 | "prop-types": "^15.6.0", 10 | "react": "^15.3.2", 11 | "react-dom": "^15.3.2", 12 | "react-redux": "^4.4.5", 13 | "redux": "^3.6.0", 14 | "redux-immutable": "^3.0.8" 15 | }, 16 | "scripts": { 17 | "start": "react-scripts start", 18 | "build": "react-scripts build", 19 | "test": "react-scripts test --env=jsdom", 20 | "eject": "react-scripts eject" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/parsers/locationToState.js: -------------------------------------------------------------------------------- 1 | import { parse as qsParse } from 'query-string'; 2 | import createLocationToRouteParser from './locationToRoute'; 3 | 4 | const parseLocation = locationToRoute => location => { 5 | const query = qsParse(location.search); 6 | const state = location.state || {}; 7 | const path = location.pathname + location.search; 8 | const route = locationToRoute(path); 9 | 10 | return { 11 | ...location, 12 | query, 13 | state, 14 | path, 15 | route 16 | }; 17 | }; 18 | 19 | const createLocationToStateParser = routes => { 20 | const locationToRoute = createLocationToRouteParser(routes); 21 | return parseLocation(locationToRoute); 22 | }; 23 | 24 | export default createLocationToStateParser; -------------------------------------------------------------------------------- /src/actions/index.js: -------------------------------------------------------------------------------- 1 | import { ACTION_TYPES } from '../constants'; 2 | 3 | export const locationChange = payload => ({ 4 | type: ACTION_TYPES.LOCATION_CHANGED, 5 | payload 6 | }); 7 | 8 | export const push = payload => ({ 9 | type: ACTION_TYPES.PUSH, 10 | payload 11 | }); 12 | 13 | export const replace = payload => ({ 14 | type: ACTION_TYPES.REPLACE, 15 | payload 16 | }); 17 | 18 | export const go = payload => ({ 19 | type: ACTION_TYPES.GO, 20 | payload 21 | }); 22 | 23 | export const goBack = payload => ({ 24 | type: ACTION_TYPES.GO_BACK, 25 | payload 26 | }); 27 | 28 | export const goForward = payload => ({ 29 | type: ACTION_TYPES.GO_FORWARD, 30 | payload 31 | }); 32 | 33 | export const goToRoute = payload => ({ 34 | type: ACTION_TYPES.GO_TO_ROUTE, 35 | payload 36 | }); -------------------------------------------------------------------------------- /src/router.js: -------------------------------------------------------------------------------- 1 | import enhancer from './store-enhancer'; 2 | import reducer from './reducer'; 3 | import middleware from './middleware'; 4 | 5 | import createLocationParser from './parsers/locationToState'; 6 | import createRouteParser from './parsers/routeToLocation'; 7 | import { DEFAULT_SLICE } from './constants'; 8 | 9 | const createRouter = ({ 10 | history, 11 | routes, 12 | slice = DEFAULT_SLICE, 13 | immutable = false 14 | }) => { 15 | const locationParser = createLocationParser(routes); 16 | const routeParser = createRouteParser(routes); 17 | 18 | return { 19 | reducer: reducer({ locationParser, immutable }), 20 | enhancer: enhancer({ history, slice, locationParser, immutable }), 21 | middleware: middleware({ history, routeParser }) 22 | }; 23 | }; 24 | 25 | export default createRouter; -------------------------------------------------------------------------------- /examples/src/store/index.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from 'redux'; 2 | import { combineReducers } from 'redux-immutable'; 3 | import { fromJS } from 'immutable'; 4 | 5 | import routes from '../routes'; 6 | 7 | import { createRouter, History } from '../../../dist'; 8 | 9 | const history = History.createBrowserHistory(); 10 | 11 | const slice = 'router'; 12 | const router = createRouter({ history, routes, slice, immutable: true }); 13 | const middleware = [router.middleware]; 14 | const toEnhance = [ 15 | router.enhancer, 16 | applyMiddleware(...middleware), 17 | window && window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__() 18 | ].filter(Boolean); 19 | const enhancer = compose(...toEnhance); 20 | const reducers = combineReducers({ 21 | [slice]: router.reducer 22 | }); 23 | 24 | const store = createStore(reducers, fromJS({}), enhancer); 25 | 26 | export default store; -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | 13 | # Directory for instrumented libs generated by jscoverage/JSCover 14 | lib-cov 15 | 16 | # Coverage directory used by tools like istanbul 17 | coverage 18 | 19 | # nyc test coverage 20 | .nyc_output 21 | 22 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 23 | .grunt 24 | 25 | # node-waf configuration 26 | .lock-wscript 27 | 28 | # Compiled binary addons (http://nodejs.org/api/addons.html) 29 | build/Release 30 | 31 | # Dependency directories 32 | node_modules 33 | jspm_packages 34 | 35 | # Optional npm cache directory 36 | .npm 37 | 38 | # Optional REPL history 39 | .node_repl_history 40 | 41 | ############################################################################################################ 42 | 43 | .npmrc 44 | .idea 45 | examples -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | 13 | # Directory for instrumented libs generated by jscoverage/JSCover 14 | lib-cov 15 | 16 | # Coverage directory used by tools like istanbul 17 | coverage 18 | 19 | # nyc test coverage 20 | .nyc_output 21 | 22 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 23 | .grunt 24 | 25 | # node-waf configuration 26 | .lock-wscript 27 | 28 | # Compiled binary addons (http://nodejs.org/api/addons.html) 29 | build/Release 30 | 31 | # Dependency directories 32 | node_modules 33 | jspm_packages 34 | 35 | # Optional npm cache directory 36 | .npm 37 | 38 | # Optional REPL history 39 | .node_repl_history 40 | 41 | ############################################################################################################ 42 | 43 | .npmrc 44 | dist 45 | umd 46 | 47 | .idea 48 | .DS_STORE 49 | package-lock.json -------------------------------------------------------------------------------- /examples/src/components/CurrentRoute/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | 5 | import './CurrentRoute.css'; 6 | 7 | const CurrentRoute = ({ path, routeId, matcher }) => ( 8 |
9 |
Info
10 |
Path: {path}
11 |
Id: {routeId}
12 |
Matcher: {JSON.stringify(matcher)}
13 |
14 | ); 15 | 16 | CurrentRoute.propTypes = { 17 | path: PropTypes.string, 18 | routeId: PropTypes.string, 19 | matcher: PropTypes.string 20 | }; 21 | 22 | function mapStateToProps(state) { 23 | return { 24 | path: state.getIn([ 'router', 'path' ], ''), 25 | routeId: state.getIn([ 'router', 'route', 'idPath' ], ''), 26 | matcher: state.getIn([ 'router', 'route', 'pattern' ]) 27 | }; 28 | } 29 | 30 | export default connect(mapStateToProps)(CurrentRoute); 31 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const ACTION_PREFIX = '@@REDUX_UNITY_ROUTER'; 2 | 3 | export const ACTION_TYPES = { 4 | LOCATION_CHANGED: `${ACTION_PREFIX}/LOCATION_CHANGED`, 5 | PUSH: `${ACTION_PREFIX}/PUSH`, 6 | REPLACE: `${ACTION_PREFIX}/REPLACE`, 7 | GO: `${ACTION_PREFIX}/GO`, 8 | GO_BACK: `${ACTION_PREFIX}/GO_BACK`, 9 | GO_FORWARD: `${ACTION_PREFIX}/GO_FORWARD`, 10 | GO_TO_ROUTE: `${ACTION_PREFIX}/GO_TO_ROUTE` 11 | }; 12 | 13 | export const HISTORY_METHODS = { 14 | [ACTION_TYPES.PUSH]: 'push', 15 | [ACTION_TYPES.REPLACE]: 'replace', 16 | [ACTION_TYPES.GO]: 'go', 17 | [ACTION_TYPES.GO_BACK]: 'goBack', 18 | [ACTION_TYPES.GO_FORWARD]: 'goForward' 19 | }; 20 | 21 | export const __DEV__ = process.env.NODE_ENV === 'development'; 22 | export const __PROD__ = !__DEV__; 23 | 24 | export const ID_DELIM = ':'; 25 | 26 | export const DEFAULT_SLICE = 'router'; 27 | 28 | export const LINK_MATCH_EXACT = 'exact'; 29 | export const LINK_MATCH_PARTIAL = 'partial'; 30 | export const LINK_CLASSNAME = 'link'; 31 | export const LINK_DEFAULT_METHOD = 'push'; 32 | export const LINK_ACTIVE_CLASSNAME = 'link__active'; 33 | -------------------------------------------------------------------------------- /src/middleware.js: -------------------------------------------------------------------------------- 1 | import { ACTION_PREFIX, ACTION_TYPES, HISTORY_METHODS} from './constants'; 2 | import { parsePath } from 'history'; 3 | 4 | export default ({ history, routeParser }) => ({ dispatch, getState }) => next => action => { 5 | 6 | if (action.type.indexOf(ACTION_PREFIX) === 0 && action.type !== ACTION_TYPES.LOCATION_CHANGED) { 7 | 8 | if (action.type === ACTION_TYPES.GO_TO_ROUTE) { 9 | action.type = ACTION_TYPES.PUSH; 10 | action.payload = routeParser(action.payload); 11 | } 12 | 13 | if ([ACTION_TYPES.PUSH, ACTION_TYPES.REPLACE].includes(action.type)) { 14 | 15 | action.payload = typeof action.payload === 'string' ? parsePath(action.payload) : action.payload; 16 | 17 | const sameLocation = history.location.pathname === action.payload.pathname 18 | && history.location.search === action.payload.search 19 | && history.location.hash === action.payload.hash; 20 | 21 | action.type = sameLocation ? ACTION_TYPES.REPLACE : action.type; 22 | } 23 | 24 | if (HISTORY_METHODS[action.type]) { 25 | history[HISTORY_METHODS[action.type]](action.payload); 26 | } 27 | 28 | return; 29 | } 30 | 31 | return next(action); // eslint-disable-line consistent-return 32 | }; -------------------------------------------------------------------------------- /src/components/Base.js: -------------------------------------------------------------------------------- 1 | import { PureComponent } from 'react'; 2 | import { DEFAULT_SLICE } from '../constants'; 3 | 4 | class BaseRouterComponent extends PureComponent { 5 | 6 | constructor(props, context) { 7 | super(props, context); 8 | 9 | const { store, router } = context; 10 | this.store = store; 11 | this.router = router; 12 | 13 | this.handleStoreChange = this.handleStoreChange.bind(this); 14 | 15 | this.unsubscribe = store && store.subscribe(this.handleStoreChange); 16 | } 17 | 18 | componentDidMount() { 19 | return this.handleStoreChange(); 20 | } 21 | 22 | componentWillUnmount() { 23 | 24 | if (this.isSubscribed) { 25 | this.unsubscribe(); 26 | delete this.unsubscribe; 27 | } 28 | } 29 | 30 | get isSubscribed() { 31 | 32 | return typeof this.unsubscribe === 'function'; 33 | } 34 | 35 | handleStoreChange() { // eslint-disable-line class-methods-use-this 36 | throw new Error('this.handleStoreChange() should be implemented'); 37 | } 38 | 39 | getStatefromStore() { 40 | const { slice = DEFAULT_SLICE, immutable } = this.router; 41 | const state = this.store.getState(); 42 | return immutable ? state.get(slice) : state[slice]; 43 | } 44 | } 45 | 46 | export default BaseRouterComponent; 47 | -------------------------------------------------------------------------------- /examples/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 18 | React App 19 | 20 | 21 |
22 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/components/Provider.js: -------------------------------------------------------------------------------- 1 | import { Children, Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import createRouteToLocationParser from '../parsers/routeToLocation'; 4 | import { DEFAULT_SLICE, __DEV__ } from '../constants'; 5 | 6 | class Provider extends Component { 7 | getChildContext() { 8 | 9 | const { immutable, slice, routes, current } = this.props; 10 | const router = { 11 | immutable, 12 | slice, 13 | routes, 14 | parseRoute: createRouteToLocationParser(routes), 15 | current 16 | }; 17 | 18 | return { router }; 19 | } 20 | 21 | render() { 22 | return Children.only(this.props.children); 23 | } 24 | } 25 | 26 | Provider.childContextTypes = { 27 | router: PropTypes.shape({ 28 | slice: PropTypes.string, 29 | immutable: PropTypes.bool, 30 | routes: PropTypes.array 31 | }).isRequired 32 | }; 33 | 34 | Provider.defaultProps = { 35 | immutable: false, 36 | slice: DEFAULT_SLICE, 37 | routes: [], 38 | current: '' 39 | }; 40 | 41 | if (__DEV__) { 42 | Provider.propTypes = { 43 | immutable: PropTypes.bool.isRequired, 44 | slice: PropTypes.string.isRequired, 45 | routes: PropTypes.array.isRequired, 46 | current: PropTypes.string.isRequired, 47 | children: PropTypes.oneOfType([ 48 | PropTypes.element, 49 | PropTypes.arrayOf(PropTypes.element), 50 | PropTypes.string 51 | ]) 52 | }; 53 | } 54 | 55 | export default Provider; 56 | -------------------------------------------------------------------------------- /src/store-enhancer.js: -------------------------------------------------------------------------------- 1 | import * as actions from './actions'; 2 | 3 | const createInitialState = ({state, slice, val, immutable}) => { 4 | if (immutable) { 5 | state = state.set(slice, require('immutable').fromJS(val)); 6 | } else { 7 | state[slice] = val; 8 | } 9 | return state; 10 | }; 11 | 12 | const scrollToHash = hash => { 13 | if (hash && window && typeof window.requestAnimationFrame === 'function') { 14 | window.requestAnimationFrame(() => { 15 | const node = document.getElementById(location.hash.substr(1)); 16 | if (node) { 17 | node.scrollIntoView(); 18 | } 19 | }); 20 | } 21 | }; 22 | 23 | export default ({ history, slice, locationParser, immutable }) => next => (reducer, initialState, enhancer) => { 24 | 25 | // boilerplate 26 | if (typeof initialState === 'function' && typeof enhancer === 'undefined') { 27 | enhancer = initialState; 28 | initialState = undefined; 29 | } 30 | let newInitialState = initialState || enhancer; 31 | 32 | const initialLocation = locationParser(history.location); 33 | 34 | scrollToHash(initialLocation.hash); 35 | 36 | newInitialState = createInitialState({ state: newInitialState, val: initialLocation, slice, immutable }); 37 | 38 | const store = next(reducer, newInitialState, enhancer); 39 | 40 | history.listen(location => { 41 | if (location.silent) return; 42 | 43 | store.dispatch(actions.locationChange(location)); 44 | 45 | scrollToHash(location.hash); 46 | }); 47 | 48 | return store; 49 | }; -------------------------------------------------------------------------------- /src/parsers/util.js: -------------------------------------------------------------------------------- 1 | import { join as pathJoin } from 'path'; 2 | import { __DEV__, ID_DELIM } from '../constants'; 3 | 4 | export const flattenRoutes = (routes, parentRoutePath = '', parentIdPath = '', parentData = {}) => { 5 | 6 | let result = []; 7 | 8 | for (let route of routes) { 9 | 10 | let { pattern } = route; 11 | 12 | if (pattern === undefined) continue; 13 | 14 | if (typeof pattern === 'string') pattern = { path: pattern }; 15 | 16 | let { path = '' } = pattern; 17 | 18 | path = pathJoin(parentRoutePath, path); 19 | 20 | const { id = path.toString(), data = {}} = route; 21 | const idPath = [parentIdPath, id].filter(item => item !== '').join(ID_DELIM); 22 | 23 | if (Array.isArray(route.routes)) { 24 | result = result.concat(flattenRoutes(route.routes, path, idPath, data)); 25 | } 26 | 27 | if (__DEV__ && console && typeof console.warn === 'function') { // eslint-disable-line no-console 28 | if (route.id === undefined) { 29 | console.warn(`Route ${JSON.stringify(pattern)} has no id`); // eslint-disable-line no-console 30 | } else if (!['string', 'number'].includes(typeof route.id)) { 31 | console.warn(`Route ${JSON.stringify(pattern)} has id that is not type of string or number`); // eslint-disable-line no-console 32 | } 33 | } 34 | 35 | const item = { 36 | id, 37 | idPath, 38 | data: { 39 | ...parentData, 40 | ...data 41 | }, 42 | ...{ 43 | pattern: { 44 | ...pattern, 45 | path 46 | } 47 | } 48 | }; 49 | 50 | result = result.concat(item); 51 | } 52 | 53 | return result; 54 | }; -------------------------------------------------------------------------------- /src/parsers/routeToLocation.js: -------------------------------------------------------------------------------- 1 | import pathToRegexp from 'path-to-regexp'; 2 | import { stringify as qsStringify } from 'query-string'; 3 | import { createPath } from 'history'; 4 | import { flattenRoutes } from './util'; 5 | import RouterError from '../error'; 6 | 7 | const ERRORS = { 8 | noId: _ => 'Can\'t match route with no id', 9 | notFound: id => `Route with id ${id} not found` 10 | }; 11 | 12 | const createMatchRouteToPath = registry => ({ id, params = {}, query = {}, hash = ''}) => { 13 | if (id === undefined) throw new RouterError(ERRORS.noId()); 14 | 15 | const matcher = registry[id]; 16 | 17 | if (matcher === undefined) throw new RouterError(ERRORS.notFound(id)); 18 | 19 | let pathname; 20 | 21 | try { 22 | // remove front trailing backslash (disable '//' situation) 23 | Object.keys(params).forEach(name => { 24 | params[name] = String(params[name] || '').replace(/^\//, ''); 25 | }); 26 | 27 | // path-to-regexp (2.4.0): encodeURI by default, disable it with encode option 28 | // 'pretty' flag disable all encoding, besides '/', '?', '#' 29 | pathname = matcher(params, { encode: value => value }); 30 | } catch (e) { 31 | throw new RouterError(e.toString()); 32 | } 33 | 34 | const location = { 35 | search: qsStringify(query), 36 | pathname, 37 | hash 38 | }; 39 | 40 | return createPath(location); 41 | }; 42 | 43 | const createRouteToLocationParser = routes => { 44 | 45 | const registry = flattenRoutes(routes).reduce((result, item) => { 46 | if (result[item.id]) { 47 | return result; 48 | } 49 | result[item.id] = pathToRegexp.compile(item.pattern.path); 50 | return result; 51 | }, {}); 52 | 53 | return createMatchRouteToPath(registry); 54 | }; 55 | 56 | export default createRouteToLocationParser; -------------------------------------------------------------------------------- /examples/src/routes.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | id: 'Main', 4 | pattern: '/main/', 5 | data: { 6 | pageTitle: 'Main page' 7 | }, 8 | routes: [ 9 | { 10 | id: 'User', 11 | pattern: '/user', 12 | data: { 13 | pageTitle: 'User profile' 14 | }, 15 | routes: [ 16 | { 17 | id: 'UserEdit', 18 | data: { 19 | token: ' e287f992d8af8fa21c08' 20 | }, 21 | pattern: { 22 | query: { 23 | edit: true 24 | } 25 | } 26 | } 27 | ] 28 | }, 29 | { 30 | id: 'default', 31 | pattern: '*' 32 | } 33 | ] 34 | }, 35 | { 36 | id: 'ScrollToHash', 37 | pattern: '/scroll-to-hash' 38 | }, 39 | { 40 | id: 'Settings', 41 | pattern: '/application/settings/' 42 | }, 43 | { 44 | id: 'Preferences', 45 | pattern: '/prefs/:action' 46 | }, 47 | { 48 | id: 'OnClick', 49 | pattern: '/on-click' 50 | }, 51 | { 52 | id: 'OnClickPromise', 53 | pattern: '/on-click-promise' 54 | }, 55 | { 56 | id: 'Redirect', 57 | pattern: '/redirect', 58 | routes: [ 59 | { 60 | id: 'Redirected', 61 | pattern: '/redirected' 62 | } 63 | ] 64 | }, 65 | { 66 | id: 'DelayedRedirect', 67 | pattern: '/delredirect', 68 | routes: [ 69 | { 70 | id: 'DelayedRedirected', 71 | pattern: '/redirected' 72 | } 73 | ] 74 | } 75 | ]; 76 | -------------------------------------------------------------------------------- /src/parsers/locationToRoute.js: -------------------------------------------------------------------------------- 1 | import pathToRegexp from 'path-to-regexp'; 2 | import qs from 'query-string'; 3 | import { flattenRoutes } from './util'; 4 | 5 | const createParamsFromKeys = (match, keys) => keys.reduce((result, key, index) => { 6 | result[key.name] = match[index + 1]; 7 | return result; 8 | }, {}); 9 | 10 | const createMatchPathToRoute = matchers => path => { 11 | 12 | path = path.split('?'); 13 | const pathname = path.shift(); 14 | const pathQuery = qs.parse(path.shift()); 15 | 16 | for (let matcher of matchers) { 17 | const { regexp, query, id, idPath, pattern, data = {} } = matcher; 18 | 19 | if (regexp.test(pathname)) { 20 | 21 | let matchQuery = true; 22 | let queryItems = Object.keys(query); 23 | let queryItemsLength = queryItems.length; 24 | 25 | while (matchQuery && queryItemsLength) { 26 | const curQueryItem = queryItems[queryItemsLength - 1]; 27 | matchQuery = query[curQueryItem].test(pathQuery[curQueryItem]); 28 | queryItemsLength--; 29 | } 30 | 31 | if (matchQuery) { 32 | let keys = []; 33 | const match = pathToRegexp(pattern.path, keys).exec(pathname); 34 | const params = createParamsFromKeys(match, keys); 35 | return { 36 | pattern, 37 | id, 38 | idPath, 39 | params, 40 | data 41 | }; 42 | } 43 | } 44 | } 45 | return {}; 46 | }; 47 | 48 | const createMatchers = routes => flattenRoutes(routes).map(route => { 49 | const regexp = pathToRegexp(route.pattern.path); 50 | const id = route.id; 51 | const query = Object.keys(route.pattern.query || {}).reduce( (result, item) => { 52 | result[item] = new RegExp(route.pattern.query[item]); 53 | return result; 54 | }, {}); 55 | 56 | return { 57 | ...route, 58 | id, 59 | regexp, 60 | query 61 | }; 62 | }); 63 | 64 | const createLocationToRouteParser = routes => { 65 | const matchers = createMatchers(routes); 66 | return createMatchPathToRoute(matchers); 67 | }; 68 | 69 | export default createLocationToRouteParser; -------------------------------------------------------------------------------- /test/mock/routes.js: -------------------------------------------------------------------------------- 1 | import { ID_DELIM } from '../../src/constants'; 2 | 3 | export const initialRoutes = [ 4 | { 5 | id: 'index', 6 | pattern: '/', 7 | routes: [ 8 | { 9 | id: 'main', 10 | pattern: '/main/', 11 | data: { 12 | pageTitle: 'test' 13 | }, 14 | routes: [ 15 | { 16 | id: 'main+param+query', 17 | pattern: { 18 | path: '/:param', 19 | query: { 20 | test: 'true' 21 | } 22 | } 23 | }, 24 | { 25 | id: 'main+param', 26 | pattern: '/:param', 27 | data: { 28 | token: 'e287f992d8af8fa21c08' 29 | } 30 | } 31 | ] 32 | }, 33 | { 34 | id: 'test', 35 | pattern: '/test/' 36 | }, 37 | { 38 | pattern: '/empty/' 39 | }, 40 | { 41 | id: 'pattern undefined' 42 | }, 43 | { 44 | 45 | } 46 | ] 47 | } 48 | ]; 49 | 50 | export const expectedRoutes = [ 51 | { 52 | id: 'main+param+query', 53 | idPath: ['index', 'main', 'main+param+query'].join(ID_DELIM), 54 | pattern: { 55 | path: '/main/:param', 56 | query: { 57 | test: 'true' 58 | } 59 | }, 60 | data: { 61 | pageTitle: 'test' 62 | } 63 | }, 64 | { 65 | id: 'main+param', 66 | idPath: ['index', 'main', 'main+param'].join(ID_DELIM), 67 | pattern: { 68 | path: '/main/:param', 69 | }, 70 | data: { 71 | pageTitle: 'test', 72 | token: 'e287f992d8af8fa21c08' 73 | } 74 | }, 75 | { 76 | id: 'main', 77 | idPath: ['index', 'main'].join(ID_DELIM), 78 | pattern: { 79 | path: '/main/' 80 | }, 81 | data: { 82 | pageTitle: 'test' 83 | } 84 | }, 85 | { 86 | id: 'test', 87 | idPath: ['index', 'test'].join(ID_DELIM), 88 | pattern: { 89 | path: '/test/' 90 | }, 91 | data: {} 92 | }, 93 | { 94 | id: '/empty/', 95 | idPath: ['index', '/empty/'].join(ID_DELIM), 96 | pattern: { 97 | path: '/empty/' 98 | }, 99 | data: {} 100 | }, 101 | { 102 | id: 'index', 103 | idPath: 'index', 104 | pattern: { 105 | path: '/' 106 | }, 107 | data: {} 108 | } 109 | ]; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-unity-router", 3 | "version": "1.6.1", 4 | "description": "Redux router that syncs history with store", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "npm run test && npm run clean && ./node_modules/.bin/babel src --out-dir dist", 8 | "build:watch": "npm run clean && ./node_modules/.bin/babel src --out-dir dist -s -w", 9 | "build:examples": "npm run build && (cd examples && npm i && npm start)", 10 | "clean": "./node_modules/.bin/rimraf dist", 11 | "commit": "./node_modules/.bin/git-cz", 12 | "coverage:report": "./node_modules/.bin/nyc report", 13 | "coverage:send": "./node_modules/.bin/nyc report --reporter=text-lcov | ./node_modules/.bin/coveralls", 14 | "lint": "./node_modules/.bin/eslint --ignore-path=.gitignore --fix ./src", 15 | "lint-prod": "NODE_ENV='production' npm run lint", 16 | "version": " ./node_modules/.bin/conventional-changelog -i CHANGELOG.md -s && git add CHANGELOG.md", 17 | "prepublish": "npm run build", 18 | "precommit": "npm test", 19 | "commitmsg": "./node_modules/.bin/validate-commit-msg", 20 | "test": "npm run lint-prod && ./node_modules/.bin/nyc ./node_modules/.bin/ava --verbose", 21 | "test:watch": "npm run lint && ./node_modules/.bin/nyc ./node_modules/.bin/ava --verbose --watch" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git@github.com:auru/redux-unity-router.git" 26 | }, 27 | "engines": { 28 | "node": ">=6" 29 | }, 30 | "keywords": [ 31 | "history", 32 | "react", 33 | "redux", 34 | "router", 35 | "unity" 36 | ], 37 | "author": "Vitaliy Blinovskov (blinovskov@yandex-team.ru)", 38 | "publishConfig": { 39 | "registry": "https://registry.npmjs.org/" 40 | }, 41 | "license": "MIT", 42 | "dependencies": { 43 | "history": "^4.6.3", 44 | "path": "^0.12.7", 45 | "path-to-regexp": "^2.4.0", 46 | "prop-types": "^15.5.10", 47 | "query-string": "^4.3.4", 48 | "url": "^0.11.0" 49 | }, 50 | "devDependencies": { 51 | "ava": "^0.20.0", 52 | "babel-cli": "^6.16.0", 53 | "babel-core": "^6.25.0", 54 | "babel-eslint": "^7.2.3", 55 | "babel-loader": "^7.1.1", 56 | "babel-polyfill": "^6.16.0", 57 | "babel-preset-es2015": "^6.16.0", 58 | "babel-preset-react": "^6.16.0", 59 | "babel-preset-stage-0": "^6.16.0", 60 | "babel-register": "^6.16.3", 61 | "commitizen": "^2.8.6", 62 | "conventional-changelog-cli": "^1.2.0", 63 | "coveralls": "^2.13.1", 64 | "cz-conventional-changelog": "^2.0.0", 65 | "eslint": "^4.1.1", 66 | "eslint-plugin-ava": "^4.2.1", 67 | "eslint-plugin-react": "^7.1.0", 68 | "husky": "^0.14.3", 69 | "immutable": "^3.8.1", 70 | "jsdom": "^11.1.0", 71 | "nyc": "^11.0.3", 72 | "prop-types": "^15.5.10", 73 | "react": "^15.6.1", 74 | "rimraf": "^2.5.4", 75 | "validate-commit-msg": "^2.12.2" 76 | }, 77 | "ava": { 78 | "files": [ 79 | "test/**/*.spec.js" 80 | ], 81 | "source": [ 82 | "**/*.{js,jsx}", 83 | "!public/**/*" 84 | ], 85 | "concurrency": 4, 86 | "failFast": false, 87 | "tap": false, 88 | "require": [ 89 | "babel-register", 90 | "babel-polyfill", 91 | "./test/helpers/setup.js" 92 | ], 93 | "babel": "inherit" 94 | }, 95 | "config": { 96 | "commitizen": { 97 | "path": "./node_modules/cz-conventional-changelog" 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/components/Fragment.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import BaseRouterComponent from './Base'; 4 | import { ID_DELIM, __DEV__} from '../constants'; 5 | import { replace } from '../actions'; 6 | 7 | class Fragment extends BaseRouterComponent { 8 | 9 | constructor(props, context) { 10 | 11 | super(props, context); 12 | 13 | const { id } = this.props; 14 | this.current = this.router.current ? this.router.current + ID_DELIM + id : id; 15 | 16 | this.state = { 17 | visible: false 18 | }; 19 | } 20 | 21 | componentWillReceiveProps(newProps) { 22 | 23 | const { redirect } = this.props; 24 | const newRedirect = newProps.redirect; 25 | 26 | if ((!redirect && newRedirect) || 27 | (newRedirect && redirect && newRedirect.id !== redirect.id) || 28 | (typeof newRedirect === 'string' && redirect !== newRedirect)) { 29 | this.handleStoreChange(newProps); 30 | } 31 | } 32 | 33 | getChildContext() { 34 | 35 | const { router } = this.context; 36 | 37 | return { router: { ...router, current: this.current } }; 38 | } 39 | 40 | handleStoreChange(newProps) { 41 | 42 | if (!this.isSubscribed) return; 43 | 44 | const { immutable, parseRoute } = this.router; 45 | const { redirect } = newProps || this.props; 46 | 47 | const current = this.current; 48 | const storeState = this.getStatefromStore(); 49 | const routerStore = immutable ? storeState.toJS() : storeState; 50 | 51 | if (routerStore) { 52 | const idPath = routerStore.route.idPath; 53 | const params = routerStore.route.params; 54 | const query = routerStore.query; 55 | const hash = routerStore.hash; 56 | const routePath = idPath + ID_DELIM; 57 | const fragmentPath = current + ID_DELIM; 58 | const match = (routePath).indexOf(fragmentPath); 59 | const matchExact = routePath === fragmentPath; 60 | 61 | if (matchExact && redirect) { 62 | const redirectRoute = typeof redirect === 'object' && redirect.id ? 63 | parseRoute({ ...{ params, query, hash }, ...redirect }) : redirect; 64 | 65 | return this.store.dispatch(replace(redirectRoute)); 66 | } 67 | 68 | if (match === 0 && !this.state.visible) { 69 | return this.setState({ 70 | matchExact, 71 | visible: true 72 | }); 73 | } 74 | if (match !== 0 && this.state.visible) { 75 | return this.setState({ 76 | matchExact, 77 | visible: false 78 | }); 79 | } 80 | } 81 | } 82 | 83 | render() { 84 | 85 | const { visible, matchExact, redirect } = this.state; 86 | const { children, component: ChildComponent} = this.props; 87 | 88 | if (!visible || (matchExact && redirect)) return null; // eslint-disable-line 89 | if (ChildComponent) return children ? {children} : ; // eslint-disable-line 90 | if (children) return
{children}
; // eslint-disable-line 91 | } 92 | } 93 | 94 | Fragment.contextTypes = { 95 | router: PropTypes.object, 96 | store: PropTypes.object 97 | }; 98 | 99 | Fragment.childContextTypes = { 100 | router: PropTypes.shape({ 101 | slice: PropTypes.string, 102 | immutable: PropTypes.bool, 103 | routes: PropTypes.array 104 | }).isRequired, 105 | store: PropTypes.object 106 | }; 107 | 108 | if (__DEV__) { 109 | Fragment.propTypes = { 110 | id: PropTypes.oneOfType([ 111 | PropTypes.string, 112 | PropTypes.number 113 | ]), 114 | children: PropTypes.oneOfType([ 115 | PropTypes.object, 116 | PropTypes.string, 117 | PropTypes.array 118 | ]), 119 | component: PropTypes.func, 120 | redirect: PropTypes.oneOfType([ 121 | PropTypes.object, 122 | PropTypes.string 123 | ]) 124 | }; 125 | } 126 | 127 | export default Fragment; 128 | -------------------------------------------------------------------------------- /examples/src/components/App/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import './App.css'; 4 | 5 | import { Link, RouterProvider, Fragment } from '../../../../dist'; 6 | import { LINK_MATCH_PARTIAL, LINK_MATCH_EXACT } from '../../../../dist/constants'; 7 | 8 | import routes from '../../routes'; 9 | 10 | import Lorem from '../Lorem'; 11 | import CurrentRoute from '../CurrentRoute'; 12 | 13 | const Main = ({children}) => { 14 | return ( 15 |
16 |

Main

17 | {children} 18 |
19 | ); 20 | }; 21 | 22 | Main.propTypes = { 23 | children: PropTypes.any 24 | }; 25 | 26 | const onClickCallback = () => { 27 | console.log('OnClick'); // eslint-disable-line no-console 28 | }; 29 | 30 | const delayedOnClickCallback = e => { 31 | e.preventDefault(); 32 | console.log('OnClickPromise'); // eslint-disable-line no-console 33 | return new Promise(resolve => { 34 | setTimeout(resolve, 2000); 35 | }); 36 | }; 37 | 38 | class App extends Component { 39 | 40 | constructor(props) { 41 | super(props); 42 | 43 | this.handleDelayedRedirect = this.handleDelayedRedirect.bind(this); 44 | 45 | this.state = { 46 | redirect: null 47 | }; 48 | } 49 | 50 | handleDelayedRedirect() { 51 | setTimeout(() => { 52 | this.setState({ 53 | redirect: { id: 'DelayedRedirected' } 54 | }); 55 | }, 2000); 56 | } 57 | 58 | render() { 59 | return ( 60 | 61 |
62 |
63 | Main 64 | Default 65 | Another default 66 | User 67 | User 2 68 | Scroll to hash 69 | Settings 70 | Preferences 71 | Redirect 72 | Delayed redirect 73 | onClick 74 | onClick Promise 75 | External 76 |
77 | 78 |
79 | 80 | 81 | 82 | User content 83 | 84 | Edit form 85 | 86 | 87 | 88 | Default 89 | 90 | 91 | 92 | 93 |

Settings

94 |
95 | 96 |

Preferences

97 |
98 | 99 |

OnClick

100 |
101 | 102 |

OnClick Promise

103 |
104 | 105 | 106 |

Redirected

107 | You have been redirected here 108 |
109 |
110 | 111 | 112 |

Delayed Redirected

113 | You have been redirected here 114 |
115 |
116 |
117 |
118 |
119 | ); 120 | } 121 | } 122 | 123 | export default App; 124 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ## 1.4.1 (2017-07-07) 3 | 4 | * fix(routeToLocation): disable URI encoding ([9c8b109](https://github.com/auru/redux-unity-router/commit/9c8b109)) 5 | 6 | 7 | 8 | 9 | # 1.4.0 (2017-07-07) 10 | 11 | * feat: update src to React 15.5.0, update deps to green ([dccc5b7](https://github.com/auru/redux-unity-router/commit/dccc5b7)) 12 | * feat(ci): node 4.x, 5.x is not supported ([7ea1f94](https://github.com/auru/redux-unity-router/commit/7ea1f94)) 13 | * chore: remove info from changelog ([c8a4630](https://github.com/auru/redux-unity-router/commit/c8a4630)) 14 | * chore: update deps to green ([ea028a8](https://github.com/auru/redux-unity-router/commit/ea028a8)) 15 | * chore: update yarn.lock file ([6ffddf2](https://github.com/auru/redux-unity-router/commit/6ffddf2)) 16 | 17 | 18 | 19 | 20 | ## 1.3.3 (2017-07-05) 21 | 22 | * perf(Components): replace React.PropTypes on 'prop-types' module ([7c3f315](https://github.com/auru/redux-unity-router/commit/7c3f315)) 23 | * 1.3.2 ([dad50ba](https://github.com/auru/redux-unity-router/commit/dad50ba)) 24 | * fix(Link): fix default pathname (null) ([9a5491b](https://github.com/auru/redux-unity-router/commit/9a5491b)) 25 | 26 | 27 | 28 | 29 | ## 1.3.2 (2017-07-03) 30 | 31 | * fix(Link): fix default pathname (null) ([9a5491b](https://github.com/auru/redux-unity-router/commit/9a5491b)) 32 | 33 | 34 | 35 | 36 | ## 1.3.1 (2017-04-12) 37 | 38 | * build(yarn): add yarn.lock file ([827ff8f](https://github.com/auru/redux-unity-router/commit/827ff8f)) 39 | * fix(routeToLocation): disable extended uri escaping ([62c9fc3](https://github.com/auru/redux-unity-router/commit/62c9fc3)) 40 | * chore(package): update ava to version 0.18.1 ([4940f76](https://github.com/auru/redux-unity-router/commit/4940f76)) 41 | * chore(package): update ava to version 0.19.0 ([182863e](https://github.com/auru/redux-unity-router/commit/182863e)) 42 | * chore(package): update eslint-plugin-react to version 6.10.0 ([86f3650](https://github.com/auru/redux-unity-router/commit/86f3650)) 43 | 44 | 45 | 46 | 47 | # 1.3.0 (2017-01-26) 48 | 49 | * chore(package): update ava to version 0.17.0 ([535c1a8](https://github.com/auru/redux-unity-router/commit/535c1a8)) 50 | * chore(package): update dependencies ([2333775](https://github.com/auru/redux-unity-router/commit/2333775)) 51 | * chore(package): update eslint-plugin-ava to version 4.0.0 ([75b79da](https://github.com/auru/redux-unity-router/commit/75b79da)) 52 | * chore(package): update eslint-plugin-react to version 6.6.0 ([02fb8ba](https://github.com/auru/redux-unity-router/commit/02fb8ba)) 53 | * chore(package): update eslint-plugin-react to version 6.7.0 ([89092c0](https://github.com/auru/redux-unity-router/commit/89092c0)) 54 | * chore(package): update eslint-plugin-react to version 6.8.0 ([bae3362](https://github.com/auru/redux-unity-router/commit/bae3362)) 55 | * chore(package): update husky to version 0.12.0 ([db7ccf6](https://github.com/auru/redux-unity-router/commit/db7ccf6)) 56 | * chore(package): update nyc to version 10.0.0 ([2a718bd](https://github.com/auru/redux-unity-router/commit/2a718bd)) 57 | * chore(package): update nyc to version 9.0.1 ([6a3d45d](https://github.com/auru/redux-unity-router/commit/6a3d45d)) 58 | * feat: redirect chanage property reactions ([2c2ce19](https://github.com/auru/redux-unity-router/commit/2c2ce19)) 59 | * 1.2.8 ([bcdffaf](https://github.com/auru/redux-unity-router/commit/bcdffaf)) 60 | * fix(Link): Fix event.which ([3e246b1](https://github.com/auru/redux-unity-router/commit/3e246b1)) 61 | * ci(coverage): added coveralls.io support ([2b0a46e](https://github.com/auru/redux-unity-router/commit/2b0a46e)) 62 | 63 | 64 | 65 | 66 | ## 1.2.8 (2016-11-29) 67 | 68 | * fix(Link): Fix event.which ([3e246b1](https://github.com/auru/redux-unity-router/commit/3e246b1)) 69 | * chore(package): update dependencies ([2333775](https://github.com/auru/redux-unity-router/commit/2333775)) 70 | * chore(package): update eslint-plugin-ava to version 4.0.0 ([75b79da](https://github.com/auru/redux-unity-router/commit/75b79da)) 71 | * chore(package): update eslint-plugin-react to version 6.6.0 ([02fb8ba](https://github.com/auru/redux-unity-router/commit/02fb8ba)) 72 | * chore(package): update eslint-plugin-react to version 6.7.0 ([89092c0](https://github.com/auru/redux-unity-router/commit/89092c0)) 73 | * chore(package): update nyc to version 10.0.0 ([2a718bd](https://github.com/auru/redux-unity-router/commit/2a718bd)) 74 | * chore(package): update nyc to version 9.0.1 ([6a3d45d](https://github.com/auru/redux-unity-router/commit/6a3d45d)) 75 | * ci(coverage): added coveralls.io support ([2b0a46e](https://github.com/auru/redux-unity-router/commit/2b0a46e)) 76 | 77 | 78 | 79 | 80 | ## 1.2.7 (2016-11-02) 81 | 82 | * chore(deps): removed unused dependencies ([77ad745](https://github.com/auru/redux-unity-router/commit/77ad745)) 83 | * chore(package): fixed package description and keywords ([b8314a2](https://github.com/auru/redux-unity-router/commit/b8314a2)) 84 | * docs(badges): Added Dependency CI ([b11bb0b](https://github.com/auru/redux-unity-router/commit/b11bb0b)) 85 | 86 | 87 | 88 | 89 | ## 1.2.6 (2016-11-02) 90 | 91 | * fix(examples): removed unused history dependency ([b1d795e](https://github.com/auru/redux-unity-router/commit/b1d795e)) 92 | * fix(react): do not pass redux-unity-router specific props from to ([cbc8542](https://github.com/auru/redux-unity-router/commit/cbc8542)), closes [#15](https://github.com/auru/redux-unity-router/issues/15) 93 | * fix(react): respect ctrl/cmd keys ([1dee34a](https://github.com/auru/redux-unity-router/commit/1dee34a)), closes [#13](https://github.com/auru/redux-unity-router/issues/13) 94 | * chore(contributing): semantic versioning ([1271539](https://github.com/auru/redux-unity-router/commit/1271539)) 95 | * chore(npm): npm run test-watch -> npm run test:watch ([4732cfe](https://github.com/auru/redux-unity-router/commit/4732cfe)) 96 | * ci(umd): remove umd builds ([6e13fad](https://github.com/auru/redux-unity-router/commit/6e13fad)) 97 | * Update README.md ([b48681d](https://github.com/auru/redux-unity-router/commit/b48681d)) 98 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /src/components/Link.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import BaseRouterComponent from './Base'; 4 | 5 | import { parse, format } from 'url'; 6 | import qs from 'query-string'; 7 | import * as actions from '../actions'; 8 | import { 9 | __DEV__, 10 | LINK_MATCH_EXACT, 11 | LINK_MATCH_PARTIAL, 12 | LINK_DEFAULT_METHOD, 13 | LINK_CLASSNAME, 14 | LINK_ACTIVE_CLASSNAME 15 | } from '../constants'; 16 | 17 | const compareQueryItems = (linkQueryItem, routeQueryItem) => { 18 | 19 | linkQueryItem = [].concat(linkQueryItem); 20 | routeQueryItem = [].concat(routeQueryItem); 21 | 22 | return linkQueryItem.reduce((result, linkQuerySubItem) => { 23 | result = result && routeQueryItem.includes(linkQuerySubItem.toString()); 24 | return result; 25 | }, true); 26 | 27 | }; 28 | 29 | function filterProps(props) { 30 | 31 | const propsCopy = Object.assign({}, props); 32 | 33 | const managedProps = [ 'to', 'activeClass', 'method', 'activeMatch' ]; 34 | 35 | for (let prop of managedProps) { 36 | delete propsCopy[prop]; 37 | } 38 | 39 | return propsCopy; 40 | } 41 | 42 | class Link extends BaseRouterComponent { 43 | 44 | constructor(props, context) { 45 | 46 | super(props, context); 47 | 48 | this.handleClick = this.handleClick.bind(this); 49 | 50 | this.href = this.getHref(props); 51 | this.state = { 52 | isActive: false 53 | }; 54 | } 55 | 56 | componentWillReceiveProps(newProps) { 57 | 58 | if (this.props.to !== newProps.to) { 59 | this.href = this.getHref(newProps); 60 | } 61 | } 62 | 63 | initiateLocationChange(e) { 64 | const { target } = this.props; 65 | 66 | if (!target && 67 | !e.altKey && 68 | !e.metaKey && 69 | !e.ctrlKey && 70 | !this.href.protocol && 71 | (e.nativeEvent && e.nativeEvent.which) !== 2 72 | ) { 73 | e.preventDefault(); 74 | this.locationChange(this.href); 75 | } 76 | } 77 | 78 | handleClick(e) { 79 | const { onClick } = this.props; 80 | 81 | if (typeof onClick === 'function') { 82 | const onClickResult = onClick(e); 83 | 84 | if (typeof onClickResult === 'object' && 85 | typeof onClickResult.then === 'function') { 86 | e.persist(); 87 | 88 | return onClickResult.then(() => { 89 | if (!e.defaultPrevented) { 90 | this.initiateLocationChange(e); 91 | } 92 | }); 93 | } 94 | } 95 | 96 | if (!e.defaultPrevented) { 97 | return this.initiateLocationChange(e); 98 | } 99 | } 100 | 101 | getHref(props) { 102 | let { to } = props; 103 | 104 | if (typeof to === 'object' && to.id) { 105 | to = this.router.parseRoute(to); 106 | } 107 | 108 | if (typeof to === 'string') { 109 | to = parse(to); 110 | to.query = qs.parse(to.query); 111 | } 112 | 113 | to.hash = typeof to.hash === 'string' && to.hash[0] !== '#' ? '#' + to.hash : to.hash; 114 | 115 | return to || false; 116 | } 117 | 118 | handleStoreChange() { 119 | 120 | if (!this.isSubscribed) return; 121 | 122 | const { activeClass, activeMatch } = this.props; 123 | const { pathname, hash, query, protocol } = this.href; 124 | 125 | if (!activeClass || !activeMatch || protocol) return; // eslint-disable-line consistent-return 126 | 127 | const routerStore = this.getStatefromStore(); 128 | const { immutable } = this.router; 129 | 130 | let isActive = true; 131 | 132 | if (activeMatch instanceof RegExp) { 133 | const routePath = ( immutable ? routerStore.get('path') : routerStore.path ); 134 | 135 | return this.setState({ // eslint-disable-line consistent-return 136 | isActive: activeMatch.test(routePath) 137 | }); 138 | } 139 | 140 | if (activeMatch === LINK_MATCH_EXACT) { 141 | if (hash) { 142 | const routeHash = ( immutable ? routerStore.get('hash') : routerStore.hash ); 143 | isActive = isActive && hash === routeHash; 144 | } 145 | 146 | if (query && Object.keys(query).length) { 147 | const routeQuery = immutable ? routerStore.get('query').toJS() : routerStore.query; 148 | isActive = isActive && Object.keys(query).reduce( 149 | (result, item) => result && compareQueryItems(query[item], routeQuery[item]), true); 150 | } 151 | } 152 | 153 | const routePathname = ( immutable ? routerStore.get('pathname') : routerStore.pathname ); 154 | 155 | isActive = isActive && ( 156 | activeMatch === LINK_MATCH_EXACT 157 | ? pathname === routePathname 158 | : routePathname.indexOf(pathname) === 0 159 | ); 160 | 161 | if (isActive !== this.state.isActive) { 162 | this.setState({ 163 | isActive 164 | }); 165 | } 166 | }; 167 | 168 | locationChange({ pathname, query, search, hash }) { 169 | const { method } = this.props; 170 | 171 | search = query || search; 172 | search = typeof search === 'object' ? qs.stringify(search) : search; 173 | 174 | this.store.dispatch(actions[method]({ pathname: pathname || '', search, hash })); 175 | } 176 | 177 | render() { 178 | 179 | const { children, activeClass, className, target = null } = this.props; 180 | const classes = this.state.isActive ? `${className} ${activeClass}` : className; 181 | 182 | const props = { 183 | ...this.props, 184 | target, 185 | href: format(this.href), 186 | className: classes 187 | }; 188 | 189 | props.onClick = this.handleClick; 190 | 191 | return React.createElement('a', filterProps(props), children); 192 | } 193 | } 194 | 195 | Link.contextTypes = { 196 | router: PropTypes.object, 197 | store: PropTypes.object 198 | }; 199 | 200 | Link.defaultProps = { 201 | to: '', 202 | className: LINK_CLASSNAME, 203 | activeClass: LINK_ACTIVE_CLASSNAME, 204 | method: LINK_DEFAULT_METHOD, 205 | activeMatch: false 206 | }; 207 | 208 | if (__DEV__) { 209 | Link.propTypes = { 210 | to: PropTypes.oneOfType([ 211 | PropTypes.string, 212 | PropTypes.object 213 | ]), 214 | className: PropTypes.string, 215 | activeClass: PropTypes.string, 216 | onClick: PropTypes.oneOfType([ 217 | PropTypes.instanceOf(Function), 218 | PropTypes.instanceOf(Promise) 219 | ]), 220 | target: PropTypes.string, 221 | method: PropTypes.string, 222 | children: PropTypes.any, 223 | activeMatch: (props, propName, componentName) => { 224 | if ( 225 | ![false, LINK_MATCH_EXACT, LINK_MATCH_PARTIAL].includes(props[propName]) && 226 | !(props[propName] instanceof RegExp) 227 | ) { 228 | return new Error( 229 | 'Invalid prop `' + propName + '` supplied to' + 230 | ' `' + componentName + '`. ' + 231 | `Should be one of [false, '${LINK_MATCH_EXACT}', '${LINK_MATCH_PARTIAL}'] or an instance of RegExp` 232 | ); 233 | } 234 | } 235 | }; 236 | } 237 | 238 | export default Link; 239 | -------------------------------------------------------------------------------- /examples/src/components/Lorem/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default () => ( 4 |
5 |

The standard Lorem Ipsum passage, used since the 1500s

6 | 7 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." 8 | 9 |

Section 1.10.32 of "de Finibus Bonorum et Malorum", written by Cicero in 45 BC

10 | 11 | "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?" 12 | 13 |

1914 translation by H. Rackham

14 | 15 | "But I must explain to you how all this mistaken idea of denouncing pleasure and praising pain was born and I will give you a complete account of the system, and expound the actual teachings of the great explorer of the truth, the master-builder of human happiness. No one rejects, dislikes, or avoids pleasure itself, because it is pleasure, but because those who do not know how to pursue pleasure rationally encounter consequences that are extremely painful. Nor again is there anyone who loves or pursues or desires to obtain pain of itself, because it is pain, but because occasionally circumstances occur in which toil and pain can procure him some great pleasure. To take a trivial example, which of us ever undertakes laborious physical exercise, except to obtain some advantage from it? But who has any right to find fault with a man who chooses to enjoy a pleasure that has no annoying consequences, or one who avoids a pain that produces no resultant pleasure?" 16 | 17 |

Section 1.10.33 of "de Finibus Bonorum et Malorum", written by Cicero in 45 BC

18 | 19 | "At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat." 20 | 21 |

1914 translation by H. Rackham

22 | 23 | "On the other hand, we denounce with righteous indignation and dislike men who are so beguiled and demoralized by the charms of pleasure of the moment, so blinded by desire, that they cannot foresee the pain and trouble that are bound to ensue; and equal blame belongs to those who fail in their duty through weakness of will, which is the same as saying through shrinking from toil and pain. These cases are perfectly simple and easy to distinguish. In a free hour, when our power of choice is untrammelled and when nothing prevents our being able to do what we like best, every pleasure is to be welcomed and every pain avoided. But in certain circumstances and owing to the claims of duty or the obligations of business it will frequently occur that pleasures have to be repudiated and annoyances accepted. The wise man therefore always holds in these matters to this principle of selection: he rejects pleasures to secure other greater pleasures, or else he endures pains to avoid worse pains." 24 | 25 |

The standard Lorem Ipsum passage, used since the 1500s

26 | 27 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." 28 | 29 |

Section 1.10.32 of "de Finibus Bonorum et Malorum", written by Cicero in 45 BC

30 | 31 | "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?" 32 | 33 |

1914 translation by H. Rackham

34 | 35 | "But I must explain to you how all this mistaken idea of denouncing pleasure and praising pain was born and I will give you a complete account of the system, and expound the actual teachings of the great explorer of the truth, the master-builder of human happiness. No one rejects, dislikes, or avoids pleasure itself, because it is pleasure, but because those who do not know how to pursue pleasure rationally encounter consequences that are extremely painful. Nor again is there anyone who loves or pursues or desires to obtain pain of itself, because it is pain, but because occasionally circumstances occur in which toil and pain can procure him some great pleasure. To take a trivial example, which of us ever undertakes laborious physical exercise, except to obtain some advantage from it? But who has any right to find fault with a man who chooses to enjoy a pleasure that has no annoying consequences, or one who avoids a pain that produces no resultant pleasure?" 36 | 37 |

Section 1.10.33 of "de Finibus Bonorum et Malorum", written by Cicero in 45 BC

38 | 39 | "At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat." 40 | 41 |

1914 translation by H. Rackham

42 | 43 | "On the other hand, we denounce with righteous indignation and dislike men who are so beguiled and demoralized by the charms of pleasure of the moment, so blinded by desire, that they cannot foresee the pain and trouble that are bound to ensue; and equal blame belongs to those who fail in their duty through weakness of will, which is the same as saying through shrinking from toil and pain. These cases are perfectly simple and easy to distinguish. In a free hour, when our power of choice is untrammelled and when nothing prevents our being able to do what we like best, every pleasure is to be welcomed and every pain avoided. But in certain circumstances and owing to the claims of duty or the obligations of business it will frequently occur that pleasures have to be repudiated and annoyances accepted. The wise man therefore always holds in these matters to this principle of selection: he rejects pleasures to secure other greater pleasures, or else he endures pains to avoid worse pains." 44 |
45 | ) -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // Inspired by https://github.com/airbnb/javascript but less opinionated. 2 | 3 | // We use eslint-loader so even warnings are very visibile. 4 | // This is why we only use "WARNING" level for potential errors, 5 | // and we don't use "ERROR" level at all. 6 | 7 | // In the future, we might create a separate list of rules for production. 8 | // It would probably be more strict. 9 | 10 | const ENV = process.env.NODE_ENV || 'development'; 11 | const isProduction = Number(ENV === 'production'); 12 | 13 | const OFF = 0; 14 | const RECOMMENDED = 1; 15 | const WARNING = 1 + isProduction; 16 | const CRITICAL = 2; 17 | 18 | module.exports = { 19 | root: true, 20 | 21 | parser: 'babel-eslint', 22 | 23 | // import plugin is termporarily disabled, scroll below to see why 24 | plugins: ['react', 'ava'], 25 | extends: "plugin:ava/recommended", 26 | 27 | env: { 28 | es6: true, 29 | commonjs: true, 30 | browser: true, 31 | node: true 32 | }, 33 | 34 | parserOptions: { 35 | ecmaVersion: 6, 36 | sourceType: 'module', 37 | ecmaFeatures: { 38 | jsx: true, 39 | generators: true, 40 | experimentalObjectRestSpread: true 41 | } 42 | }, 43 | 44 | settings: { 45 | 'import/ignore': [ 46 | 'node_modules', 47 | '\\.(json|css|jpg|png|gif|eot|svg|ttf|woff|woff2|mp4|webm)$', 48 | ], 49 | 'import/extensions': ['.js'], 50 | 'import/resolver': { 51 | node: { 52 | extensions: ['.js', '.json'] 53 | } 54 | } 55 | }, 56 | 57 | rules: { 58 | // http://eslint.org/docs/rules/ 59 | 'array-callback-return': CRITICAL, 60 | 'block-scoped-var': WARNING, 61 | 'class-methods-use-this': WARNING, 62 | 'consistent-return': OFF, 63 | 'curly': OFF, 64 | 'default-case': [WARNING, {commentPattern: '^no default$'}], 65 | 'dot-location': [WARNING, 'property'], 66 | 'dot-notation': WARNING, 67 | 'eqeqeq': [WARNING, 'allow-null'], 68 | 'guard-for-in': WARNING, 69 | 'no-alert': RECOMMENDED, 70 | 'no-array-constructor': WARNING, 71 | 'no-caller': WARNING, 72 | 'no-catch-shadow': WARNING, 73 | 'no-cond-assign': [CRITICAL, 'always'], 74 | 'no-console': WARNING, 75 | 'no-control-regex': WARNING, 76 | 'no-debugger': WARNING, 77 | 'no-delete-var': CRITICAL, 78 | 'no-dupe-args': WARNING, 79 | 'no-dupe-keys': CRITICAL, 80 | 'no-duplicate-case': WARNING, 81 | 'no-else-return': RECOMMENDED, 82 | 'no-empty': WARNING, 83 | 'no-empty-character-class': WARNING, 84 | 'no-empty-function': WARNING, 85 | 'no-empty-pattern': WARNING, 86 | 'no-eq-null': WARNING, 87 | 'no-eval': CRITICAL, 88 | 'no-ex-assign': WARNING, 89 | 'no-extend-native': WARNING, 90 | 'no-extra-bind': WARNING, 91 | 'no-extra-label': WARNING, 92 | 'no-fallthrough': WARNING, 93 | 'no-func-assign': CRITICAL, 94 | 'no-global-assign': CRITICAL, 95 | 'no-implicit-globals': CRITICAL, 96 | 'no-implied-eval': CRITICAL, 97 | 'no-invalid-regexp': WARNING, 98 | 'no-invalid-this': CRITICAL, 99 | 'no-inner-declarations': WARNING, 100 | 'no-iterator': WARNING, 101 | 'no-label-var': CRITICAL, 102 | 'no-labels': [WARNING, {allowLoop: false, allowSwitch: false}], 103 | 'no-lone-blocks': WARNING, 104 | 'no-loop-func': WARNING, 105 | 'no-magic-numbers': OFF, 106 | 'no-mixed-operators': [WARNING, { 107 | groups: [ 108 | ['+', '-', '*', '/', '%', '**'], 109 | ['&', '|', '^', '~', '<<', '>>', '>>>'], 110 | ['==', '!=', '===', '!==', '>', '>=', '<', '<='], 111 | ['&&', '||'], 112 | ['in', 'instanceof'] 113 | ], 114 | allowSamePrecedence: false 115 | }], 116 | 'no-multi-spaces': RECOMMENDED, 117 | 'no-multi-str': WARNING, 118 | 'no-native-reassign': WARNING, 119 | 'no-negated-in-lhs': WARNING, 120 | 'no-new': WARNING, 121 | 'no-new-func': WARNING, 122 | 'no-new-object': WARNING, 123 | 'no-new-symbol': WARNING, 124 | 'no-new-wrappers': WARNING, 125 | 'no-obj-calls': WARNING, 126 | 'no-octal': WARNING, 127 | 'no-octal-escape': WARNING, 128 | 'no-proto': CRITICAL, 129 | 'no-redeclare': CRITICAL, 130 | 'no-regex-spaces': CRITICAL, 131 | 'no-restricted-syntax': [ 132 | WARNING, 133 | 'LabeledStatement', 134 | 'WithStatement', 135 | ], 136 | 'no-return-assign': WARNING, 137 | 'no-script-url': WARNING, 138 | 'no-self-assign': WARNING, 139 | 'no-self-compare': WARNING, 140 | 'no-sequences': WARNING, 141 | 'no-shadow': CRITICAL, 142 | 'no-shadow-restricted-names': WARNING, 143 | 'no-sparse-arrays': WARNING, 144 | 'no-throw-literal': WARNING, 145 | 'no-undef': WARNING, 146 | 'no-undef-init': WARNING, 147 | 'no-undefined': OFF, 148 | 'no-unexpected-multiline': WARNING, 149 | 'no-unreachable': WARNING, 150 | 'no-unsafe-finally': WARNING, 151 | 'no-unused-expressions': WARNING, 152 | 'no-unused-labels': WARNING, 153 | 'no-unused-vars': [WARNING, {vars: 'local', args: 'none'}], 154 | 'no-use-before-define': [WARNING, 'nofunc'], 155 | 'no-useless-call': CRITICAL, 156 | 'no-useless-concat': WARNING, 157 | 'no-useless-constructor': WARNING, 158 | 'no-useless-escape': WARNING, 159 | 'no-useless-rename': [WARNING, { 160 | ignoreDestructuring: false, 161 | ignoreImport: false, 162 | ignoreExport: false, 163 | }], 164 | 'no-void': WARNING, 165 | 'no-with': WARNING, 166 | 'no-warning-comments': RECOMMENDED, 167 | 'operator-assignment': [WARNING, 'always'], 168 | radix: WARNING, 169 | 'require-yield': WARNING, 170 | 'rest-spread-spacing': [WARNING, 'never'], 171 | strict: [WARNING, 'never'], 172 | 'unicode-bom': [WARNING, 'never'], 173 | 'use-isnan': WARNING, 174 | 'valid-typeof': WARNING, 175 | 'vars-on-top': RECOMMENDED, 176 | 'wrap-iife': [RECOMMENDED, 'outside'], 177 | 178 | // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/ 179 | 180 | // TODO: import rules are temporarily disabled because they don't play well 181 | // with how eslint-loader only checks the file you change. So if module A 182 | // imports module B, and B is missing a default export, the linter will 183 | // record this as an issue in module A. Now if you fix module B, the linter 184 | // will not be aware that it needs to re-lint A as well, so the error 185 | // will stay until the next restart, which is really confusing. 186 | 187 | // This is probably fixable with a patch to eslint-loader. 188 | // When file A is saved, we want to invalidate all files that import it 189 | // *and* that currently have lint errors. This should fix the problem. 190 | 191 | // 'import/default': WARNING, 192 | // 'import/export': WARNING, 193 | // 'import/named': WARNING, 194 | // 'import/namespace': WARNING, 195 | // 'import/no-amd': WARNING, 196 | // 'import/no-duplicates': WARNING, 197 | // 'import/no-extraneous-dependencies': WARNING, 198 | // 'import/no-named-as-default': WARNING, 199 | // 'import/no-named-as-default-member': WARNING, 200 | // 'import/no-unresolved': [WARNING, { commonjs: true }], 201 | 202 | // https://github.com/yannickcr/eslint-plugin-react/tree/master/docs/rules 203 | 'jsx-quotes': WARNING, 204 | 'react/jsx-boolean-value': WARNING, 205 | 'react/jsx-equals-spacing': [WARNING, 'never'], 206 | 'react/jsx-handler-names': [WARNING, { 207 | eventHandlerPrefix: 'handle', 208 | eventHandlerPropPrefix: 'on', 209 | }], 210 | 'react/jsx-no-bind': CRITICAL, 211 | 'react/jsx-no-duplicate-props': [WARNING, {ignoreCase: true}], 212 | 'react/jsx-no-undef': WARNING, 213 | 'react/jsx-pascal-case': [WARNING, { 214 | allowAllCaps: true, 215 | ignore: [], 216 | }], 217 | 'react/jsx-uses-react': WARNING, 218 | 'react/jsx-uses-vars': WARNING, 219 | 'react/no-deprecated': WARNING, 220 | 'react/no-direct-mutation-state': WARNING, 221 | 'react/no-is-mounted': CRITICAL, 222 | 'react/no-string-refs': WARNING, 223 | 'react/prefer-es6-class': CRITICAL, 224 | 'react/prefer-stateless-function': CRITICAL, 225 | 'react/react-in-jsx-scope': WARNING, 226 | 'react/require-render-return': CRITICAL, 227 | 'react/self-closing-comp': WARNING, 228 | 'react/jsx-closing-bracket-location': RECOMMENDED, 229 | 'react/jsx-wrap-multilines': WARNING, 230 | 'react/prop-types': RECOMMENDED, 231 | 'react/sort-comp': RECOMMENDED, 232 | 233 | // style 234 | 'block-spacing': [RECOMMENDED, 'always'], 235 | 'brace-style': RECOMMENDED, 236 | 'camelcase': WARNING, 237 | 'comma-dangle': WARNING, 238 | 'comma-spacing': WARNING, 239 | 'consistent-this': [WARNING, 'self'], 240 | 'func-call-spacing': WARNING, 241 | 'func-names': RECOMMENDED, 242 | 'linebreak-style': CRITICAL, 243 | 'keyword-spacing': RECOMMENDED, 244 | 'indent': [RECOMMENDED, 4], 245 | 'no-lonely-if': RECOMMENDED, 246 | 'new-cap': [WARNING, {newIsCap: true}], 247 | 'new-parens': CRITICAL, 248 | 'no-mixed-spaces-and-tabs': RECOMMENDED, 249 | 'no-multiple-empty-lines': RECOMMENDED, 250 | 'no-trailing-spaces': RECOMMENDED, 251 | 'no-unneeded-ternary': WARNING, 252 | 'no-whitespace-before-property': WARNING, 253 | 'semi': [RECOMMENDED, 'always'], 254 | 'quotes': [RECOMMENDED, 'single'], 255 | 'spaced-comment': [RECOMMENDED, 'always'], 256 | 'space-before-blocks': [RECOMMENDED, 'always'], 257 | 'space-before-function-paren': [RECOMMENDED, 'never'], 258 | 259 | // ES6 260 | 'arrow-body-style': [RECOMMENDED, 'as-needed'], 261 | 'arrow-parens': [RECOMMENDED, 'as-needed'], 262 | 'arrow-spacing': [RECOMMENDED, {'before': true, 'after': true}], 263 | 'constructor-super': WARNING, 264 | 'generator-star-spacing': [RECOMMENDED, {'before': false, 'after': true}], 265 | 'no-confusing-arrow': RECOMMENDED, 266 | 'no-useless-computed-key': WARNING, 267 | 'no-this-before-super': WARNING, 268 | 'no-duplicate-imports': RECOMMENDED, 269 | 'no-dupe-class-members': CRITICAL, 270 | 'no-const-assign': CRITICAL 271 | } 272 | }; 273 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redux Unity Router 2 | 3 | [![Travis-CI](https://api.travis-ci.org/auru/redux-unity-router.svg?branch=master)](https://travis-ci.org/auru/redux-unity-router) 4 | [![Coverage Status](https://coveralls.io/repos/github/auru/redux-unity-router/badge.svg?branch=master)](https://coveralls.io/github/auru/redux-unity-router?branch=master) 5 | [![npm version](https://badge.fury.io/js/redux-unity-router.svg)](https://badge.fury.io/js/redux-unity-router) 6 | [![Scrutinizer](https://scrutinizer-ci.com/g/auru/redux-unity-router/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/auru/redux-unity-router/) 7 | [![Deps](https://david-dm.org/auru/redux-unity-router/status.svg)](https://david-dm.org/auru/redux-unity-router) 8 | [![Deps-Dev](https://david-dm.org/auru/redux-unity-router/dev-status.svg)](https://david-dm.org/auru/redux-unity-router) 9 | [![Dependency Status](https://dependencyci.com/github/auru/redux-unity-router/badge)](https://dependencyci.com/github/auru/redux-unity-router) 10 | 11 | > Simple routing for your redux application. 12 | 13 | The main purpose of this router is to mirror your browser history to the redux store and help you easily declare routes. 14 | 15 | **We also provide [React bindings](#react-components)!** 16 | 17 | # Table of Contents 18 | 19 | * [Installation](#installation) 20 | * [Usage](#usage) 21 | * [API](#api) 22 | * [createRouter](#createrouter-history-routes-immutable-slice-) 23 | * [actions](#actions) 24 | * [actionTypes](#actiontypes) 25 | * [React components](#react-components) 26 | * [``](#routerprovider) 27 | * [``](#fragment) 28 | * [``](#link) 29 | * [Examples](#examples) 30 | * [Contributing](#contributing) 31 | * [License](#license) 32 | 33 | 34 | # Installation 35 | 36 | Install `redux-unity-router` package from npm: 37 | 38 | ```bash 39 | npm i --save redux-unity-router 40 | ``` 41 | 42 | # Usage 43 | 44 | Before proceeding to the next step, we suggest you create a file containing your routes: 45 | 46 | ```js 47 | /* routes.js */ 48 | 49 | export default { 50 | id: 'Main', 51 | pattern: '/application/', 52 | data: { 53 | pageTitle: 'My simple application' 54 | }, 55 | routes: [ 56 | { 57 | id: 'News', 58 | pattern: '/news/', 59 | data: { 60 | pageTitle: 'My news' 61 | }, 62 | routes: [ 63 | { 64 | id: 'Item', 65 | pattern: ':id' 66 | } 67 | ] 68 | }, 69 | { 70 | id: 'Contacts', 71 | pattern: '/contacts/' 72 | } 73 | ] 74 | }; 75 | ``` 76 | You can learn more about setting up `routes` and their structure in the [API section](#api). 77 | 78 | Then require those `routes` and set up your `store` like this: 79 | 80 | ```js 81 | /* store.js */ 82 | 83 | import { createStore, applyMiddleware, compose, combineReducers } from 'redux'; 84 | 85 | // Previously defined routes 86 | import routes from './routes.js'; 87 | 88 | import { createRouter, History } from 'redux-unity-router'; 89 | 90 | // Create history 91 | const history = History.createBrowserHistory(); 92 | 93 | // Create router instance 94 | const router = createRouter({ history, routes }); 95 | 96 | // Add router middleware to your list of middlewares 97 | const middleware = [ router.middleware ]; 98 | 99 | // Enhance your store by using router's enhancer 100 | const toEnhance = [ 101 | router.enhancer, 102 | applyMiddleware(...middleware) 103 | ]; 104 | 105 | // Put it all together 106 | const enhancer = compose(...toEnhance); 107 | const reducers = combineReducers({ 108 | router: router.reducer 109 | }); 110 | 111 | const initialState = {} 112 | 113 | const store = createStore(reducers, initialState, enhancer); 114 | 115 | export default store; 116 | ``` 117 | 118 | Now you've got yourself a simple routing system! 119 | 120 | After navigating to `/news/1?edit=true#title` you can expect your store's state to contain `'router'` entry similar to: 121 | ```json 122 | { 123 | "pathname": "/news", 124 | "search": "?edit=true", 125 | "hash": "#title", 126 | "key": "gmj9fs", 127 | "query": {"edit": "true"}, 128 | "state": {}, 129 | "path": "/news/1?edit=true", 130 | "route": { 131 | "pattern": {"path": "/news/:id"}, 132 | "id": "Item", 133 | "idPath": "News:Item", 134 | "params": {"id": "1"}, 135 | "data": {"pageTitle": "My news"} 136 | } 137 | } 138 | ``` 139 | 140 | You can manage your layout by using [``](#routerprovider), [``](#fragment) and [``](#link) React-components. 141 | They should help keep your application simple and maintainable. 142 | 143 | 144 | # API 145 | 146 | ## `createRouter({ history, routes, immutable, slice })` 147 | 148 | ```js 149 | import { createRouter } from 'redux-unity-router' 150 | ``` 151 | 152 | Router factory, that returns an instance of the router object, containing: [middleware](http://redux.js.org/docs/advanced/Middleware.html), [enhancer](http://redux.js.org/docs/api/applyMiddleware.html) and [reducer](http://redux.js.org/docs/basics/Reducers.html). 153 | 154 | ### `history` {Object} 155 | History object created by [abstraction](https://github.com/mjackson/history) over browser's History API. 156 | 157 | ### `routes` {Array} 158 | An array of [routes](#route-object). If any of the routes can be matched to the same pattern, the route that has been declared first in `routes` array will take precedence. 159 | 160 | ### `immutable` {Boolean} *optional* 161 | **Default:** `false` 162 | 163 | If you use [immutable](https://facebook.github.io/immutable-js/) store, set this to `true`. 164 | 165 | ### `slice` {String} *optional* 166 | **Default:** `'router'` 167 | 168 | Store's key, that will contain `router`'s data. 169 | 170 | ## `route` {Object} 171 | An object containing route's definition. 172 | 173 | ### `pattern` {Object|String} 174 | **Redux-Unity-Router** uses [path-to-regexp](https://github.com/pillarjs/path-to-regexp) for route-matching. There's also [a handy tool](http://forbeslindesay.github.io/express-route-tester/) to test your patterns. 175 | 176 | Although you can declare patterns in the form of `string`s, that becomes problematic, when you want to match a route with query parameters, that may appear in arbitrary order. For this situation you may want to declare a pattern in a form of a plain `object`: 177 | 178 | #### `path` {String} *optional* 179 | Same as you'd declare a pattern in a form of a string. 180 | 181 | #### `query` {Object} *optional* 182 | Plain query object. 183 | 184 | ### `id` {String} *optional (but recommended)* 185 | **Default:** equals `pattern` if `typeof pattern === 'string'` or `pattern.path` if `typeof pattern === 'object'` 186 | 187 | Unique Id of the route. 188 | It is recommended that you define route's id, so you can easily navigate to it with `` component. 189 | 190 | ### `data` {Object} 191 | Any arbitrary data you want reflected in the redux store, when the route is matched. 192 | 193 | ### `routes` {Array} *optional* 194 | Any sub-routes a route may have. All the patterns and data of these sub-routes will be merged with their parent's . Sub-routes always take precedence over their parents in the matching process. 195 | 196 | ## Example: 197 | 198 | ```js 199 | const routes = [ 200 | { 201 | id: 'User', 202 | pattern: '/user/:id', 203 | data: { 204 | pageTitle: 'User Profile' 205 | } 206 | routes: [ 207 | { 208 | id: 'UserEdit', 209 | data: { 210 | pageTitle: 'Edit User Profile' 211 | }, 212 | pattern: { 213 | query: { 214 | edit: 'true' 215 | } 216 | } 217 | }, 218 | { 219 | id: 'UserLogout', 220 | data: { 221 | message: 'goodbye' 222 | }, 223 | 224 | pattern: { 225 | path: 'logout' 226 | } 227 | } 228 | ] 229 | } 230 | ] 231 | 232 | // This will produce 3 patterns: 233 | // { path: '/user/:id', query: { edit: 'true' }, data: { pageTitle: 'User Profile' } } 234 | // { path: '/user/:id/logout', data: { pageTitle: 'Edit User Profile' } } 235 | // { path: '/user/:id', data: { pageTitle: 'User Profile', message: ''goodbye' } } 236 | ``` 237 | 238 | ## actions 239 | ```js 240 | import { actions } from 'redux-unity-router' 241 | ``` 242 | or 243 | ```js 244 | import actions from 'redux-unity-router/actions' 245 | ``` 246 | 247 | Actually, these are action-creators (functions, that produce plain action objects). You can use them if you want to programmatically navigate to any part of your application. Most of them correspond to standard methods of browser's History API (except for `goToRoute` and `locationChange`). 248 | 249 | ### `push(payload)` 250 | Navigate to new url/path. 251 | 252 | #### `payload` {String|Object} 253 | * `payload` of type `string` will be interpreted as path or url. 254 | * `payload` of type `object` should contain one the following properties: 255 | 256 | ##### `pathname` {String} *optional* 257 | e.g. `'/news'` 258 | ##### `search` {String} *optional* 259 | e.g. `'?edit=true'` 260 | ##### `hash` {String} *optional* 261 | e.g. `'#title'` 262 | ##### `silent` {Boolean} *optional* 263 | **Default:** `false` 264 | 265 | This extra option allows you to change current location url without propagating changes to the Redux store. 266 | 267 | ### `replace(payload)` 268 | Navigate to new url/path, replacing current history entry. 269 | 270 | #### `payload` {String/Object} 271 | Same as for `push` action. 272 | 273 | ### `go(payload)` 274 | Go back or forward in history stack. 275 | 276 | #### `payload` {Integer} 277 | e.g. `-1` 278 | 279 | ### `goBack()` 280 | Equivalent to `go(-1)`. 281 | 282 | ### `goForward()` 283 | Equivalent to `go(1)`. 284 | 285 | ### `goToRoute(payload)` 286 | Navigate to the predefined route. 287 | 288 | #### `payload` {Object} 289 | 290 | ##### `id` {String} 291 | Valid route ID. 292 | 293 | ##### `params` {Object} *optional* 294 | If our route contains parameters, you should provide them here. 295 | 296 | ##### `query` {Object} *optional* 297 | Plain query object. 298 | 299 | ##### `hash` {String} *optional* 300 | Hash for the resulting url. 301 | 302 | ### Example: 303 | If you've defined a route like this: 304 | ```js 305 | { 306 | id: 'Preferences', 307 | pattern: '/prefs/:action' 308 | } 309 | ``` 310 | and you want to navigate to `/prefs/edit?edit=true#title`, you should dispatch an action like this: 311 | ```js 312 | store.dispatch(actions.goToRoute({ 313 | id: 'Preferences', 314 | params: { action: 'edit' }, 315 | query: { edit: true }, 316 | hash: 'title' 317 | })); 318 | ``` 319 | 320 | ### `locationChange(payload)` 321 | You most likely will **never** use this one, as it is used by **Redux-Unity-Router** internally to produce an entirely new router state. 322 | 323 | #### payload {Object} 324 | Check your store for this one! 325 | 326 | ## actionTypes 327 | ```js 328 | import { actionTypes } from 'redux-unity-router' 329 | ``` 330 | or 331 | ```js 332 | import actionTypes from 'redux-unity-router/actionTypes' 333 | ``` 334 | 335 | Internally **Redux-Unity-Router** dispatches actions with following action-types 336 | 337 | * @@REDUX_UNITY_ROUTER/**LOCATION_CHANGED** 338 | * @@REDUX_UNITY_ROUTER/**PUSH** 339 | * @@REDUX_UNITY_ROUTER/**REPLACE** 340 | * @@REDUX_UNITY_ROUTER/**GO** 341 | * @@REDUX_UNITY_ROUTER/**GO_BACK** 342 | * @@REDUX_UNITY_ROUTER/**GO_FORWARD** 343 | * @@REDUX_UNITY_ROUTER/**GO_TO_ROUTE** 344 | 345 | Keep in mind, that if you want to handle actions with these action-types in your **reducers**, all actions except for 346 | @@REDUX_UNITY_ROUTER/**LOCATION_CHANGED** will be swallowed by **Redux-Unity-Router**'s middleware. 347 | 348 | # React components 349 | 350 | Although you can easily manage application's layout based on the router's redux store data, we do provide some handy react components for you to use: 351 | 352 | ## `` 353 | ### Props 354 | #### `routes` {Array} 355 | An array of routes. We advice you use the same array for both `createRouter` and ``. 356 | 357 | #### `immutable` {Boolean} 358 | **Default:** `false` 359 | 360 | If you use [immutable](https://facebook.github.io/immutable-js/) store, set this to `true`. 361 | 362 | #### `slice` {String} *optional* 363 | **Default:** `'router'` 364 | 365 | Store's key, that will contain `router`'s data. Use the same one you've used in `createRouter` setting up your store. 366 | 367 | ## `` 368 | ### Props 369 | Supports all default `
` properties. 370 | 371 | #### `to` {String|Object} 372 | * `string` type will be interpreted as **path** or **url** (external urls are supported as well) 373 | * `object` type can be interpreted 2 ways: if it has property `id`, it will be interpreted as `route` (see [actions.goToRoute](#gotoroutepayload)), otherwise it will be considered as a standard location object (see [actions.push](#pushpayload)). 374 | 375 | #### `className` {String} *optional* 376 | **Default:** `'link'` 377 | 378 | `className` for the generated link. 379 | 380 | #### `activeClass` {String} *optional* 381 | **Default:** `'link__active'` 382 | 383 | `className` that will be added to the link if it matches current route. 384 | 385 | #### `target` {String|Null} *optional* 386 | **Default:** `null` 387 | 388 | Target attribute. 389 | 390 | #### `activeMatch` false|'exact'|'partial'|{RegExp} *optional* 391 | **Default:** `false` 392 | 393 | Dictates whether and how `activeClass` should be added to the link. 394 | * **`false`** - no current route matching at all. This is the default behavior. 395 | * **`'exact'`** - link will receive its `activeClass` only if its `pathname`, `query` and `hash` match current route's. 396 | * **`'partial'`** - link will receive its `activeClass` when current route's `pathname` begins with the `pathname` supplied to link. 397 | * **`'{RegExp}'`** - if you supply a regular expression, current route's entire `path` will be tested against it. 398 | 399 | #### `onClick` {Function} *optional* 400 | **Default:** `undefined` 401 | 402 | Optional `onClick` callback, that will be fired before `` dispatches its navigation action. If this callback returns a `{Promise}`, ``'s navigation action will be fired, when the returned `{Promise}` resolves. 403 | 404 | ### Example 405 | ```html 406 | // Navigates to /application 407 | Main page 408 | 409 | // Navigates to /application/news?id=1#comment-box 410 | News 411 | 412 | // if route is defined like { id: 'Settings', pattern: '/user/:userId' } navigates to /user/1?edit=true#title 413 | 414 | ``` 415 | 416 | ## `` 417 | Displays react components and other ``s based on current route. 418 | 419 | ### Props 420 | 421 | #### `id` {String} 422 | Route's ID that you want a `` displayed on. 423 | 424 | #### `redirect` {String|Object} *optional* 425 | Redirect to `path`, `url` or `route`, when `id` is the last in the chain of nested `'`s ids. 426 | 427 | See [``](#link)'s `to` prop. 428 | 429 | #### `component` {React component} *optional* 430 | You can pass react component to be used as a direct child of the ``. 431 | 432 | ### Example 433 | ```html 434 | 435 | Main component for my application. 436 | 437 | 438 | Component with my news list. 439 | 440 | 441 | // imported react component 442 | 443 | 444 | // imported react component 445 | 446 | 447 | 448 | 449 | You have been redirected here. 450 | 451 | 452 | 453 | ``` 454 | 455 | ## Examples 456 | We provide a basic example of working React app that you can dig into. Just clone this repo and run: 457 | ```bash 458 | npm run build:examples 459 | ``` 460 | 461 | ## Contributing 462 | 463 | * Provide [conventional commit messages](https://github.com/conventional-changelog/conventional-changelog-angular/blob/master/convention.md) by using `npm run commit` instead of `git commit`. 464 | * **Core contributors:** use GitHub's *Rebase and merge* as a default way of merging PRs. 465 | 466 | ## License 467 | MIT © AuRu 468 | --------------------------------------------------------------------------------