diff --git a/README.md b/README.md index 4ad6724..987c87c 100644 --- a/README.md +++ b/README.md @@ -12,1003 +12,10 @@ To install: ```bash $ npm i -S ion-router ``` -Table of Contents -================= - * [Simple example](#simple-example) - * [Internal Linking with <Link>](#internal-linking-with-link) - * [Extending the example: asynchronous state loading](#extending-the-example-asynchronous-state-loading) - * [Available selectors for Toggle](#available-selectors-for-toggle) - * [What about complex routes like react\-router nested <Route>?](#what-about-complex-routes-like-react-router-nested-route) - * [Dynamic Routes](#dynamic-routes) - * [enter/exit hooks](#enterexit-hooks) - * [Code splitting and asynchronous loading of Routes](#code-splitting-and-asynchronous-loading-of-routes) - * [Server-side Rendering](#server-side-rendering) - * [Explicitly changing URL](#explicitly-changing-url) - * [Reverse routing: creating URLs from parameters](#reverse-routing-creating-urls-from-parameters) - * [Why a new router?](#why-a-new-router) - * [Principles](#principles) - * [URL state is just another asynchronous input to redux state](#url-state-is-just-another-asynchronous-input-to-redux-state) - * [When the URL changes, it should cause a state change in the redux store](#when-the-url-changes-it-should-cause-a-state-change-in-the-redux-store) - * [When the state changes in the redux store, it should be reflected in the URL](#when-the-state-changes-in-the-redux-store-it-should-be-reflected-in-the-url) - * [Route definition is separate from the components](#route-definition-is-separate-from-the-components) - * [IndexRoute, Redirect and ErrorRoute are not necessary](#indexroute-redirect-and-errorroute-are-not-necessary) - * [Easy testing](#easy-testing) - * [License](#license) - * [Thanks](#thanks) +## New Documentation -## Simple example - -Let's expand upon the [todo list example from the redux documentation](http://redux.js.org/docs/basics/ExampleTodoList.html) - -In the sample application, we can create new todos, mark them as finished, and filter -the list to display all of them, just active todos, and just completed todos. We can -add URL routing quite simply by focusing on the filtering state. - -We'll respond to these 3 URLs: - -``` -/filter/SHOW_ALL -/filter/SHOW_ACTIVE -/filter/SHOW_COMPLETED -``` - -To do this, we'll need to add four items to the app: - - 1. The router reducer, for storing routing state. - 2. A route definition, mapping url to state, and state to url - 3. The route definition within the app itself - 4. include redux-saga and react-redux, and pass in the sagaMiddleware and connect - -reducers/index.js: -```javascript -import { combineReducers } from 'redux' -import routing from 'ion-router/reducer' // the new line -import todos from './todos' -import visibilityFilter from './visibilityFilter' - -const todoApp = combineReducers({ - todos, - visibilityFilter, - routing // add the routing reducer -}) - -export default todoApp -``` - -Routes.js: -```javascript -import React from 'react' -import Routes from 'ion-router/Routes' -import Route from 'ion-router/Route' -import * as actions from './actions' - -const paramsFromState = state => ({ visibilityFilter: state.visibilityFilter }) -const stateFromParams = params => ({ - visibilityFilter: params.visibilityFilter || 'SHOW_ACTIVE' -}) -const updateState = { - visibilityFilter: filter => actions.setVisibilityFilter(filter) -} - -export default () => ( - - - -) -``` - -index.js: -```javascript -import React from 'react' -import { render } from 'react-dom' -import { createStore, applyMiddleware, combineReducers } from 'redux' -import { Provider, connect } from 'react-redux' // new - import connect -import makeRouter, { makeRouterMiddleware } from 'ion-router' // our router - new line -import routing from 'ion-router/reducer' - -import todoApp from './reducers' -import App from './components/App' - -// set up the router and create the store -const routerMiddleware = makeRouterMiddleware() -const store = createStore(combineReducers({ - todoApp, - routing // router reducer here -}), undefined, applyMiddleware(routerMiddleware)) // router middleware -makeRouter(connect, store) // set up the router - - -render( - - - , - document.getElementById('root') -) -``` - -then add these lines inside App.js: - -```javascript -import Routes from './Routes' // new line -// ... - -const App = () => ( -
- - - -
-) -``` - -### Internal linking with `` - -Note that if we want to set up a menu of urls, ion-router provides a -`` component that should be used for all internal links. It uses the `to` -prop in place of href. An onClick handler may be passed to handle the click in -a custom fashion. All other props will be passed through to the internal `` -tag. - -If you wish to replace the current url instead of pushing, use the `replace` prop -instead of the `to` prop. - -Unlike any other router, the `` component can also create abstract routes -from a list of route parameters. With this route declaration: - -```javascript -const routes = () => ( - - - -) -``` - -we can create a link like so: - -```javascript -const App = () => ( -
- -
-) -``` - -and if the dynamic value refers to `123` the route will link to `/this/hi/123` - -### Extending the example: asynchronous state loading - -What if we are loading the todo list from a database? There will be a short delay while -the list is loaded, and the user will just see an empty list of todos. If they add a todo, -the todo id could suddenly conflict with todos the user creates, which would erase them on the -database load. Better is to display a different component while loading. - -To implement this with our router, you will use: - - 1. a loading component that will be displayed when the todos are loading - 2. a "Toggle" higher order component that is used to switch on/off display of a - component or its loading component - 3. an asynchronous program to load the todos from the database. - 4. an additional way of marking whether state is loaded or not in the store, and - actions and reducer code to capture this state. - -redux-saga is an excellent solution for expressing complex asynchronous actions in a -simple way. You can write your asynchronous loader in any manner you choose, whether -it is a thunk, saga, observable, or fill-in-your-favorite. - -For this example, we will assume that you can add a simple "loaded" field to the todos -reducer, and actions to set it to true or false. - -Let's design the loading component first: - -Loading.js: -```javascript -import React from 'react' - -export default () => ( -
-

Loading...

-
-) -``` - -Asynchronous loading of the todo items from the database can be accomplished with a very -simple saga. The saga assumes that the todos can be accessed via a REST service that -returns JSON, and uses the axios library to make an xhr call to retrieve it from the -server at the `"/getTodos"` address. - -loadTodosSaga.js: -```javascript -import { call, put } from 'redux-saga/effects' -import axios from 'axios' - -import * as actions from './actions' - -export default function *loadTodos() { - // mark loading as starting - yield put(actions.setLoaded(false)) - const todos = yield call([axios, axios.get], '/getTodos') - // a new action for setting all of the todos at once - yield put(actions.setTodos(todos)) - // mark loading as finished - yield put(actions.setLoaded(true)) -} -``` - -Now let's create a Toggle. A Toggle is a higher order component that responds to state in order -to turn on or off the display of a component, like a toggle switch. It takes 2 callbacks as parameters. -Each callback receives the state as a parameter and should return truthy or falsey values. The first is -used to determine whether the main component should be displayed. The second optional callback is used -to determine whether state is still loading, and if so, whether to display the loading component. -By default, if no loading callback is passed in, a Toggle assumes that the state is -loaded. - -In our example, there is only 1 route, and so we will display it if our state is marked -as loaded. If not, we will not display the component. Instead, we will display the -loading component. Here is the source: - -TodosToggle.js: -```javascript -import Toggle from 'ion-router/Toggle' - -export default Toggle(state => state.loaded, state => !state.loaded) -``` - -The TodosToggle is a component that accepts 2 props: `component` and `loadingComponent`. -`component` should be a React component or connected container to display if the -Toggle condition is satisfied, and `loadingComponent` should be a React component or connected -container to display if the loading condition is satisfied. - -Note that if both callbacks return true, then the loading component will be displayed. - -Finally, the usage of TodosToggle is straightforward. - -in App.js: -```javascript -import React from 'react' -import Footer from './Footer' -import AddTodo from '../containers/AddTodo' -import VisibleTodoList from '../containers/VisibleTodoList' - -import Routes from './Routes' -import Loading from './Loading' -import TodosToggle from './TodosToggle' - -const App = () => ( -
- - - -
-
-) - -export default App -``` - -Now our component will display the todo list only when it has loaded. - -### Common use case: displaying a component when a route is selected - -In most applications, there are menus that select components based on the user -selecting a sub-application. To display components whose sole display criteria is -the selection of a route, use a `RouteToggle` - -```javascript -import RouteToggle from 'ion-router' - -const TodosRoute = RouteToggle('todos') -``` - -In this way, you can display several components scattered around a layout template -that are route-specific without having to make a new layout template just for that route, -or doing any strange contortions. - -A `RouteToggle` accepts all the arguments of Toggle after the route name to match: - -```javascript -import RouteToggle from 'ion-router' - -const TodosRoute = RouteToggle('todos', state => state.whatever === 'hi') -``` - -The example above will only toggle if the todos route is active and the `whatever` -portion of state is equal to 'hi' - -A `RouteToggle` can be thought of -as a simpler version of this source code: - -```javascript -import Toggle from 'ion-router/Toggle' -import { matchedRoutes } from 'ion-router/selectors' - -const TodosRoute = Toggle(state => matchedRoutes(state, 'todos')) -``` - -### Available selectors for Toggles - -The following selectors are available for use with Toggles. import as follows: - -```javascript -import * as selectors from 'ion-router/selectors' -``` - -#### matchedRoute(state, name) - -```javascript -import * as selectors from 'ion-router/selectors' -import Toggle from 'ion-router/Toggle' - -export Toggle(state => selectors.matchedRoute(state, 'routename')) -``` - -`matchedRoute` accepts a single route name, or an array of route names to match. -By default, it matches on any route. To enable strict matching (all routes must match) -pass in true to the third parameter of matchedRoute - -```javascript -import * as selectors from 'ion-router/selectors' -import Toggle from 'ion-router/Toggle' - -export Toggle(state => selectors.matchedRoute(state, ['route', 'subroute'], true)) -``` - -This is useful for strict matching of a sub-route path. - -Note that a convenience Toggle, `RouteToggle` exists to match a route: - -```javascript -import RouteToggle from 'ion-router/RouteToggle' - -export RouteToggle('routename', state => otherconditions()) -``` - -This selector returns true if the route specified by `'routename'` is active - -#### noMatches -```javascript -import * as selectors from 'ion-router/selectors' -import Toggle from 'ion-router/Toggle' - -export Toggle(state => selectors.noMatches(state)) -``` - -This selector returns true if no routes match, and can be used for an error component -or default component - -#### stateExists - -```javascript -import * as selectors from 'ion-router/selectors' -import Toggle from 'ion-router/Toggle' - -export Toggle(state => state.whatever, state => selectors.stateExists(state, /* state descriptor */)) -``` - -This toggle is designed to be used to detect whether state has loaded. Pass in -a skeleton of the state shape and it will traverse the state to determine whether it exists. - -Here is a sample from an actual project: - -```javascript -import Toggle from 'ion-router/Toggle' -import * as selectors from 'ion-router/selectors' - -export const check = state => selectors.stateExists(state, { - campers: { - ids: [] - }, - groups: { - ids: [], - groups: {}, - selectedGroup: (group, state) => { - if (!group) return true - if (state.groups.ids.indexOf(group) === -1) return false - const g = state.groups.groups[group] - if (!g) return false - if (g.type && !state.ensembleTypes.ensembleTypes[g.type]) return false - if (g.members.length) { - if (g.members.some(m => m ? !state.campers.campers[m] : false)) return false - } - return true - } - }, - ensembleTypes: { - ids: [], - }, -}) - -export default Toggle(state => state.groups.selectedGroup, check) -``` - -The selector verifies that the campers and ensembleTypes state areas have an ids -member that is an array, and that the groups state area has ids and groups set up. -For selectedGroup, a callback is called, passed the value of the state item plus the -entire state tree. The callback verifies that the selected group's state is internally -consistent and when everything is set up, returns true. - -### What about complex routes like react-router nested ``? - -For a complex application, there will be components that should only display on certain -routes. For example, an example from the react-router documentation: - -```javascript -render(( - - - - - - - - - -), document.getElementById('root')) -``` - -There are 3 things happening here. - - 1. The App structure is dictated by the declaration of routes. - 2. The `App` component will have as its `children` prop set to `About` or `Users` or - `NoMatch`, depending on the url. - 3. In addition, if the route is `/user/123` the `App` component - will have its children set to `Users` with its children set to `User` - -This complexity is forced by the design of react-router. How can we express these routes -using ion-router? - -We need 2 things: - - 1. Toggles for routes and for selected user, and for no match - 2. Plugging in the Toggles where they should be displayed within the React tree. - -```javascript -import * as selectors from 'ion-router/selectors' -import Toggle from 'ion-router/Toggle' -import RouteToggle from 'ion-router/RouteToggle' - -const AboutToggle = RouteToggle('about') -const UsersToggle = RouteToggle(['users', 'user']) -const SelectedUserToggle = Toggle(state => !!state.users.selectedUser, - state => usersLoaded(state) && state.users.user[state.users.selectedUser]) -const NoMatchToggle = Toggle(state => selectors.noMatches(state)) -``` - -Now, to plug them in: - -App.js -```javascript -// App class render: - render() { - return ( -
- - - -
- ) - } -``` - -Routes.js: -```javascript -import React from 'react' -import Routes from 'ion-router/Routes' -import Route from 'ion-router/Route' -import * as actions from './actions' - -const paramsFromState = state => ({ userId: state.users.selectedUser || undefined }) -const stateFromParams = params => ({ userId: params.userId || false }) -const updateState = { - userId: id => actions.setSelectedUser(id) -} -const exitParams = { - userId: undefined -} - -export default () => ( - - - - - -) -``` - -Note that the `else` prop of a Toggle higher order component can be used to display an -alternative component if the state test is not satisfied, but the component state is loaded. -So in our example, we want to display the user list if a user is not selected, so we set our -`else` to `Users` and our `component` to `User` - -UsersRoute.js: -```javascript - render() { - return ( -
- -
- ) - } -``` - -Easy! - -### Dynamic Routes - -Sometimes, it is necessary to implement dynamic routes that are calculated from a parent -route. This can be done quite easily. - -```javascript -// parent route -const Parent = () => ( - - - -) -// dynamically loaded later -const Child = ({ parentroute }) => ( - - - -) -``` - -In this case, the child will make its path match `/parent/path/:hi` - -### `enter`/`exit` hooks - -To implement enter or exit hooks you can listen for the `ENTER_ROUTES` or -`EXIT_ROUTES` action to perform actions such as loading asynchronous state. -Here is a sample implementation: - -```javascript -import * as types from 'ion-router/types' - -function *enter() { - while (true) { - const action = yield take(types.ENTER_ROUTE) - if (action.payload.indexOf('myroute') === -1) continue - // enter code goes here - do { - const second = yield take(types.EXIT_ROUTE) - } while (second.payload.indexOf('myroute') === 1) - // exit code goes here - } -} -``` - -Anything can be done in this code, including forcing a route to change, like a traditional -enter/exit hook. Because it is so trivial to implement this with the above code, the -event loop that listens for URL changes and state changes does not listen for enter/exit -hooks directly. - -#### updating state on route exit - -All routes that accept parameters and map them to state will need to unset that state -upon exiting the route. ion-router can do this automatically for any -route with only optional parameters, such as: - -`/path(/:optional(/:second_optional))` - -However, for routes that have a required parameter such as: - -`/path/:required` - -you need to tell the router how to handle this case. If the required parameter should -be simply set to undefined upon exiting, then you need to explicitly pass this -into the `exitParams` prop for `` - -```javascript -exitParams = { - required: undefined -} -const Routes = () => ( - - - -) -``` - -If you wish to dynamically set up the parameters based on existing parameters, pass -in a function that accepts the previous url's params as an argument and returns the -exit params: - -```javascript -exitParams = params => ({ - required: params.required, - optional: undefined -}) -const Routes = () => ( - - - -) -``` - -### Code splitting and asynchronous loading of Routes - -Routes can be loaded at any time. If you load a new component asynchronously (using -require.ensure, for instance), and dynamically add a new `...` inside that -component, the router will seamlessly start using the route. Code splitting has never -been simpler. - -### Server-side Rendering - -When rendering routes on the server, there are 2 options. No changes need be made -to the component source. However, because of the way server rendering works, multiple -actions and re-renders will occur when setting up routes. To avoid the performance -penalty for complex applications, an optional third parameter to the router setup -can be used to pass in the routes. The definition of routes is an object with the -same keys as the props one would pass to a `` tag. - -so instead of: - -```javascript -// set up our router -makeRouter(connect, store) - -exitParams = params => ({ - required: params.required, - optional: undefined -}) -const Routes = () => ( - - - -) -``` - -one would use: - - -```javascript -exitParams = params => ({ - required: params.required, - optional: undefined -}) - -// set up our router -makeRouter(connect, store, [{ - name: 'test', - path: '/path/:required(/:optional)', - stateToParams=..., - paramsToState=..., - updateState={...}, - exitParams={exitParams}, -}]) - -``` - -The same setup can be used on both client and server for root routes, so there is no -need to keep the `` and `` elements in your component tree if -you choose to initialize on start-up. You should continue to use the components for -dynamic routes loaded later. - -### Explicitly changing URL - -A number of actions are provided to change the browser state directly, most useful -for menus and other direct links. - -ion-router uses the [history](https://github.com/mjackson/history) package -internally. The actions mirror the push/replace/go/goBack/goForward methods as -documented for the history package. - -```javascript -import * as actions from 'ion-router/actions' -dispatch(actions.push('/path/to/go/to/next')) -dispatch(actions.goBack()) -// etc. -``` - -### Reverse routing: creating URLs from parameters - -ion-router uses the [route-parser](https://github.com/rcs/route-parser) -package internally, which allows us to take advantage of some great features. - -The `makePath` function is available for creating a url from params, allowing -separation of the URL structure from the data that is used to populate it. - -```javascript -import { makePath } from 'ion-router' - -// if we have a route like this: -const a = - -console.log(makePath('foo', { - fancy: 'pants' -})) -// '/my/pants/path' -console.log(makePath('foo', { - fancy: 'pants', - wow: 'oops' -})) -// this will not match the second portion because "supercomplicated" is not specified -// '/my/pants/path' -console.log(makePath('foo', { - fancy: 'pants', - wow: 'yes', - supercomplicated: '/this/works/just/fine' -})) -// '/my/pants/path/yes/this/works/just/fine -console.log(makePath('foo', { - fancy: 'pants', - wow: 'yes', - supercomplicated: '/this/works/just/fine', - thing: 'wheeee' -})) -// '/my/pants/path/yes/this/works/just/fine/wheeee -``` - -## Why a new router? - -[react-router](https://github.com/ReactTraining/react-router) is a mature router for -React that has a huge following and community support. Why create a new router? -In my work with react-router, I found that it was not possible to achieve -some basic goals using react-router. I couldn't figure out a way to store state from -url parameters and easily change the url from the state when using redux. It is the -classic two-way binding issue: if there are 2 sources of state, they will fight and -cause unexpected bugs. - -In addition, I moved to redux for state because the tree of components in React rarely -corresponds to the way data is used. In many cases, I find myself rendering different -portions of the component tree using the same data. So I will have 2 React components -in totally different parts of the component tree using the same piece of data. -With react-router, I found myself duplicating a lot of content with a single component, -or using complex routing rules to enable displaying this information. react-router -version 4 allows declaring the same route multiple times throughout the code, but -this can make things more confusing, and opens up another vector for bugs since -route information must be duplicated wherever it is used. - -With ion-router, multiple components can respond to a route change anywhere -in the React component tree, allowing for more modular design. The below solution -is more performant both because the components -are not rendered at all if the route is not satisfied. - -```javascript -import React from 'react' -import Routes from 'ion-router/Routes' -import Route from 'ion-router/Route' -import Toggle from 'ion-router/Toggle' -import { connect } from 'react-redux' - -import * as actions from './actions' - -const albumRouteMapping = { - stateFromParams: params => ({ id: params.album, track: +params.track }), - paramsFromState: state => ({ - album: state.albums.selectedAlbum.id, - track: state.albums.selectedTrack ? state.albums.selectedTrack : undefined - }), - updateState: { - id: id => actions.selectAlbum(id), - track: track => actions.playTrack(track) - } -} - -const TrackToggle = Toggle(state => state.albums.selectedTrack, - state => state.albums.selectedTrack - && state.albums.allTracks[state.albums.selectedTrack].loading) -const AlbumToggle = Toggle(state => state.albums.selectedAlbum) - -const AlbumList = ({ albums, selectAlbum }) => ( -
    - {albums.map(album =>
  • {album.name}
  • )} -
-) - -const AlbumDetail = ({ album, playTrack }) => ( -
    -
  • Album details
  • -
  • ...(stuff from the {album.name}
  • - {album.tracks.map(track =>
  • {track.name}
  • )} -
-) - -const AlbumSummary = ({ album }) => { -

- {album.name} -

-} - -const TrackPlayer = ({ track }) => { -
-

{track.title}

- -
-} - -const AlbumSummaryContainer = connect(state => ({ album: state.albums.selectedAlbum }))(AlbumSummary) -const AlbumListContainer = connect(state => ({ albums: state.albums.allAlbums }), - dispatch => ({ selectAlbum: id => dispatch(actions.selectAlbum(id)) }))(AlbumList) -const AlbumDetailContainer = connect(state => ({ album: state.albums.selectedAlbum }), - dispatch => ({ playTrack: id => dispatch(actions.selectTrack(id)) }))(AlbumDetail) -const TrackPlayerContainer = connect(state => ({ track: state.albums.tracks[state.albums.selectedTrack] }))(TrackPlayer) - -const MyComponent = () => ( -
- - - - - - - - - - - - - -
-) -``` - -In addition, declaring new routes in asynchronously loaded code is trivial with this -design. One need only put in `` declarations in the child code and the new routes -will be added, and also automatically removed if the child code is removed from the -render tree. - -## Principles - -Most routers start from an assumption that the url determines what part of the application -to display. This results in a tree of urls mapping to components. Because routes -are defined by the URL, it then becomes necessary to provide hooks and an index route, and -an unknown route and so on and so forth. - -However clever one is, this results in a very subtle logic flaw when using redux. Redux-based -applications consider the store state to be a single source of truth. As such, general -state is not stored inside components, or pulled out of the client-side database or the -url state from pushState/popState. [Read more about the state debate](https://github.com/reactjs/redux/issues/1287). - -### URL state is just another asynchronous input to redux state - -We are trained to think of the browser URL as some kind of magic all-knowing state container. -Simply because it is there and the user can directly change it to any value. But how different -is this really than a database accessed on the server via asynchronous xhr? Or even -synchronous localStorage? Let's stop thinking of the URL as a state container. It -is an input that we can use to create state. - -### When the URL changes, it should cause a state change in the redux store - -We want our URL to change the way the application works. This allows users to bookmark a -particular view, such as an email (/inbox/message/243) or a particular todo list filter -(/todos/all or /todos/search/house) - -### When the state changes in the redux store, it should be reflected in the URL - -If a user clicks on something that affects the application state by triggering an action, -such as selecting an email to view, we want the URL to then update so the user can bookmark -that application state or share it. - -This single principle is the reason for the existence of this router. - -### Route definition is separate from the components - -Because URL state is just another input to the redux state, we only need to define -how to transform URLs into redux state. Components then choose whether to render based -on that state. This is a crucial difference from every other router out there. - -### Components are explicitly used where they go, and can be moved anywhere - -With traditional routers, you must render the component where the route is declared. -This creates rigidity. In addition, with programs based on react-router, the link -between where a component exists and the route lives in the router. The only indication -that something "routey" is happening is the presence of `{this.props.children}` which -can make debugging and technical debt higher. This router restores the natural tree and -layout of a React app: you use the component where it will actually be rendered in the -tree. Less technical debt, less confusion. - -The drawback is that direct connection between URL and component is less obvious. The -tradeoff seems worth it, as the URL is just another input to the program. Currently, -the relationship between database definition and component is just as opaque, and that -works just fine, this is no different. - -### IndexRoute, Redirect and ErrorRoute are not necessary - -Use Toggle and smart (connected) components to do all of this logic. For example, an -error route is basically a toggle that only displays when other routes are not selected. -You can use the `noMatches` selector for this purpose. An indexRoute can be implemented -with the `matchedRoute('/')` selector (and by defining a route for '/'). - -A redirect can be implemented simply by listening for a URL in a saga and pushing a new -one: - -```javascript -import { replace } from 'ion-router' -import { ROUTE } from 'ion-router/types' -import { take, put } from 'redux-saga/effects' -import { createPath } from 'history' -import RouteParser from 'route-parser' // this is used internally - -function *redirect() { - while (true) { - const action = yield (take(ROUTE)) - const parser = new RouteParser('/old/path/:is/:this') - const newparser = new RouteParser('/new/:is/:this') - const params = parser.match(createPath(action)) - if (params) { - yield put(replace(newparser.reverse(params))) - } - } -} -``` - -### Easy testing - -Everything is properly isolated, and testable. You can easily unit test your route -stateFromParams and paramsFromState and updateState properties. Components are -simply components, no magic. - -To set up routes for testing in a unit test, the `synchronousMakeRoutes` functions is -available. Pass in an array of routes, and use the return in the reducer - -```javascript -import { synchronousMakeRoutes, routerReducer } from 'ion-router' - -describe('some component that uses routes', () => { - let fakeState - beforeEach(() => { - const action = synchronousMakeRoutes([ - { - name: 'route1', - path: '/route1' - }, - { - name: 'route1', - path: '/route2/:thing', - stateToParams: state => state, - paramsToState: params => params, - update: { - thing: thing => ({ type: 'changething', payload: thing }) - } - } - ]) - fakeState = { - routing: routerReducer(undefined, action) - } - }) - it('test something', () => { - // use components that have or - }) -}) -``` - -You will need to set this up for any `` components that use route to generate -the path, and any components that contain `` or `` tags when rendering -them. +Our documentation now lives [here](https://cellog.github.io/ion-router) ## License diff --git a/docs/md/README.md b/docs/md/README.md new file mode 100644 index 0000000..db8b4b7 --- /dev/null +++ b/docs/md/README.md @@ -0,0 +1,976 @@ +Ion Router Logo + +# ion-router +###### Connecting your url and redux state + +[![Code Climate](https://codeclimate.com/github/cellog/ion-router/badges/gpa.svg)](https://codeclimate.com/github/cellog/ion-router) [![Test Coverage](https://codeclimate.com/github/cellog/ion-router/badges/coverage.svg)](https://codeclimate.com/github/cellog/ion-router/coverage) [![Build Status](https://travis-ci.org/cellog/ion-router.svg?branch=master)](https://travis-ci.org/cellog/ion-router) [![npm](https://img.shields.io/npm/v/ion-router.svg)](https://www.npmjs.com/package/ion-router) + +Elegant powerful routing based on the simplicity of storing url as state + +To install: + +```bash +$ npm i -S ion-router +``` +Table of Contents +================= + + * [Simple example](#simple-example) + * [Internal Linking with <Link>](#internal-linking-with-link) + * [Extending the example: asynchronous state loading](#extending-the-example-asynchronous-state-loading) + * [Available selectors for Toggle](#available-selectors-for-toggle) + * [What about complex routes like react\-router nested <Route>?](#what-about-complex-routes-like-react-router-nested-route) + * [Dynamic Routes](#dynamic-routes) + * [enter/exit hooks](#enterexit-hooks) + * [Code splitting and asynchronous loading of Routes](#code-splitting-and-asynchronous-loading-of-routes) + * [Server-side Rendering](#server-side-rendering) + * [Explicitly changing URL](#explicitly-changing-url) + * [Why a new router?](#why-a-new-router) + * [Principles](#principles) + * [URL state is just another asynchronous input to redux state](#url-state-is-just-another-asynchronous-input-to-redux-state) + * [When the URL changes, it should cause a state change in the redux store](#when-the-url-changes-it-should-cause-a-state-change-in-the-redux-store) + * [When the state changes in the redux store, it should be reflected in the URL](#when-the-state-changes-in-the-redux-store-it-should-be-reflected-in-the-url) + * [Route definition is separate from the components](#route-definition-is-separate-from-the-components) + * [IndexRoute, Redirect and ErrorRoute are not necessary](#indexroute-redirect-and-errorroute-are-not-necessary) + * [Easy testing](#easy-testing) + * [License](#license) + * [Thanks](#thanks) + +## Simple example + +Let's expand upon the [todo list example from the redux documentation](http://redux.js.org/docs/basics/ExampleTodoList.html) + +In the sample application, we can create new todos, mark them as finished, and filter +the list to display all of them, just active todos, and just completed todos. We can +add URL routing quite simply by focusing on the filtering state. + +We'll respond to these 3 URLs: + +``` +/filter/SHOW_ALL +/filter/SHOW_ACTIVE +/filter/SHOW_COMPLETED +``` + +To do this, we'll need to add four items to the app: + + 1. The router reducer, for storing routing state. + 2. A route definition, mapping url to state, and state to url + 3. The route definition within the app itself + 4. include redux-saga and react-redux, and pass in the sagaMiddleware and connect + +reducers/index.js: +```javascript +import { combineReducers } from 'redux' +import routing from 'ion-router/reducer' // the new line +import todos from './todos' +import visibilityFilter from './visibilityFilter' + +const todoApp = combineReducers({ + todos, + visibilityFilter, + routing // add the routing reducer +}) + +export default todoApp +``` + +Routes.js: +```javascript +import React from 'react' +import Routes from 'ion-router/Routes' +import Route from 'ion-router/Route' +import * as actions from './actions' + +const paramsFromState = state => ({ visibilityFilter: state.visibilityFilter }) +const stateFromParams = params => ({ + visibilityFilter: params.visibilityFilter || 'SHOW_ACTIVE' +}) +const updateState = { + visibilityFilter: filter => actions.setVisibilityFilter(filter) +} + +export default () => ( + + + +) +``` + +index.js: +```javascript +import React from 'react' +import { render } from 'react-dom' +import { createStore } from 'redux' +import { Provider, connect } from 'react-redux' // new - import connect +import makeRouter, { makeRouterStoreEnhancer } from 'ion-router' // our router - new line + +import todoApp from './reducers' +import App from './components/App' + +// set up the router and create the store +const routerEnhancer = makeRouterStoreEnhancer() +const store = createStore(todoApp, undefined, routerEnhancer) // router store enhancer +makeRouter(connect, store) // set up the router + + +render( + + + , + document.getElementById('root') +) +``` + +then add these lines inside App.js: + +```javascript +import Routes from './Routes' // new line +// ... + +const App = () => ( +
+ + +
+ +
+) +``` + +### Internal linking with `` + +Note that if we want to set up a menu of urls, ion-router provides a +`` component that should be used for all internal links. It uses the `to` +prop in place of href. An onClick handler may be passed to handle the click in +a custom fashion. All other props will be passed through to the internal `
` +tag. + +If you wish to replace the current url instead of pushing, use the `replace` prop +instead of the `to` prop. + +Unlike any other router, the `` component can also create abstract routes +from a list of route parameters. With this route declaration: + +```javascript +const routes = () => ( + + + +) +``` + +we can create a link like so: + +```javascript +const App = () => ( +
+ +
+) +``` + +and if the dynamic value refers to `123` the route will link to `/this/hi/123` + +### Extending the example: asynchronous state loading + +What if we are loading the todo list from a database? There will be a short delay while +the list is loaded, and the user will just see an empty list of todos. If they add a todo, +the todo id could suddenly conflict with todos the user creates, which would erase them on the +database load. Better is to display a different component while loading. + +To implement this with our router, you will use: + + 1. a loading component that will be displayed when the todos are loading + 2. a "Toggle" higher order component that is used to switch on/off display of a + component or its loading component + 3. an asynchronous program to load the todos from the database. + 4. an additional way of marking whether state is loaded or not in the store, and + actions and reducer code to capture this state. + +redux-saga is an excellent solution for expressing complex asynchronous actions in a +simple way. You can write your asynchronous loader in any manner you choose, whether +it is a thunk, saga, observable, or fill-in-your-favorite. + +For this example, we will assume that you can add a simple "loaded" field to the todos +reducer, and actions to set it to true or false. + +Let's design the loading component first: + +Loading.js: +```javascript +import React from 'react' + +export default () => ( +
+

Loading...

+
+) +``` + +Asynchronous loading of the todo items from the database can be accomplished with a very +simple saga. The saga assumes that the todos can be accessed via a REST service that +returns JSON, and uses the axios library to make an xhr call to retrieve it from the +server at the `"/getTodos"` address. + +loadTodosSaga.js: +```javascript +import { call, put } from 'redux-saga/effects' +import axios from 'axios' + +import * as actions from './actions' + +export default function *loadTodos() { + // mark loading as starting + yield put(actions.setLoaded(false)) + const todos = yield call([axios, axios.get], '/getTodos') + // a new action for setting all of the todos at once + yield put(actions.setTodos(todos)) + // mark loading as finished + yield put(actions.setLoaded(true)) +} +``` + +Now let's create a Toggle. A Toggle is a higher order component that responds to state in order +to turn on or off the display of a component, like a toggle switch. It takes 2 callbacks as parameters. +Each callback receives the state as a parameter and should return truthy or falsey values. The first is +used to determine whether the main component should be displayed. The second optional callback is used +to determine whether state is still loading, and if so, whether to display the loading component. +By default, if no loading callback is passed in, a Toggle assumes that the state is +loaded. + +In our example, there is only 1 route, and so we will display it if our state is marked +as loaded. If not, we will not display the component. Instead, we will display the +loading component. Here is the source: + +TodosToggle.js: +```javascript +import Toggle from 'ion-router/Toggle' + +export default Toggle(state => state.loaded, state => !state.loaded) +``` + +The TodosToggle is a component that accepts 2 props: `component` and `loadingComponent`. +`component` should be a React component or connected container to display if the +Toggle condition is satisfied, and `loadingComponent` should be a React component or connected +container to display if the loading condition is satisfied. + +Note that if both callbacks return true, then the loading component will be displayed. + +Finally, the usage of TodosToggle is straightforward. + +in App.js: +```javascript +import React from 'react' +import Footer from './Footer' +import AddTodo from '../containers/AddTodo' +import VisibleTodoList from '../containers/VisibleTodoList' + +import Routes from './Routes' +import Loading from './Loading' +import TodosToggle from './TodosToggle' + +const App = () => ( +
+ + + +
+
+) + +export default App +``` + +Now our component will display the todo list only when it has loaded. + +### Common use case: displaying a component when a route is selected + +In most applications, there are menus that select components based on the user +selecting a sub-application. To display components whose sole display criteria is +the selection of a route, use a `RouteToggle` + +```javascript +import RouteToggle from 'ion-router' + +const TodosRoute = RouteToggle('todos') +``` + +In this way, you can display several components scattered around a layout template +that are route-specific without having to make a new layout template just for that route, +or doing any strange contortions. + +A `RouteToggle` accepts all the arguments of Toggle after the route name to match: + +```javascript +import RouteToggle from 'ion-router' + +const TodosRoute = RouteToggle('todos', state => state.whatever === 'hi') +``` + +The example above will only toggle if the todos route is active and the `whatever` +portion of state is equal to 'hi' + +A `RouteToggle` can be thought of +as a simpler version of this source code: + +```javascript +import Toggle from 'ion-router/Toggle' +import { matchedRoutes } from 'ion-router/selectors' + +const TodosRoute = Toggle(state => matchedRoutes(state, 'todos')) +``` + +### Available selectors for Toggles + +The following selectors are available for use with Toggles. import as follows: + +```javascript +import * as selectors from 'ion-router/selectors' +``` + +#### matchedRoute(state, name) + +```javascript +import * as selectors from 'ion-router/selectors' +import Toggle from 'ion-router/Toggle' + +export Toggle(state => selectors.matchedRoute(state, 'routename')) +``` + +`matchedRoute` accepts a single route name, or an array of route names to match. +By default, it matches on any route. To enable strict matching (all routes must match) +pass in true to the third parameter of matchedRoute + +```javascript +import * as selectors from 'ion-router/selectors' +import Toggle from 'ion-router/Toggle' + +export Toggle(state => selectors.matchedRoute(state, ['route', 'subroute'], true)) +``` + +This is useful for strict matching of a sub-route path. + +Note that a convenience Toggle, `RouteToggle` exists to match a route: + +```javascript +import RouteToggle from 'ion-router/RouteToggle' + +export RouteToggle('routename', state => otherconditions()) +``` + +This selector returns true if the route specified by `'routename'` is active + +#### noMatches +```javascript +import * as selectors from 'ion-router/selectors' +import Toggle from 'ion-router/Toggle' + +export Toggle(state => selectors.noMatches(state)) +``` + +This selector returns true if no routes match, and can be used for an error component +or default component + +#### stateExists + +```javascript +import * as selectors from 'ion-router/selectors' +import Toggle from 'ion-router/Toggle' + +export Toggle(state => state.whatever, state => selectors.stateExists(state, /* state descriptor */)) +``` + +This toggle is designed to be used to detect whether state has loaded. Pass in +a skeleton of the state shape and it will traverse the state to determine whether it exists. + +Here is a sample from an actual project: + +```javascript +import Toggle from 'ion-router/Toggle' +import * as selectors from 'ion-router/selectors' + +export const check = state => selectors.stateExists(state, { + campers: { + ids: [] + }, + groups: { + ids: [], + groups: {}, + selectedGroup: (group, state) => { + if (!group) return true + if (state.groups.ids.indexOf(group) === -1) return false + const g = state.groups.groups[group] + if (!g) return false + if (g.type && !state.ensembleTypes.ensembleTypes[g.type]) return false + if (g.members.length) { + if (g.members.some(m => m ? !state.campers.campers[m] : false)) return false + } + return true + } + }, + ensembleTypes: { + ids: [], + }, +}) + +export default Toggle(state => state.groups.selectedGroup, check) +``` + +The selector verifies that the campers and ensembleTypes state areas have an ids +member that is an array, and that the groups state area has ids and groups set up. +For selectedGroup, a callback is called, passed the value of the state item plus the +entire state tree. The callback verifies that the selected group's state is internally +consistent and when everything is set up, returns true. + +### What about complex routes like react-router nested ``? + +For a complex application, there will be components that should only display on certain +routes. For example, an example from the react-router documentation: + +```javascript +render(( + + + + + + + + + +), document.getElementById('root')) +``` + +There are 3 things happening here. + + 1. The App structure is dictated by the declaration of routes. + 2. The `App` component will have as its `children` prop set to `About` or `Users` or + `NoMatch`, depending on the url. + 3. In addition, if the route is `/user/123` the `App` component + will have its children set to `Users` with its children set to `User` + +This complexity is forced by the design of react-router. How can we express these routes +using ion-router? + +We need 2 things: + + 1. Toggles for routes and for selected user, and for no match + 2. Plugging in the Toggles where they should be displayed within the React tree. + +```javascript +import * as selectors from 'ion-router/selectors' +import Toggle from 'ion-router/Toggle' +import RouteToggle from 'ion-router/RouteToggle' + +const AboutToggle = RouteToggle('about') +const UsersToggle = RouteToggle(['users', 'user']) +const SelectedUserToggle = Toggle(state => !!state.users.selectedUser, + state => usersLoaded(state) && state.users.user[state.users.selectedUser]) +const NoMatchToggle = Toggle(state => selectors.noMatches(state)) +``` + +Now, to plug them in: + +App.js +```javascript +// App class render: + render() { + return ( +
+ + + +
+ ) + } +``` + +Routes.js: +```javascript +import React from 'react' +import Routes from 'ion-router/Routes' +import Route from 'ion-router/Route' +import * as actions from './actions' + +const paramsFromState = state => ({ userId: state.users.selectedUser || undefined }) +const stateFromParams = params => ({ userId: params.userId || false }) +const updateState = { + userId: id => actions.setSelectedUser(id) +} +const exitParams = { + userId: undefined +} + +export default () => ( + + + + + +) +``` + +Note that the `else` prop of a Toggle higher order component can be used to display an +alternative component if the state test is not satisfied, but the component state is loaded. +So in our example, we want to display the user list if a user is not selected, so we set our +`else` to `Users` and our `component` to `User` + +UsersRoute.js: +```javascript + render() { + return ( +
+ +
+ ) + } +``` + +### Dynamic Routes + +Sometimes, it is necessary to implement dynamic routes that are calculated from a parent +route. This can be done quite easily. + +```javascript +// parent route +const Parent = () => ( + + + +) +// dynamically loaded later +const Child = ({ parentroute }) => ( + + + +) +``` + +In this case, the child will make its path match `/parent/path/:hi` + +### `enter`/`exit` hooks + +To implement enter or exit hooks you can listen for the `ENTER_ROUTES` or +`EXIT_ROUTES` action to perform actions such as loading asynchronous state. +Here is a sample implementation: + +```javascript +import * as types from 'ion-router/types' + +function *enter() { + while (true) { + const action = yield take(types.ENTER_ROUTE) + if (action.payload.indexOf('myroute') === -1) continue + // enter code goes here + do { + const second = yield take(types.EXIT_ROUTE) + } while (second.payload.indexOf('myroute') === 1) + // exit code goes here + } +} +``` + +Anything can be done in this code, including forcing a route to change, like a traditional +enter/exit hook. Because it is so trivial to implement this with the above code, the +event loop that listens for URL changes and state changes does not listen for enter/exit +hooks directly. + +#### updating state on route exit + +All routes that accept parameters and map them to state will need to unset that state +upon exiting the route. ion-router can do this automatically for any +route with only optional parameters, such as: + +`/path(/:optional(/:second_optional))` + +However, for routes that have a required parameter such as: + +`/path/:required` + +you need to tell the router how to handle this case. If the required parameter should +be simply set to undefined upon exiting, then you need to explicitly pass this +into the `exitParams` prop for `` + +```javascript +exitParams = { + required: undefined +} +const Routes = () => ( + + + +) +``` + +If you wish to dynamically set up the parameters based on existing parameters, pass +in a function that accepts the previous url's params as an argument and returns the +exit params: + +```javascript +exitParams = params => ({ + required: params.required, + optional: undefined +}) +const Routes = () => ( + + + +) +``` + +### Code splitting and asynchronous loading of Routes + +Routes can be loaded at any time. If you load a new component asynchronously (using +require.ensure, for instance), and dynamically add a new `...` inside that +component, the router will seamlessly start using the route. Code splitting has never +been simpler. + +### Server-side Rendering + +When rendering routes on the server, there are 2 options. No changes need be made +to the component source. However, because of the way server rendering works, multiple +actions and re-renders will occur when setting up routes. To avoid the performance +penalty for complex applications, an optional third parameter to the router setup +can be used to pass in the routes. The definition of routes is an object with the +same keys as the props one would pass to a `` tag. + +so instead of: + +```javascript +// set up our router +makeRouter(connect, store) + +exitParams = params => ({ + required: params.required, + optional: undefined +}) +const Routes = () => ( + + + +) +``` + +one would use: + + +```javascript +exitParams = params => ({ + required: params.required, + optional: undefined +}) + +// set up our router +makeRouter(connect, store, [{ + name: 'test', + path: '/path/:required(/:optional)', + stateToParams=..., + paramsToState=..., + updateState={...}, + exitParams={exitParams}, +}]) + +``` + +The same setup can be used on both client and server for root routes, so there is no +need to keep the `` and `` elements in your component tree if +you choose to initialize on start-up. You should continue to use the components for +dynamic routes loaded later. + +### Explicitly changing URL + +A number of actions are provided to change the browser state directly, most useful +for menus and other direct links. + +ion-router uses the [history](https://github.com/mjackson/history) package +internally. The actions mirror the push/replace/go/goBack/goForward methods as +documented for the history package. + +```javascript +import * as actions from 'ion-router/actions' +dispatch(actions.push('/path/to/go/to/next')) +dispatch(actions.goBack()) +// etc. +``` + +## Why a new router? + +[react-router](https://github.com/ReactTraining/react-router) is a mature router for +React that has a huge following and community support. Why create a new router? +In my work with react-router, I found that it was not possible to achieve +some basic goals using react-router. I couldn't figure out a way to store state from +url parameters and easily change the url from the state when using redux. It is the +classic two-way binding issue: if there are 2 sources of state, they will fight and +cause unexpected bugs. + +In addition, I moved to redux for state because the tree of components in React rarely +corresponds to the way data is used. In many cases, I find myself rendering different +portions of the component tree using the same data. So I will have 2 React components +in totally different parts of the component tree using the same piece of data. +With react-router, I found myself duplicating a lot of content with a single component, +or using complex routing rules to enable displaying this information. react-router +version 4 allows declaring the same route multiple times throughout the code, but +this can make things more confusing, and opens up another vector for bugs since +route information must be duplicated wherever it is used. + +With ion-router, multiple components can respond to a route change anywhere +in the React component tree, allowing for more modular design. The below solution +is more performant both because the components +are not rendered at all if the route is not satisfied. + +```javascript +import React from 'react' +import Routes from 'ion-router/Routes' +import Route from 'ion-router/Route' +import Toggle from 'ion-router/Toggle' +import { connect } from 'react-redux' + +import * as actions from './actions' + +const albumRouteMapping = { + stateFromParams: params => ({ id: params.album, track: +params.track }), + paramsFromState: state => ({ + album: state.albums.selectedAlbum.id, + track: state.albums.selectedTrack ? state.albums.selectedTrack : undefined + }), + updateState: { + id: id => actions.selectAlbum(id), + track: track => actions.playTrack(track) + } +} + +const TrackToggle = Toggle(state => state.albums.selectedTrack, + state => state.albums.selectedTrack + && state.albums.allTracks[state.albums.selectedTrack].loading) +const AlbumToggle = Toggle(state => state.albums.selectedAlbum) + +const AlbumList = ({ albums, selectAlbum }) => ( +
    + {albums.map(album =>
  • {album.name}
  • )} +
+) + +const AlbumDetail = ({ album, playTrack }) => ( +
    +
  • Album details
  • +
  • ...(stuff from the {album.name}
  • + {album.tracks.map(track =>
  • {track.name}
  • )} +
+) + +const AlbumSummary = ({ album }) => { +

+ {album.name} +

+} + +const TrackPlayer = ({ track }) => { +
+

{track.title}

+ +
+} + +const AlbumSummaryContainer = connect(state => ({ album: state.albums.selectedAlbum }))(AlbumSummary) +const AlbumListContainer = connect(state => ({ albums: state.albums.allAlbums }), + dispatch => ({ selectAlbum: id => dispatch(actions.selectAlbum(id)) }))(AlbumList) +const AlbumDetailContainer = connect(state => ({ album: state.albums.selectedAlbum }), + dispatch => ({ playTrack: id => dispatch(actions.selectTrack(id)) }))(AlbumDetail) +const TrackPlayerContainer = connect(state => ({ track: state.albums.tracks[state.albums.selectedTrack] }))(TrackPlayer) + +const MyComponent = () => ( +
+ + + + + + + + + + + + + +
+) +``` + +In addition, declaring new routes in asynchronously loaded code is trivial with this +design. One need only put in `` declarations in the child code and the new routes +will be added, and also automatically removed if the child code is removed from the +render tree. + +## Principles + +Most routers start from an assumption that the url determines what part of the application +to display. This results in a tree of urls mapping to components. Because routes +are defined by the URL, it then becomes necessary to provide hooks and an index route, and +an unknown route and so on and so forth. + +However clever one is, this results in a very subtle logic flaw when using redux. Redux-based +applications consider the store state to be a single source of truth. As such, general +state is not stored inside components, or pulled out of the client-side database or the +url state from pushState/popState. [Read more about the state debate](https://github.com/reactjs/redux/issues/1287). + +### URL state is just another asynchronous input to redux state + +We are trained to think of the browser URL as some kind of magic all-knowing state container. +Simply because it is there and the user can directly change it to any value. But how different +is this really than a database accessed on the server via asynchronous xhr? Or even +synchronous localStorage? Let's stop thinking of the URL as a state container. It +is an input that we can use to create state. + +### When the URL changes, it should cause a state change in the redux store + +We want our URL to change the way the application works. This allows users to bookmark a +particular view, such as an email (/inbox/message/243) or a particular todo list filter +(/todos/all or /todos/search/house) + +### When the state changes in the redux store, it should be reflected in the URL + +If a user clicks on something that affects the application state by triggering an action, +such as selecting an email to view, we want the URL to then update so the user can bookmark +that application state or share it. + +This single principle is the reason for the existence of this router. + +### Route definition is separate from the components + +Because URL state is just another input to the redux state, we only need to define +how to transform URLs into redux state. Components then choose whether to render based +on that state. This is a crucial difference from every other router out there. + +### Components are explicitly used where they go, and can be moved anywhere + +With traditional routers, you must render the component where the route is declared. +This creates rigidity. In addition, with programs based on react-router, the link +between where a component exists and the route lives in the router. The only indication +that something "routey" is happening is the presence of `{this.props.children}` which +can make debugging and technical debt higher. This router restores the natural tree and +layout of a React app: you use the component where it will actually be rendered in the +tree. Less technical debt, less confusion. + +The drawback is that direct connection between URL and component is less obvious. The +tradeoff seems worth it, as the URL is just another input to the program. Currently, +the relationship between database definition and component is just as opaque, and that +works just fine, this is no different. + +### IndexRoute, Redirect and ErrorRoute are not necessary + +Use Toggle and smart (connected) components to do all of this logic. For example, an +error route is basically a toggle that only displays when other routes are not selected. +You can use the `noMatches` selector for this purpose. An indexRoute can be implemented +with the `matchedRoute('/')` selector (and by defining a route for '/'). + +A redirect can be implemented simply by listening for a URL in a saga and pushing a new +one: + +```javascript +import { replace } from 'ion-router' +import { ROUTE } from 'ion-router/types' +import { take, put } from 'redux-saga/effects' +import { createPath } from 'history' +import RouteParser from 'route-parser' // this is used internally + +function *redirect() { + while (true) { + const action = yield (take(ROUTE)) + const parser = new RouteParser('/old/path/:is/:this') + const newparser = new RouteParser('/new/:is/:this') + const params = parser.match(createPath(action)) + if (params) { + yield put(replace(newparser.reverse(params))) + } + } +} +``` + +### Easy testing + +Everything is properly isolated, and testable. You can easily unit test your route +stateFromParams and paramsFromState and updateState properties. Components are +simply components, no magic. + +To set up routes for testing in a unit test, the `synchronousMakeRoutes` functions is +available. Pass in an array of routes, and use the return in the reducer + +```javascript +import { synchronousMakeRoutes, routerReducer } from 'ion-router' + +describe('some component that uses routes', () => { + let fakeState + beforeEach(() => { + const action = synchronousMakeRoutes([ + { + name: 'route1', + path: '/route1' + }, + { + name: 'route1', + path: '/route2/:thing', + stateToParams: state => state, + paramsToState: params => params, + update: { + thing: thing => ({ type: 'changething', payload: thing }) + } + } + ]) + fakeState = { + routing: routerReducer(undefined, action) + } + }) + it('test something', () => { + // use components that have or + }) +}) +``` + +You will need to set this up for any `` components that use route to generate +the path, and any components that contain `` or `` tags when rendering +them. + +## License + +MIT License + +## Thanks + +[![http://www.browserstack.com](https://www.browserstack.com/images/layout/browserstack-logo-600x315.png)](http://www.browserstack.com) + +Huge thanks to [BrowserStack](http://www.browserstack.com) for providing +cross-browser testing on real devices, both automatic testing and manual testing. \ No newline at end of file diff --git a/docs/my-markdown-loader/index.js b/docs/my-markdown-loader/index.js new file mode 100644 index 0000000..36d8d8a --- /dev/null +++ b/docs/my-markdown-loader/index.js @@ -0,0 +1,18 @@ +"use strict"; + +const marked = require("marked"); +const loaderUtils = require("loader-utils"); + +module.exports = function (markdown) { + // merge params and default config + const options = loaderUtils.parseQuery(this.query); + + this.cacheable(); + + marked.setOptions(options); + + const output = JSON.stringify(marked(markdown)) + return `module.exports = ${output}`; +}; + +module.exports.seperable = true; diff --git a/docs/my-markdown-loader/package.json b/docs/my-markdown-loader/package.json new file mode 100644 index 0000000..1fdcb91 --- /dev/null +++ b/docs/my-markdown-loader/package.json @@ -0,0 +1,8 @@ +{ + "name": "marky-loader", + "version": "0.5.0", + "description": "configuration-less markdown loader for webpack", + "main": "index.js", + "author": "Gregory Beaver", + "license": "MIT" +} diff --git a/docs/package.json b/docs/package.json index ea4f0df..1800a2d 100644 --- a/docs/package.json +++ b/docs/package.json @@ -6,7 +6,9 @@ "dependencies": { "font-awesome": "^4.7.0", "history": "^4.6.1", - "ion-router": "^0.10.3", + "ion-router": "file:..", + "markdown-loader": "^2.0.0", + "marky-loader": "file:my-markdown-loader/", "prismjs-loader": "^0.0.4", "prop-types": "^15.5.8", "react": "^15.5.4", diff --git a/docs/public/ion-router.png b/docs/public/ion-router.png new file mode 100644 index 0000000..7226824 Binary files /dev/null and b/docs/public/ion-router.png differ diff --git a/docs/src/App.css b/docs/src/App.css index 8869e7b..8aa7da5 100644 --- a/docs/src/App.css +++ b/docs/src/App.css @@ -9,11 +9,18 @@ .App-header { background-color: #222; - height: 150px; + height: 10vh; + min-height: 50px; padding: 20px; color: white; } +.markdown-viewer { + padding: 20px; + height: 90vh; + overflow-y: scroll; +} + .App-intro { font-size: large; text-align: left; diff --git a/docs/src/App.jsx b/docs/src/App.jsx index 379eec4..0122c41 100644 --- a/docs/src/App.jsx +++ b/docs/src/App.jsx @@ -1,16 +1,26 @@ import React, { Component } from 'react' -import Routes from 'ion-router/Routes' +import { getConnectedRoutes } from 'ion-router/Routes' +import { connect } from 'react-redux' import Route from 'ion-router/Route' -import Link from 'ion-router/Link' +import { getConnectedLink } from 'ion-router/Link' +import { connectToggle } from 'ion-router/Toggle' import Menu from 'react-burger-menu/lib/menus/scaleRotate' import './App.css' import Examples from './components/Examples' -import Example from './components/Example' +import MarkdownViewer from './components/MarkdownViewer' import ExamplesToggle from './toggles/ExamplesToggle' import * as actions from './redux/actions' import examples from './examples' +import test from '!!marky!../md/README.md' // eslint-disable-line + +const Routes = getConnectedRoutes(connect, 'mainStore') +const Link = getConnectedLink(connect, 'mainStore') +connectToggle(connect) +Routes.displayName = 'FancyRoutes' +Link.displayName = 'FancyLink' + class App extends Component { render() { return ( @@ -33,7 +43,7 @@ class App extends Component { ( - + )} /> diff --git a/docs/src/components/Example.jsx b/docs/src/components/Example.jsx index 971cf3b..0e58650 100644 --- a/docs/src/components/Example.jsx +++ b/docs/src/components/Example.jsx @@ -1,20 +1,15 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import createHistory from 'history/createMemoryHistory' -import { createStore, applyMiddleware, combineReducers, compose } from 'redux' -import { createProvider, connect } from 'react-redux-custom-store' -import makeRouter, { makeRouterMiddleware } from 'ion-router' +import { createStore, combineReducers, compose } from 'redux' +import { connect, Provider } from 'react-redux' +import makeRouter, { makeRouterStoreEnhancer } from 'ion-router' import routing from 'ion-router/reducer' import Browser from './Browser' import ShowSource from './ShowSource' import examples from '../examples' -const Provider = createProvider('examples') -const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ? // eslint-disable-line - window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ name: 'examples' }) // eslint-disable-line - : compose - class Example extends Component { static propTypes = { example: PropTypes.string.isRequired @@ -32,9 +27,13 @@ class Example extends Component { // set up the router and create the store - const routerMiddleware = makeRouterMiddleware(this.history) + const enhancer = makeRouterStoreEnhancer(this.history) + + const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ? // eslint-disable-line + window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ name: `examples: ${props.example}` }) // eslint-disable-line + : compose this.store = createStore(reducer, undefined, - composeEnhancers(applyMiddleware(routerMiddleware))) + composeEnhancers(enhancer)) makeRouter(connect, this.store) } diff --git a/docs/src/components/Examples.jsx b/docs/src/components/Examples.jsx index ecd8bea..965b513 100644 --- a/docs/src/components/Examples.jsx +++ b/docs/src/components/Examples.jsx @@ -5,7 +5,7 @@ import thing from './Example' const Example = connect(state => ({ example: state.examples.example -}))(thing) +}), undefined, undefined, { storeKey: 'mainStore' })(thing) export default function Examples() { return ( diff --git a/docs/src/components/MarkdownViewer.jsx b/docs/src/components/MarkdownViewer.jsx new file mode 100644 index 0000000..6436d17 --- /dev/null +++ b/docs/src/components/MarkdownViewer.jsx @@ -0,0 +1,15 @@ +import React from 'react' +import PropTypes from 'prop-types' + +export default function Viewer({ text }) { + const danger = { __html: text } + return ( +
+
+
+ ) +} + +Viewer.propTypes = { + text: PropTypes.string.isRequired +} diff --git a/docs/src/index.js b/docs/src/index.js index e4d195c..1206585 100644 --- a/docs/src/index.js +++ b/docs/src/index.js @@ -1,14 +1,15 @@ import React from 'react' import ReactDOM from 'react-dom' -import { createStore, applyMiddleware, combineReducers, compose } from 'redux' -import { Provider, connect } from 'react-redux' -import makeRouter, { makeRouterMiddleware } from 'ion-router' +import { createStore, combineReducers, compose } from 'redux' +import { createProvider } from 'react-redux-custom-store' +import * as ion from 'ion-router' import routing from 'ion-router/reducer' import examples from './redux/example' import App from './App' import './index.css' +const Provider = createProvider('mainStore') const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose // eslint-disable-line const reducer = combineReducers({ routing, @@ -17,14 +18,13 @@ const reducer = combineReducers({ // set up the router and create the store -const routerMiddleware = makeRouterMiddleware() +const enhancer = ion.makeRouterStoreEnhancer() const store = createStore(reducer, undefined, - composeEnhancers(applyMiddleware(routerMiddleware))) -makeRouter(connect, store) + composeEnhancers(enhancer)) ReactDOM.render( , document.getElementById('root') -); +) diff --git a/docs/src/toggles/ExampleToggle.js b/docs/src/toggles/ExampleToggle.js index bd9732b..b720865 100644 --- a/docs/src/toggles/ExampleToggle.js +++ b/docs/src/toggles/ExampleToggle.js @@ -1,3 +1,3 @@ import Toggle from 'ion-router/Toggle' -export default Toggle(state => state.examples.example) +export default Toggle(state => state.examples.example, undefined, {}, false, 'mainStore') diff --git a/docs/src/toggles/ExamplesToggle.js b/docs/src/toggles/ExamplesToggle.js index bb22496..2d88780 100644 --- a/docs/src/toggles/ExamplesToggle.js +++ b/docs/src/toggles/ExamplesToggle.js @@ -1,3 +1,3 @@ import RouteToggle from 'ion-router/RouteToggle' -export default RouteToggle('examples') +export default RouteToggle('examples', null, undefined, {}, false, 'mainStore') diff --git a/docs/yarn.lock b/docs/yarn.lock index 8c583c9..7b66891 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -2623,9 +2623,8 @@ invert-kv@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" -ion-router@^0.10.3: - version "0.10.3" - resolved "https://registry.yarnpkg.com/ion-router/-/ion-router-0.10.3.tgz#50bc8502d8dcac3e92f1a25f8089ae3ffb500ad6" +"ion-router@file:..": + version "0.11.0" dependencies: history "^4.5.1" invariant "^2.2.2" @@ -3220,7 +3219,7 @@ load-json-file@^1.0.0: pinkie-promise "^2.0.0" strip-bom "^2.0.0" -loader-utils@0.2.14, loader-utils@^0.2.11, loader-utils@^0.2.3, loader-utils@^0.2.7, loader-utils@~0.2.2, loader-utils@~0.2.5: +loader-utils@0.2.14: version "0.2.14" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-0.2.14.tgz#3edab2a123ebb196a1c9d6dd3e83384958843e6f" dependencies: @@ -3229,7 +3228,7 @@ loader-utils@0.2.14, loader-utils@^0.2.11, loader-utils@^0.2.3, loader-utils@^0. json5 "^0.5.0" object-assign "^4.0.1" -loader-utils@0.2.x, loader-utils@^0.2.16: +loader-utils@0.2.x, loader-utils@^0.2.11, loader-utils@^0.2.16, loader-utils@^0.2.3, loader-utils@^0.2.7, loader-utils@~0.2.2, loader-utils@~0.2.5: version "0.2.17" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-0.2.17.tgz#f86e6374d43205a6e6c60e9196f17c0299bfb348" dependencies: @@ -3366,6 +3365,13 @@ makeerror@1.0.x: dependencies: tmpl "1.0.x" +markdown-loader@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/markdown-loader/-/markdown-loader-2.0.0.tgz#421862d38c4224fd3615eb648017ea385b562d78" + dependencies: + loader-utils "^0.2.16" + marked "^0.3.6" + marked-terminal@^1.6.2: version "1.7.0" resolved "https://registry.yarnpkg.com/marked-terminal/-/marked-terminal-1.7.0.tgz#c8c460881c772c7604b64367007ee5f77f125904" @@ -3380,6 +3386,9 @@ marked@^0.3.6: version "0.3.6" resolved "https://registry.yarnpkg.com/marked/-/marked-0.3.6.tgz#b2c6c618fccece4ef86c4fc6cb8a7cbf5aeda8d7" +"marky-loader@file:my-markdown-loader/": + version "0.1.0" + math-expression-evaluator@^1.2.14: version "1.2.17" resolved "https://registry.yarnpkg.com/math-expression-evaluator/-/math-expression-evaluator-1.2.17.tgz#de819fdbcd84dccd8fae59c6aeb79615b9d266ac" diff --git a/package.json b/package.json index 08e674b..63459b3 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,9 @@ { "name": "ion-router", - "version": "0.10.3", + "version": "0.11.0", "description": "elegant powerful routing based on the simplicity of storing url as state", "main": "lib/index.js", + "homepage": "https://cellog.github.io/ion-router", "directories": { "test": "tests" }, @@ -23,7 +24,8 @@ "react", "react-router", "route", - "router" + "router", + "routing" ], "author": "Gregory Beaver", "license": "MIT", diff --git a/src/Link.jsx b/src/Link.jsx index d2fb8f3..d1043ea 100644 --- a/src/Link.jsx +++ b/src/Link.jsx @@ -98,12 +98,16 @@ export const Placeholder = () => { 'initialize Link (see https://github.com/cellog/ion-router/issues/1)') } +export function getConnectedLink(connect, storeKey = 'store') { + return connect(state => ({ + '@@__routes': state.routing.routes + }), undefined, undefined, { storeKey })(Link) +} + let ConnectedLink = null -export function connectLink(connect) { - ConnectedLink = connect(state => ({ - '@@__routes': state.routing.routes - }))(Link) +export function connectLink(connect, storeKey = 'store') { + ConnectedLink = getConnectedLink(connect, storeKey) } const ConnectLink = props => (ConnectedLink ? : ) diff --git a/src/RouteToggle.jsx b/src/RouteToggle.jsx index 674c975..2427b7c 100644 --- a/src/RouteToggle.jsx +++ b/src/RouteToggle.jsx @@ -2,8 +2,8 @@ import Toggle from './Toggle' import * as selectors from './selectors' export default function RouteToggle(route, othertests = null, loading = undefined, - componentMap = {}, debug = false) { + componentMap = {}, debug = false, storeKey = 'store') { return Toggle(state => (selectors.matchedRoute(state, route) && (othertests ? othertests(state) : true) - ), loading, componentMap, debug) + ), loading, componentMap, debug, storeKey) } diff --git a/src/Routes.jsx b/src/Routes.jsx index 66366d7..1401a48 100644 --- a/src/Routes.jsx +++ b/src/Routes.jsx @@ -1,20 +1,23 @@ import React, { Component, Children } from 'react' import PropTypes from 'prop-types' import * as actions from './actions' -import { onServer } from '.' -export class RawRoutes extends Component { +export const RawRoutes = (storeKey = 'store') => class extends Component { static propTypes = { dispatch: PropTypes.func, children: PropTypes.any, '@@__routes': PropTypes.object, } - constructor(props) { - super(props) + static contextTypes = { + [storeKey]: PropTypes.object + } + + constructor(props, context) { + super(props, context) this.addRoute = this.addRoute.bind(this) this.myRoutes = [] - this.isServer = onServer() + this.isServer = this.context[storeKey].routerOptions.isServer } componentDidMount() { @@ -48,10 +51,15 @@ export const Placeholder = () => { 'initialize Routes (see https://github.com/cellog/ion-router/issues/1)') } +export function getConnectedRoutes(connect, storeKey = 'store', Raw = RawRoutes(storeKey)) { + return connect(state => ({ '@@__routes': state.routing.routes.routes }), + undefined, undefined, { storeKey })(Raw) +} + let ConnectedRoutes = null -export function connectRoutes(connect) { - ConnectedRoutes = connect(state => ({ '@@__routes': state.routing.routes.routes }))(RawRoutes) +export function connectRoutes(connect, storeKey = 'store', Raw = RawRoutes(storeKey)) { + ConnectedRoutes = getConnectedRoutes(connect, storeKey, Raw) } const ConnectRoutes = props => (ConnectedRoutes ? : ) diff --git a/src/Toggle.jsx b/src/Toggle.jsx index 72a9a66..cf701c1 100644 --- a/src/Toggle.jsx +++ b/src/Toggle.jsx @@ -43,7 +43,7 @@ export const NullComponent = (Loading, Component, ElseComponent, debug, cons = c return Toggle } -export default (isActive, loaded = () => true, componentLoadingMap = {}, debug = false) => { +export default (isActive, loaded = () => true, componentLoadingMap = {}, debug = false, storeKey = 'store') => { const scaffold = (state, rProps) => { const loadedTest = !!loaded(state, rProps) return { @@ -84,7 +84,7 @@ export default (isActive, loaded = () => true, componentLoadingMap = {}, debug = lastProps.else = ElseComponent lastProps.loadingComponent = Loading const Switcher = NullComponent(Loading, Component, ElseComponent, debug) - Toggle.HOC = connect(scaffold)(Switcher) + Toggle.HOC = connect(scaffold, undefined, undefined, { storeKey })(Switcher) const elseName = ElseComponent.displayName || ElseComponent.name || 'Component' const componentName = Component.displayName || Component.name || 'Component' const loadingName = Loading.displayName || Loading.name || 'Component' diff --git a/src/index.js b/src/index.js index 3e82067..0fdc3b5 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,3 @@ -import { createPath } from 'history' - import * as actions from './actions' import * as enhancers from './enhancers' import { connectLink } from './Link' @@ -7,53 +5,29 @@ import { connectRoutes } from './Routes' import { connectToggle } from './Toggle' import middleware from './middleware' -export { middleware as makeRouterMiddleware } - -export const options = { - server: false, - enhancedRoutes: {}, - pending: false, - resolve: false, -} +import makeRouterStoreEnhancer from './storeEnhancer' +export { makeRouterStoreEnhancer } +export { middleware as makeRouterMiddleware } export { actionHandlers } from './middleware' export reducer from './reducer' -export const setServer = (val = true) => { - options.server = val -} - -export function makePath(name, params) { - if (!options.enhancedRoutes[name]) return false - return options.enhancedRoutes[name]['@parser'].reverse(params) -} - -export function matchesPath(route, locationOrPath) { - if (!options.enhancedRoutes[route]) return false - return options.enhancedRoutes[route]['@parser'].match(locationOrPath.pathname ? createPath(locationOrPath) : locationOrPath) -} - -export const onServer = () => options.server -export const setEnhancedRoutes = (r, opts = options) => { - opts.enhancedRoutes = r // eslint-disable-line -} - // for unit-testing purposes -export function synchronousMakeRoutes(routes, opts = options) { +export function synchronousMakeRoutes(routes, opts) { const action = actions.batchRoutes(routes) - setEnhancedRoutes(Object.keys(action.payload.routes).reduce((en, route) => - enhancers.save(action.payload.routes[route], en), opts.enhancedRoutes), opts) + opts.enhancedRoutes = Object.keys(action.payload.routes).reduce((en, route) => // eslint-disable-line + enhancers.save(action.payload.routes[route], en), opts.enhancedRoutes) return action } export default function makeRouter(connect, store, routeDefinitions, - isServer = false, opts = options) { - connectLink(connect) - connectRoutes(connect) - connectToggle(connect) - opts.isServer = isServer // eslint-disable-line + isServer = false, storeKey = 'store') { + connectLink(connect, storeKey) + connectRoutes(connect, storeKey) + connectToggle(connect, storeKey) + store.routerOptions.isServer = isServer // eslint-disable-line if (routeDefinitions) { - store.dispatch(synchronousMakeRoutes(routeDefinitions, opts)) + store.dispatch(synchronousMakeRoutes(routeDefinitions, store.routerOptions.enhancedRoutes)) } } diff --git a/src/middleware.js b/src/middleware.js index a6f2a41..816cfb5 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -5,7 +5,6 @@ import invariant from 'invariant' import * as types from './types' import * as actions from './actions' import * as helpers from './helpers' -import { options, setEnhancedRoutes } from '.' function ignore(store, next, action) { return next(action) @@ -54,7 +53,7 @@ export function processHandler(handler, routes, state, action) { return info } -export default function createMiddleware(history = createBrowserHistory(), opts = options, +export default function createMiddleware(history = createBrowserHistory(), handlers = actionHandlers, debug = false) { let lastLocation = createPath(history.location) let activeListener = listen // eslint-disable-line @@ -62,6 +61,7 @@ export default function createMiddleware(history = createBrowserHistory(), opts ...handlers } function listen(store, next, action) { + const opts = store.routerOptions const handler = myHandlers[action.type] ? myHandlers[action.type] : myHandlers['*'] @@ -72,12 +72,12 @@ export default function createMiddleware(history = createBrowserHistory(), opts const info = processHandler(handler, opts.enhancedRoutes, state, action) const ret = next(action) info.toDispatch.forEach(act => store.dispatch(act)) - setEnhancedRoutes(info.newEnhancedRoutes, opts) + opts.enhancedRoutes = info.newEnhancedRoutes return ret } const ret = next(action) const info = processHandler(handler, opts.enhancedRoutes, store.getState(), action) - setEnhancedRoutes(info.newEnhancedRoutes, opts) + opts.enhancedRoutes = info.newEnhancedRoutes if (debug && info.toDispatch.length) { console.info(`ion-router PROCESSING: ${action.type}`) // eslint-disable-line console.info(`dispatching: `, info.toDispatch) // eslint-disable-line @@ -89,6 +89,8 @@ export default function createMiddleware(history = createBrowserHistory(), opts } } return (store) => { + invariant(store.routerOptions, 'ion-router error: store has not been initialized. Did you ' + + 'use the store enhancer?') history.listen((location) => { const a = createPath(location) if (a === lastLocation) return diff --git a/src/storeEnhancer.js b/src/storeEnhancer.js new file mode 100644 index 0000000..50166be --- /dev/null +++ b/src/storeEnhancer.js @@ -0,0 +1,20 @@ +import createBrowserHistory from 'history/createBrowserHistory' +import { compose } from 'redux' + +import middleware, { actionHandlers } from './middleware' + +export default (history = createBrowserHistory(), + handlers = actionHandlers, + debug = false, options = {}) => + createStore => (reducer, preloadedState) => { + const store = { + ...createStore(reducer, preloadedState), + routerOptions: { + server: false, + enhancedRoutes: {}, + ...options + } + } + store.dispatch = compose(middleware(history, handlers, debug)(store))(store.dispatch) + return store + } diff --git a/test/Link.test.js b/test/Link.test.js index 5ae2af6..0e1c1b7 100644 --- a/test/Link.test.js +++ b/test/Link.test.js @@ -1,9 +1,7 @@ import React from 'react' import ConnectLink, { Link, connectLink } from '../src/Link' -import { push, replace } from '../src/actions' -import { setEnhancedRoutes } from '../src' -import * as enhancers from '../src/enhancers' +import { push, replace, route } from '../src/actions' import { renderComponent, connect } from './test_helper' describe('Link', () => { @@ -58,7 +56,15 @@ describe('Link', () => { connectLink(connect) const [component, , log] = renderComponent(ConnectLink, { dispatch: () => null, to: '/hi', onClick: spy }, {}, true) component.find('a').trigger('click') - expect(log).eqls([push('/hi')]) + expect(log).eqls([ + route({ pathname: '/', + search: '', + hash: '', + state: undefined, + key: undefined }), + push('/hi'), + route(log[2].payload) + ]) expect(spy.called).is.true }) it('errors (in dev) on href passed in', () => { @@ -67,15 +73,6 @@ describe('Link', () => { .throws('href should not be passed to Link, use "to," "replace" or "route" (passed "/hi")') }) describe('generates the correct path when route option is used', () => { - before(() => { - setEnhancedRoutes(enhancers.save({ - name: 'there', - path: '/there/:there' - }, enhancers.save({ - name: 'hi', - path: '/hi/:there' - }, {}))) - }) it('push', () => { const dispatch = sinon.spy() const component = renderComponent(Link, { diff --git a/test/Route.test.js b/test/Route.test.js index d0f602a..56b9ff6 100644 --- a/test/Route.test.js +++ b/test/Route.test.js @@ -2,9 +2,8 @@ import React from 'react' import Route, { fake } from '../src/Route' import Routes, { connectRoutes } from '../src/Routes' import * as actions from '../src/actions' -import { setEnhancedRoutes } from '../src' import * as enhancers from '../src/enhancers' -import { renderComponent, connect } from './test_helper' +import { renderComponent, connect, sagaStore } from './test_helper' describe('Route', () => { const paramsFromState = state => ({ @@ -28,8 +27,8 @@ describe('Route', () => { } let component, store, log // eslint-disable-line connectRoutes(connect) - function make(props = {}, Comp = Routes, state = {}) { - const info = renderComponent(Comp, props, state, true) + function make(props = {}, Comp = Routes, state = {}, s = undefined) { + const info = renderComponent(Comp, props, state, true, s) component = info[0] store = info[1] log = info[2] @@ -47,6 +46,11 @@ describe('Route', () => { ) make({}, R) expect(log).eqls([ + actions.route({ pathname: '/', + search: '', + hash: '', + state: undefined, + key: undefined }), actions.batchRoutes([{ name: 'ensembles', path: '/ensembles/:id', @@ -61,12 +65,14 @@ describe('Route', () => { const R = () => - setEnhancedRoutes(enhancers.save({ - name: 'foo', - path: '/testing/' - }, {})) - make({}, R, { + const mystore = sagaStore({ routing: { + matchedRoutes: [], + location: { + pathname: '', + hash: '', + search: '' + }, routes: { ids: ['foo'], routes: { @@ -78,12 +84,26 @@ describe('Route', () => { } } }) - expect(log).eqls([actions.batchRoutes([{ name: 'test', - path: '/testing/mine/', - parent: 'foo', - paramsFromState: fake, - stateFromParams: fake, - updateState: {} }])]) + mystore.store.routerOptions.enhancedRoutes = enhancers.save({ + name: 'foo', + path: '/testing/' + }, {}) + make({}, R, undefined, mystore) + expect(log).eqls([ + actions.route({ pathname: '/', + search: '', + hash: '', + state: undefined, + key: undefined + }), + actions.batchRoutes([{ name: 'test', + path: '/testing/mine/', + parent: 'foo', + paramsFromState: fake, + stateFromParams: fake, + updateState: {} + }]) + ]) }) it('passes url down to children', () => { fake() // for coverage @@ -108,6 +128,12 @@ describe('Route', () => { make({}, R) expect(log).eqls([ + actions.route({ pathname: '/', + search: '', + hash: '', + state: undefined, + key: undefined + }), actions.batchRoutes([{ name: 'ensembles', path: '/ensembles/:id', diff --git a/test/RouteToggle.test.js b/test/RouteToggle.test.js index abb5acb..1bb9a3e 100644 --- a/test/RouteToggle.test.js +++ b/test/RouteToggle.test.js @@ -20,6 +20,11 @@ describe('RouteToggle', () => { const container = renderComponent(Route, { component: Component, foo: 'bar' }, { week: 1, routing: { + location: { + pathname: '', + hash: '', + search: '' + }, routes: { test: { name: 'test', @@ -37,6 +42,11 @@ describe('RouteToggle', () => { const container = renderComponent(Route, { component: Component, foo: 'bar' }, { week: 1, routing: { + location: { + pathname: '', + hash: '', + search: '' + }, routes: { test: { name: 'test', @@ -54,6 +64,11 @@ describe('RouteToggle', () => { const container = renderComponent(Route, { component: Component, foo: 'bar' }, { week: 1, routing: { + location: { + pathname: '', + hash: '', + search: '' + }, routes: { test: { name: 'test', @@ -72,6 +87,11 @@ describe('RouteToggle', () => { const container = renderComponent(R, { component: Component, foo: 'bar', week: 1 }, { week: 1, routing: { + location: { + pathname: '', + hash: '', + search: '' + }, routes: { test: { name: 'test', @@ -95,6 +115,11 @@ describe('RouteToggle', () => { const container = renderComponent(R, { component: Component, bobby: 'hi', frenzel: 'there', blah: 'oops' }, { week: 1, routing: { + location: { + pathname: '', + hash: '', + search: '' + }, routes: { test: { name: 'test', diff --git a/test/Routes.test.jsx b/test/Routes.test.jsx index a9d2974..55155d1 100644 --- a/test/Routes.test.jsx +++ b/test/Routes.test.jsx @@ -1,14 +1,14 @@ import React, { Component } from 'react' import ConnectedRoutes, { connectRoutes, RawRoutes } from '../src/Routes' import * as actions from '../src/actions' -import { setServer, onServer } from '../src' -import { renderComponent, connect } from './test_helper' +import * as enhancers from '../src/enhancers' +import { renderComponent, connect, sagaStore } from './test_helper' describe('Routes', () => { let component, store, log // eslint-disable-line - function make(props = {}, Comp = ConnectedRoutes, state = {}, mount = false) { + function make(props = {}, Comp = ConnectedRoutes, state = {}, mount = false, s = undefined) { connectRoutes(connect) - const info = renderComponent(Comp, props, state, true, false, mount) + const info = renderComponent(Comp, props, state, true, s, mount) component = info[0] store = info[1] log = info[2] @@ -25,11 +25,12 @@ describe('Routes', () => { spy1(one) return spy } - connectRoutes(connect) + const raw = RawRoutes('store') + connectRoutes(connect, 'store', raw) expect(spy1.called).is.true expect(spy.called).is.true - expect(spy.args[0]).eqls([RawRoutes]) + expect(spy.args[0]).eqls([raw]) }) it('passes in @@AddRoute prop', () => { @@ -58,6 +59,11 @@ describe('Routes', () => { component.props({ thing: false }) expect(log).eqls([ + actions.route({ pathname: '/', + search: '', + hash: '', + state: undefined, + key: undefined }), actions.batchRoutes([{ name: 'foo', path: '/bar' }]), actions.batchRemoveRoutes([{ name: 'foo', path: '/bar' }]) ]) @@ -68,8 +74,14 @@ describe('Routes', () => { const R = () => - make({}, R, { + const mystore = sagaStore({ routing: { + matchedRoutes: [], + location: { + pathname: '', + hash: '', + search: '' + }, routes: { ids: ['hi'], routes: { @@ -81,6 +93,11 @@ describe('Routes', () => { } } }) + mystore.store.routerOptions.enhancedRoutes = enhancers.save({ + name: 'hi', + path: '/there' + }, {}) + make({}, R, undefined, false, mystore) expect(component.find(Thing).props('@@__routes')).eqls({ hi: { name: 'hi', @@ -101,8 +118,6 @@ describe('Routes', () => { expect(component.props('props').children[1].props.className).eqls('there') }) describe('server', () => { - before(() => setServer()) - after(() => setServer(false)) it('addRoute', () => { class Thing extends Component { constructor(props) { @@ -118,9 +133,15 @@ describe('Routes', () => { const R = () => - make({}, R) - expect(onServer()).is.true + const mystore = sagaStore() + mystore.store.routerOptions.isServer = true + make({}, R, undefined, false, mystore) expect(log).eqls([ + actions.route({ pathname: '/', + search: '', + hash: '', + state: undefined, + key: undefined }), actions.addRoute({ name: 'foo', path: '/bar' }), actions.batchRoutes([{ name: 'foo', path: '/bar' }]) ]) diff --git a/test/index.test.js b/test/index.test.js index e3904f4..7ed9f6d 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -3,66 +3,6 @@ import * as actions from '../src/actions' import * as enhancers from '../src/enhancers' describe('ion-router', () => { - afterEach(() => index.setEnhancedRoutes({})) - describe('makePath/matchesPath', () => { - beforeEach(() => { - const routes = [{ - name: 'campers', - path: '/campers/:year(/:id)', - paramsFromState: state => ({ - id: state.campers.selectedCamper ? state.campers.selectedCamper : undefined, - year: state.currentYear + '' // eslint-disable-line - }), - stateFromParams: params => ({ - id: params.id ? params.id : false, - year: +params.year - }), - updateState: { - id: id => ({ type: 'select', payload: id }), - year: year => ({ type: 'year', payload: year }) - } - }, { - name: 'ensembles', - path: '/ensembles(/:id)', - paramsFromState: state => ({ - id: state.ensembleTypes.selectedEnsembleType ? - state.ensembleTypes.selectedEnsembleType : undefined, - }), - stateFromParams: params => ({ - id: params.id ? params.id : false, - }), - updateState: { - id: id => ({ type: 'ensemble', payload: id }), - } - }, { - name: 'foo', - path: '/my/:fancy/path(/:wow/*supercomplicated(/:thing))', - }] - index.setEnhancedRoutes( - enhancers.save(routes[2], - enhancers.save(routes[1], - enhancers.save(routes[0], {})))) - }) - it('makePath', () => { - expect(index.makePath('oops')).eqls(false) - expect(index.makePath('campers', { year: '2014', id: 'hi' })).eqls('/campers/2014/hi') - expect(index.makePath('campers', { })).eqls(false) - expect(index.makePath('ensembles', { id: 'hi' })).eqls('/ensembles/hi') - expect(index.makePath('ensembles', { })).eqls('/ensembles') - expect(index.makePath('foo', { fancy: 'shmancy' })).eqls('/my/shmancy/path') - expect(index.makePath('foo', { fancy: 'shmancy', wow: 'amazing', supercomplicated: 'boop/deboop' })) - .eqls('/my/shmancy/path/amazing/boop/deboop') - expect(index.makePath('foo', { fancy: 'shmancy', wow: 'amazing', supercomplicated: 'boop/deboop', thing: 'huzzah' })) - .eqls('/my/shmancy/path/amazing/boop/deboop/huzzah') - }) - it('matchesPath', () => { - expect(index.matchesPath('oops')).eqls(false) - expect(index.matchesPath('campers', '/campers/2014/hi')).eqls({ year: '2014', id: 'hi' }) - expect(index.matchesPath('campers', '/campefrs/2014')).eqls(false) - expect(index.matchesPath('campers', { pathname: '/campers/2014/hi', search: '', hash: '' })).eqls({ year: '2014', id: 'hi' }) - }) - }) - it('synchronousMakeRoutes', () => { const routes = [{ name: 'campers', @@ -112,90 +52,29 @@ describe('ion-router', () => { parent: undefined, } }) - index.synchronousMakeRoutes([]) - }) - it('setServer', () => { - expect(index.options.server).is.false - index.setServer() - expect(index.options.server).is.true - index.setServer(false) - expect(index.options.server).is.false }) describe('main', () => { it('calls the 3 connect functions', () => { const spy = sinon.spy() - index.default(() => spy) + const store = { routerOptions: {} } + index.default(() => spy, store) expect(spy.called).is.true expect(spy.args).has.length(2) }) it('sets options server', () => { - index.default(() => () => null, {}, undefined, true) - expect(index.options.isServer).is.true + const store = { routerOptions: {} } + index.default(() => () => null, store, undefined, true) + expect(store.routerOptions.isServer).is.true }) it('sets up server routes', () => { + const log = [] const store = { - dispatch: () => null - } - index.default(() => () => null, store, [ - { - name: 'hi', - path: '/hi' - }, - { - name: 'there', - path: '/there' + dispatch: action => log.push(action), + routerOptions: { + enhancedRoutes: {} } - ]) - expect(index.options.enhancedRoutes).eqls(enhancers.save( - { - name: 'there', - path: '/there', - parent: undefined, - }, enhancers.save( - { - name: 'hi', - path: '/hi', - parent: undefined, - }, {} - ))) - index.setEnhancedRoutes({}) - }) - it('uses options passed in', () => { - const store = { - dispatch: sinon.spy() - } - const opts = {} - index.default(() => () => null, store, [ - { - name: 'hi', - path: '/hi' - }, - { - name: 'there', - path: '/there' - } - ], true, opts) - expect(opts).eqls({ - enhancedRoutes: enhancers.save( - { - name: 'there', - path: '/there', - parent: undefined, - }, enhancers.save( - { - name: 'hi', - path: '/hi', - parent: undefined, - }, {})), - isServer: true - }) - }) - it('dispatches routes', () => { - const store = { - dispatch: sinon.spy() } - const opts = {} - index.default(() => () => null, store, [ + const routes = [ { name: 'hi', path: '/hi' @@ -204,20 +83,9 @@ describe('ion-router', () => { name: 'there', path: '/there' } - ], true, opts) - expect(store.dispatch.called).is.true - expect(store.dispatch.args[0]).eqls([ - actions.batchRoutes([ - { - name: 'hi', - path: '/hi' - }, - { - name: 'there', - path: '/there' - } - ]) - ]) + ] + index.default(() => () => null, store, routes) + expect(log).eqls([actions.batchRoutes(routes)]) }) }) }) diff --git a/test/middleware.test.js b/test/middleware.test.js index 5ab3858..b1da0a3 100644 --- a/test/middleware.test.js +++ b/test/middleware.test.js @@ -1,6 +1,7 @@ import createHistory from 'history/createMemoryHistory' import createMiddleware, { actionHandlers, ignoreKey } from '../src/middleware' +import storeEnhancer from '../src/storeEnhancer' import { synchronousMakeRoutes } from '../src' import routerReducer from '../src/reducer' import * as actions from '../src/actions' @@ -19,7 +20,13 @@ describe('middleware', () => { } } const store = { - dispatch: sinon.spy() + dispatch: sinon.spy(), + routerOptions: { + server: false, + enhancedRoutes: {}, + pending: false, + resolve: false, + } } const mid = createMiddleware(spy) expect(mid(store)).is.instanceof(Function) @@ -52,8 +59,7 @@ describe('middleware', () => { let history let opts function makeStuff(spies = actionHandlers, reducers = undefined, debug = false) { - const mid = createMiddleware(history, opts, spies, debug) - return sagaStore(undefined, reducers, [mid]) + return sagaStore(undefined, reducers, [], storeEnhancer(history, spies, debug, opts)) } it('throws on action with false route', () => { expect(() => { @@ -98,6 +104,11 @@ describe('middleware', () => { info.store.dispatch(actions.route('/hi')) expect(spy.called).is.true expect(info.log).eqls([ + actions.route({ pathname: '/', + search: '', + hash: '', + state: undefined, + key: undefined }), actions.route('/hi'), { type: 'foo' } ]) @@ -174,6 +185,11 @@ describe('middleware', () => { expect(spy.args[0][2]).equals(action) expect(spy.args.length).eqls(1) expect(info.log).eqls([ + actions.route({ pathname: '/', + search: '', + hash: '', + state: undefined, + key: undefined }), { type: 'hithere' }, { type: 'hithere' }, ]) @@ -208,6 +224,11 @@ describe('middleware', () => { }) expect(spy.args[0][2]).equals(action) expect(info.log).eqls([ + actions.route({ pathname: '/', + search: '', + hash: '', + state: undefined, + key: undefined }), { type: 'hithere' }, ]) }) @@ -222,8 +243,13 @@ describe('middleware', () => { const info = makeStuff(duds) info.store.dispatch(actions.push('/foo')) expect(info.log).eqls([ + actions.route({ pathname: '/', + search: '', + hash: '', + state: undefined, + key: undefined }), actions.push('/foo'), - actions.route(info.log[1].payload) + actions.route(info.log[2].payload) ]) }) it('state action handling passes new state to handler', () => { diff --git a/test/test_helper.jsx b/test/test_helper.jsx index 21b4ddb..07fa9d3 100644 --- a/test/test_helper.jsx +++ b/test/test_helper.jsx @@ -1,12 +1,18 @@ import React, { Component } from 'react' import teaspoon from 'teaspoon' import { Provider, connect } from 'react-redux' -import { createStore, combineReducers, applyMiddleware } from 'redux' +import { createStore, combineReducers, applyMiddleware, compose } from 'redux' +import createHistory from 'history/createMemoryHistory' + import reducer from '../src/reducer' +import storeEnhancer from '../src/storeEnhancer' const fakeWeekReducer = (state = 1) => state -function sagaStore(state, reducers = { routing: reducer, week: fakeWeekReducer }, middleware = []) { +function sagaStore(state, reducers = { routing: reducer, week: fakeWeekReducer }, middleware = [], + enhancer = storeEnhancer(createHistory({ + initialEntries: ['/'] + }))) { const log = [] const logger = store => next => action => { // eslint-disable-line log.push(action) @@ -14,28 +20,15 @@ function sagaStore(state, reducers = { routing: reducer, week: fakeWeekReducer } } const store = createStore(combineReducers(reducers), - state, applyMiddleware(...middleware, logger)) + state, compose(enhancer, applyMiddleware(...middleware, logger))) return { log, store, } } -function renderComponent(ComponentClass, props = {}, state = {}, returnStore = false, - sagaStore = false, intoDocument = false) { - let store - let log - if (!sagaStore) { - log = [] - const logger = store => next => action => { // eslint-disable-line - log.push(action) - return next(action) - } - - store = createStore(combineReducers({ routing: reducer, week: fakeWeekReducer }), - state, applyMiddleware(logger)) - } - +function renderComponent(ComponentClass, props = {}, state = undefined, returnStore = false, + mySagaStore = sagaStore(state), intoDocument = false) { class Tester extends Component { constructor(props) { super(props) @@ -48,7 +41,7 @@ function renderComponent(ComponentClass, props = {}, state = {}, returnStore = f } render() { return ( - + ) @@ -59,7 +52,7 @@ function renderComponent(ComponentClass, props = {}, state = {}, returnStore = f ).render(intoDocument) const ret = componentInstance if (returnStore) { - return [ret, store, log] + return [ret, mySagaStore.store, mySagaStore.log] } return ret }