├── docs ├── src │ ├── examples │ │ ├── Auth.jsx │ │ ├── index.ts │ │ ├── Loading.jsx │ │ ├── Basic.jsx │ │ ├── Typescript.tsx │ │ ├── SubRoutes.jsx │ │ └── StateChanges.jsx │ ├── marked.d.ts │ ├── raw.macro.d.ts │ ├── react-app-env.d.ts │ ├── redux │ │ ├── types.js │ │ ├── actions.js │ │ └── example.js │ ├── index.css │ ├── toggles │ │ ├── ExampleToggle.js │ │ └── ExamplesToggle.js │ ├── components │ │ ├── Test.jsx │ │ ├── MarkdownViewer.jsx │ │ ├── Window.jsx │ │ ├── ShowSource.jsx │ │ ├── Examples.jsx │ │ ├── Browser.jsx │ │ └── Example.jsx │ ├── index.js │ ├── logo.svg │ ├── App.tsx │ └── App.css ├── public │ ├── favicon.ico │ ├── apple-icon.png │ ├── ion-router.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-96x96.png │ ├── ms-icon-70x70.png │ ├── apple-icon-57x57.png │ ├── apple-icon-60x60.png │ ├── apple-icon-72x72.png │ ├── apple-icon-76x76.png │ ├── ms-icon-144x144.png │ ├── ms-icon-150x150.png │ ├── ms-icon-310x310.png │ ├── android-icon-36x36.png │ ├── android-icon-48x48.png │ ├── android-icon-72x72.png │ ├── android-icon-96x96.png │ ├── apple-icon-114x114.png │ ├── apple-icon-120x120.png │ ├── apple-icon-144x144.png │ ├── apple-icon-152x152.png │ ├── apple-icon-180x180.png │ ├── android-icon-144x144.png │ ├── android-icon-192x192.png │ ├── apple-icon-precomposed.png │ ├── browserconfig.xml │ ├── manifest.json │ ├── 404.html │ └── index.html ├── .storybook │ └── config.js ├── my-markdown-loader │ ├── package.json │ └── index.js ├── .gitignore ├── stories │ └── index.jsx ├── tsconfig.json └── package.json ├── .eslintignore ├── Link.js ├── types.js ├── Route.js ├── Routes.js ├── Toggle.js ├── actions.js ├── enhancers.js ├── reducer.js ├── selectors.js ├── RouteToggle.js ├── .vscode └── settings.json ├── logos ├── ion-router.png └── ion-router.xcf ├── .npmignore ├── src ├── __mocks__ │ └── Context.js ├── DisplaysChildren.tsx ├── types.ts ├── RouteToggle.tsx ├── Context.ts ├── NullComponent.tsx ├── type-tests │ ├── index.test-d.ts │ └── actions.test-d.ts ├── storeEnhancer.ts ├── selectors.ts ├── Route.tsx ├── Routes.tsx ├── Toggle.tsx ├── enhancers.ts ├── reducer.ts ├── index.ts ├── Link.tsx ├── middleware.ts └── actions.ts ├── test ├── test.config.es6.js ├── .eslintrc ├── enhanced.test.ts ├── test_helper.tsx ├── index.test.ts ├── RouteToggle.test.tsx ├── actions.test.ts ├── Route.test.tsx ├── reducer.test.ts ├── Link.test.tsx ├── selectors.test.ts ├── Toggle.test.tsx ├── Routes.test.tsx └── helpers.test.ts ├── .babelrc ├── .gitignore ├── .eslintrc ├── .codeclimate.json ├── LICENSE ├── wallaby.js ├── README.md ├── run-tests.sh ├── .travis.yml ├── package.json └── tsconfig.json /docs/src/examples/Auth.jsx: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/*{.,-}min.js 2 | test/karma -------------------------------------------------------------------------------- /docs/src/marked.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'marked' 2 | -------------------------------------------------------------------------------- /Link.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/Link.js') 2 | -------------------------------------------------------------------------------- /types.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/types') 2 | -------------------------------------------------------------------------------- /Route.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/Route.js') 2 | -------------------------------------------------------------------------------- /Routes.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/Routes.js') 2 | -------------------------------------------------------------------------------- /Toggle.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/Toggle.js') 2 | -------------------------------------------------------------------------------- /docs/src/raw.macro.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'raw.macro' 2 | -------------------------------------------------------------------------------- /actions.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/actions.js') 2 | -------------------------------------------------------------------------------- /enhancers.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/enhancers.js') 2 | -------------------------------------------------------------------------------- /reducer.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/reducer.js') 2 | -------------------------------------------------------------------------------- /selectors.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/selectors.js') 2 | -------------------------------------------------------------------------------- /RouteToggle.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/RouteToggle.js') 2 | -------------------------------------------------------------------------------- /docs/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /docs/src/redux/types.js: -------------------------------------------------------------------------------- 1 | export const CHOOSE_EXAMPLE = 'CHOOSE_EXAMPLE' 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /logos/ion-router.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellog/ion-router/HEAD/logos/ion-router.png -------------------------------------------------------------------------------- /logos/ion-router.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellog/ion-router/HEAD/logos/ion-router.xcf -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellog/ion-router/HEAD/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /docs/public/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellog/ion-router/HEAD/docs/public/apple-icon.png -------------------------------------------------------------------------------- /docs/public/ion-router.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellog/ion-router/HEAD/docs/public/ion-router.png -------------------------------------------------------------------------------- /docs/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellog/ion-router/HEAD/docs/public/favicon-16x16.png -------------------------------------------------------------------------------- /docs/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellog/ion-router/HEAD/docs/public/favicon-32x32.png -------------------------------------------------------------------------------- /docs/public/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellog/ion-router/HEAD/docs/public/favicon-96x96.png -------------------------------------------------------------------------------- /docs/public/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellog/ion-router/HEAD/docs/public/ms-icon-70x70.png -------------------------------------------------------------------------------- /docs/public/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellog/ion-router/HEAD/docs/public/apple-icon-57x57.png -------------------------------------------------------------------------------- /docs/public/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellog/ion-router/HEAD/docs/public/apple-icon-60x60.png -------------------------------------------------------------------------------- /docs/public/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellog/ion-router/HEAD/docs/public/apple-icon-72x72.png -------------------------------------------------------------------------------- /docs/public/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellog/ion-router/HEAD/docs/public/apple-icon-76x76.png -------------------------------------------------------------------------------- /docs/public/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellog/ion-router/HEAD/docs/public/ms-icon-144x144.png -------------------------------------------------------------------------------- /docs/public/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellog/ion-router/HEAD/docs/public/ms-icon-150x150.png -------------------------------------------------------------------------------- /docs/public/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellog/ion-router/HEAD/docs/public/ms-icon-310x310.png -------------------------------------------------------------------------------- /docs/public/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellog/ion-router/HEAD/docs/public/android-icon-36x36.png -------------------------------------------------------------------------------- /docs/public/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellog/ion-router/HEAD/docs/public/android-icon-48x48.png -------------------------------------------------------------------------------- /docs/public/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellog/ion-router/HEAD/docs/public/android-icon-72x72.png -------------------------------------------------------------------------------- /docs/public/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellog/ion-router/HEAD/docs/public/android-icon-96x96.png -------------------------------------------------------------------------------- /docs/public/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellog/ion-router/HEAD/docs/public/apple-icon-114x114.png -------------------------------------------------------------------------------- /docs/public/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellog/ion-router/HEAD/docs/public/apple-icon-120x120.png -------------------------------------------------------------------------------- /docs/public/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellog/ion-router/HEAD/docs/public/apple-icon-144x144.png -------------------------------------------------------------------------------- /docs/public/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellog/ion-router/HEAD/docs/public/apple-icon-152x152.png -------------------------------------------------------------------------------- /docs/public/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellog/ion-router/HEAD/docs/public/apple-icon-180x180.png -------------------------------------------------------------------------------- /docs/public/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellog/ion-router/HEAD/docs/public/android-icon-144x144.png -------------------------------------------------------------------------------- /docs/public/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellog/ion-router/HEAD/docs/public/android-icon-192x192.png -------------------------------------------------------------------------------- /docs/public/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cellog/ion-router/HEAD/docs/public/apple-icon-precomposed.png -------------------------------------------------------------------------------- /docs/src/toggles/ExampleToggle.js: -------------------------------------------------------------------------------- 1 | import Toggle from 'ion-router/Toggle' 2 | 3 | export default Toggle(state => state.examples.example, undefined, {}, false, 'mainStore') 4 | -------------------------------------------------------------------------------- /docs/src/toggles/ExamplesToggle.js: -------------------------------------------------------------------------------- 1 | import RouteToggle from 'ion-router/RouteToggle' 2 | 3 | export default RouteToggle('examples', null, undefined, {}, false, 'mainStore') 4 | -------------------------------------------------------------------------------- /docs/src/components/Test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function Test() { 4 | return ( 5 |
6 | Hi there! 7 |
8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /docs/.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from '@kadira/storybook'; 2 | 3 | function loadStories() { 4 | require('../stories/index'); 5 | } 6 | 7 | configure(loadStories, module); 8 | -------------------------------------------------------------------------------- /docs/src/redux/actions.js: -------------------------------------------------------------------------------- 1 | import * as types from './types' 2 | 3 | export function chooseExample(example) { 4 | return { 5 | type: types.CHOOSE_EXAMPLE, 6 | payload: example 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /docs/my-markdown-loader/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "marky-loader", 3 | "version": "0.5.0", 4 | "description": "configuration-less markdown loader for webpack", 5 | "main": "index.js", 6 | "author": "Gregory Beaver", 7 | "license": "MIT" 8 | } 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | test 4 | examples 5 | coverage 6 | .idea 7 | wallaby.js 8 | .travis.yml 9 | karma-common.conf.js 10 | .eslintignore 11 | .eslintrc 12 | .codeclimate.yml 13 | local-test.env 14 | run-tests.sh 15 | browserstack.svg 16 | .babelrc 17 | .npmignore 18 | docs -------------------------------------------------------------------------------- /src/__mocks__/Context.js: -------------------------------------------------------------------------------- 1 | const Context = { 2 | Provider: ({ value, children }) => { 3 | Context.value = value 4 | return children 5 | }, 6 | value: null, 7 | Consumer: props => { 8 | return props.children(Context.value) 9 | } 10 | } 11 | 12 | export default Context 13 | -------------------------------------------------------------------------------- /docs/public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | #ffffff -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | 19 | -------------------------------------------------------------------------------- /src/DisplaysChildren.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | export function DisplaysChildren({ children }: { children: React.ReactChild }) { 5 | return {children} 6 | } 7 | 8 | export default DisplaysChildren 9 | 10 | DisplaysChildren.propTypes = { 11 | children: PropTypes.any, 12 | } 13 | -------------------------------------------------------------------------------- /test/test.config.es6.js: -------------------------------------------------------------------------------- 1 | const stuff = { 2 | output: { 3 | pathinfo: true 4 | }, 5 | 6 | resolve: { 7 | extensions: ['', '.js', '.jsx'] 8 | }, 9 | 10 | module: { 11 | loaders: [ 12 | { test: /\.jsx?$/, loader: 'babel-loader', include: /(src|test)/ }, 13 | ] 14 | }, 15 | 16 | devtool: 'inline-source-map' 17 | } 18 | 19 | export default stuff 20 | -------------------------------------------------------------------------------- /docs/stories/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import * as storybook from '@kadira/storybook' // eslint-disable-line 3 | 4 | import Browser from '../src/components/Browser' 5 | 6 | storybook.storiesOf('Browser', module) 7 | .add('title bar', () => ( 8 | 13 | )) 14 | -------------------------------------------------------------------------------- /docs/src/components/MarkdownViewer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | export default function Viewer({ text }) { 5 | const danger = { __html: text } 6 | return ( 7 |
8 |
9 |
10 | ) 11 | } 12 | 13 | Viewer.propTypes = { 14 | text: PropTypes.string.isRequired 15 | } 16 | -------------------------------------------------------------------------------- /docs/src/components/Window.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from 'react-dom' 3 | 4 | export default function Window({ 5 | element, 6 | }) { 7 | const node = React.useRef(null) 8 | const setNode = React.useCallback(ref => { 9 | if (ref) { 10 | render(element, ref) 11 | node.current = ref 12 | } 13 | }) 14 | return ( 15 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /docs/src/redux/example.js: -------------------------------------------------------------------------------- 1 | import * as types from './types' 2 | 3 | const defaultState = { 4 | example: false 5 | } 6 | 7 | export default function reducer(state = defaultState, action) { 8 | if (!action || !action.type) return state 9 | switch (action.type) { 10 | case types.CHOOSE_EXAMPLE: 11 | return { 12 | ...state, 13 | example: action.payload 14 | } 15 | default: 16 | return state 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /docs/my-markdown-loader/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const marked = require("marked"); 4 | const loaderUtils = require("loader-utils"); 5 | 6 | module.exports = function (markdown) { 7 | // merge params and default config 8 | const options = loaderUtils.parseQuery(this.query); 9 | 10 | this.cacheable(); 11 | 12 | marked.setOptions(options); 13 | 14 | const output = JSON.stringify(marked(markdown)) 15 | return `module.exports = ${output}`; 16 | }; 17 | 18 | module.exports.seperable = true; 19 | -------------------------------------------------------------------------------- /docs/src/components/ShowSource.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' 4 | import { dark } from 'react-syntax-highlighter/dist/esm/styles/prism' 5 | 6 | function ShowSource({ source, lang = "jsx" }) { 7 | return ( 8 | 9 | {source} 10 | 11 | ) 12 | } 13 | 14 | ShowSource.propTypes = { 15 | source: PropTypes.string.isRequired, 16 | lang: PropTypes.string 17 | } 18 | 19 | export default ShowSource 20 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-typescript", 4 | "@babel/preset-react", 5 | [ 6 | "@babel/env", 7 | { 8 | "targets": { 9 | "edge": "17", 10 | "firefox": "60", 11 | "chrome": "67", 12 | "safari": "11.1" 13 | }, 14 | "useBuiltIns": "usage", 15 | "corejs": "2" 16 | } 17 | ] 18 | ], 19 | "plugins": [ 20 | "@babel/plugin-proposal-class-properties", 21 | "@babel/plugin-proposal-export-default-from", 22 | "@babel/plugin-proposal-object-rest-spread" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "preserve" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export const EDIT_ROUTE = '@@ion-router/EDIT_ROUTE' 2 | export const REMOVE_ROUTE = '@@ion-router/REMOVE_ROUTE' 3 | export const BATCH_ROUTES = '@@ion-router/BATCH_ROUTES' 4 | export const BATCH_REMOVE_ROUTES = '@@ion-router/BATCH_REMOVE_ROUTES' 5 | 6 | export const ACTION = '@@ion-router/ACTION' 7 | export const ROUTE = '@@ion-router/ROUTE' 8 | 9 | export const MATCH_ROUTES = '@@ion-router/MATCH_ROUTES' 10 | export const SET_PARAMS = '@@ion-router/SET_PARAMS' 11 | 12 | export const ENTER_ROUTES = '@@ion-router/ENTER_ROUTES' 13 | export const EXIT_ROUTES = '@@ion-router/EXIT_ROUTES' 14 | 15 | export const PENDING_UPDATES = '@@ion-router/PENDING_UPDATES' 16 | export const COMMITTED_UPDATES = '@@ion-router/COMMITTED_UPDATES' 17 | -------------------------------------------------------------------------------- /src/RouteToggle.tsx: -------------------------------------------------------------------------------- 1 | import Toggle, { 2 | ReduxSelector, 3 | ComponentLoadingMap, 4 | MightDefineVars, 5 | LoadedSelector, 6 | } from './Toggle' 7 | import * as selectors from './selectors' 8 | 9 | export function RouteToggle< 10 | ExtraProps extends MightDefineVars, 11 | StoreState extends selectors.FullStateWithRouter 12 | >( 13 | route: string, 14 | othertests: ReduxSelector | null = null, 15 | loading: LoadedSelector | undefined = undefined, 16 | componentMap: ComponentLoadingMap = {} 17 | ) { 18 | return Toggle( 19 | (state: StoreState) => 20 | selectors.matchedRoute(state, route) && 21 | (othertests ? othertests(state) : true), 22 | loading, 23 | componentMap 24 | ) 25 | } 26 | 27 | export default RouteToggle 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | 13 | # Directory for instrumented libs generated by jscoverage/JSCover 14 | lib-cov 15 | 16 | # Coverage directory used by tools like istanbul 17 | coverage 18 | 19 | # nyc test coverage 20 | .nyc_output 21 | 22 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 23 | .grunt 24 | 25 | # node-waf configuration 26 | .lock-wscript 27 | 28 | # Compiled binary addons (http://nodejs.org/api/addons.html) 29 | build/Release 30 | 31 | # Dependency directories 32 | node_modules 33 | jspm_packages 34 | 35 | # Optional npm cache directory 36 | .npm 37 | 38 | # Optional REPL history 39 | .node_repl_history 40 | 41 | local-test.env 42 | lib 43 | .DS_Store 44 | test/lcov 45 | -------------------------------------------------------------------------------- /docs/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ion-router docs", 3 | "icons": [ 4 | { 5 | "src": "\/android-icon-36x36.png", 6 | "sizes": "36x36", 7 | "type": "image\/png", 8 | "density": "0.75" 9 | }, 10 | { 11 | "src": "\/android-icon-48x48.png", 12 | "sizes": "48x48", 13 | "type": "image\/png", 14 | "density": "1.0" 15 | }, 16 | { 17 | "src": "\/android-icon-72x72.png", 18 | "sizes": "72x72", 19 | "type": "image\/png", 20 | "density": "1.5" 21 | }, 22 | { 23 | "src": "\/android-icon-96x96.png", 24 | "sizes": "96x96", 25 | "type": "image\/png", 26 | "density": "2.0" 27 | }, 28 | { 29 | "src": "\/android-icon-144x144.png", 30 | "sizes": "144x144", 31 | "type": "image\/png", 32 | "density": "3.0" 33 | }, 34 | { 35 | "src": "\/android-icon-192x192.png", 36 | "sizes": "192x192", 37 | "type": "image\/png", 38 | "density": "4.0" 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /src/Context.ts: -------------------------------------------------------------------------------- 1 | import * as react from 'react' 2 | import { Dispatch, AnyAction, Store } from 'redux' 3 | import { IonRouterState } from './reducer' 4 | import { FullStateWithRouter } from './selectors' 5 | import { IonRouterOptions } from './storeEnhancer' 6 | import { DeclareRoute } from './enhancers' 7 | 8 | export interface RouterContext { 9 | dispatch: Dispatch 10 | routes: IonRouterState['routes']['routes'] 11 | addRoute: < 12 | ReduxState extends FullStateWithRouter, 13 | Params extends { [key: string]: string }, 14 | ParamsState extends { [key: string]: any }, 15 | Action extends { type: string; [key: string]: any } 16 | >( 17 | route: DeclareRoute 18 | ) => void 19 | store: Store & IonRouterOptions 20 | } 21 | 22 | export const Context = react.createContext(null) 23 | 24 | export default Context 25 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "parserOptions": { 4 | "ecmaVersion": 9, 5 | "sourceType": "module", 6 | "ecmaFeatures": { 7 | "jsx": true, 8 | "modules": true, 9 | } 10 | }, 11 | "env": { 12 | "es6": true, 13 | "node": true, 14 | "browser": true, 15 | "jest": true 16 | }, 17 | "plugins": [ 18 | "react" 19 | ], 20 | "extends": ["eslint:recommended", "plugin:react/recommended"], 21 | "rules": { 22 | "quotes": 0, 23 | "func-names": 0, 24 | "comma-dangle": 0, 25 | "semi": [2, "never"], 26 | "new-cap": 0, 27 | "react/prefer-stateless-function": 0, 28 | "react/forbid-prop-types": 0, 29 | "react/require-default-props": 0, 30 | "react/prop-types": 1, 31 | "generator-star-spacing": 0, 32 | "no-plusplus": 0 33 | }, 34 | "settings": { 35 | "react": { 36 | "pragma": "React", 37 | "version": "16.4.0" 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "parserOptions": { 4 | "ecmaVersion": 9, 5 | "sourceType": "module", 6 | "ecmaFeatures": { 7 | "jsx": true, 8 | "modules": true, 9 | } 10 | }, 11 | "env": { 12 | "node": true, 13 | "browser": true, 14 | "jest": true 15 | }, 16 | "plugins": [ 17 | "react" 18 | ], 19 | "extends": ["eslint:recommended", "plugin:react/recommended"], 20 | "rules": { 21 | "quotes": 0, 22 | "no-shadow": 0, 23 | "no-unused-expressions": 0, 24 | "react/no-multi-comp": 0, 25 | "no-nested-ternary": 0, 26 | "func-names": 0, 27 | "comma-dangle": 0, 28 | "semi": [2, "never"], 29 | "new-cap": 0, 30 | "react/prefer-stateless-function": 0, 31 | "react/forbid-prop-types": 0, 32 | "react/require-default-props": 0, 33 | "react/prop-types": 0, 34 | "generator-star-spacing": 0, 35 | "no-plusplus": 0 36 | }, 37 | "settings": { 38 | "react": { 39 | "pragma": "React", 40 | "version": "16.4.0" 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.codeclimate.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2", 3 | "plugins": { 4 | "eslint": { 5 | "enabled": true, 6 | "channel": "eslint-4", 7 | "config": { 8 | "extensions": [ 9 | ".js", 10 | ".jsx" 11 | ] 12 | }, 13 | "checks": { 14 | "complexity": { 15 | "enabled": true 16 | }, 17 | "react/require-extensions": { 18 | "enabled": false 19 | } 20 | }, 21 | "exclude_fingerprints": [ 22 | "71e4a5cf3764ccc996875dd8c72112db", 23 | "c6734c0621047f7dd7f475259f61d063", 24 | "7061fe03cece5d4a19066647c5acd738", 25 | "2128d3b42dfe30300804ee4ad774bffc", 26 | "f47b36ac9d4590110806f10eb8a9ebff", 27 | "3bb9ef7dcc79b677447a671cb1adbec1", 28 | "a0112bc6945138500fb4b2e083aa0980", 29 | "8e126c9a4c86caafbc412615135e8751" 30 | ] 31 | } 32 | }, 33 | "exclude_patterns": [ 34 | "build/", 35 | "node_modules/", 36 | ".storybook/", 37 | "test/", 38 | "examples/", 39 | "wallaby.js", 40 | "docs/" 41 | ] 42 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Greg Beaver 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /wallaby.js: -------------------------------------------------------------------------------- 1 | module.exports = function(wallaby) { 2 | return { 3 | files: [ 4 | 'test/test_helper.jsx', 5 | { pattern: 'test/**/*.test.js', ignore: true }, 6 | { pattern: 'test/karma/*', ignore: true, instrument: false }, 7 | 'src/**/*.js*', 8 | ], 9 | filesWithNoCoverageCalculated: ['test/test_helper.jsx'], 10 | tests: [ 11 | { pattern: 'node_modules/*', ignore: true, instrument: false }, 12 | { pattern: 'test/karma/*', ignore: true, instrument: false }, 13 | 'test/**/*.test.js*' 14 | ], 15 | compilers: { 16 | '**/*.js?(x)': wallaby.compilers.babel({ 17 | babel: require('@babel/core'), 18 | presets: ["@babel/preset-react", ["@babel/env", { 19 | targets: { 20 | edge: "17", 21 | firefox: "60", 22 | chrome: "67", 23 | safari: "11.1" 24 | }, 25 | useBuiltIns: "usage" 26 | }]], 27 | plugins: ["@babel/plugin-proposal-class-properties", "@babel/plugin-proposal-export-default-from"], 28 | }), 29 | }, 30 | env: { 31 | type: 'node' 32 | }, 33 | testFramework: 'jest' 34 | // debug: true 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /docs/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { createStore, combineReducers, compose } from 'redux' 4 | import { Provider } from 'react-redux' 5 | import Routes from 'ion-router/Routes' 6 | import * as ion from 'ion-router' 7 | import routing from 'ion-router/reducer' 8 | import examples from './redux/example' 9 | import exampleSetup from './examples' 10 | 11 | import App from './App' 12 | import './index.css' 13 | 14 | const exampleReducers = Object.keys(exampleSetup).reduce((reducers, key) => ({ 15 | ...reducers, 16 | [key]: exampleSetup[key].reducer 17 | }), {}) 18 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose // eslint-disable-line 19 | const reducer = combineReducers({ 20 | routing, 21 | examples, 22 | ...exampleReducers, 23 | }) 24 | 25 | 26 | // set up the router and create the store 27 | const enhancer = ion.makeRouterStoreEnhancer() 28 | const store = createStore(reducer, undefined, 29 | composeEnhancers(enhancer)) 30 | 31 | ReactDOM.render( 32 | 33 | 34 | 35 | 36 | , 37 | document.getElementById('root') 38 | ) 39 | -------------------------------------------------------------------------------- /docs/src/components/Examples.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | 4 | import ExampleToggle from '../toggles/ExampleToggle' 5 | import Link from 'ion-router/Link' 6 | import Example from './Example' 7 | 8 | const ConnectedExample = connect(state => ({ 9 | example: state.examples.example 10 | }))(Example) 11 | 12 | export default function Examples() { 13 | return ( 14 |
15 | ( 16 |
17 |

Examples of ion-router's power

18 |

19 | There are many ways ion-router can be used, and this 20 | list will grow as new ones are added. 21 |

22 |
    23 |
  • Basic Example
  • 24 |
  • Asynchronous loading
  • 25 |
  • Dynamic sub-routes
  • 26 |
  • Updating url from only redux state changes
  • 27 |
28 |
29 | )} 30 | /> 31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Ion Router Logo 2 | 3 | # ion-router 4 | ###### Connecting your url and redux state 5 | 6 | [![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) 7 | 8 | Elegant powerful routing based on the simplicity of storing url as state 9 | 10 | To install: 11 | 12 | ```bash 13 | $ npm i -S ion-router 14 | ``` 15 | 16 | ## New Documentation 17 | 18 | Our documentation now lives [here](https://cellog.github.io/ion-router) 19 | 20 | ## License 21 | 22 | MIT License 23 | 24 | ## Thanks 25 | 26 | [![http://www.browserstack.com](https://www.browserstack.com/images/layout/browserstack-logo-600x315.png)](http://www.browserstack.com) 27 | 28 | Huge thanks to [BrowserStack](http://www.browserstack.com) for providing 29 | cross-browser testing on real devices, both automatic testing and manual testing. -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "new-docs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "homepage": "https://cellog.github.io/ion-router", 6 | "dependencies": { 7 | "font-awesome": "^4.7.0", 8 | "history": "^4.6.1", 9 | "ion-router": "file:../", 10 | "marked": "^0.8.0", 11 | "prop-types": "^15.7.2", 12 | "raw.macro": "^0.3.0", 13 | "react-burger-menu": "^2.6.13", 14 | "react-syntax-highlighter": "^12.2.1", 15 | "styled-jsx": "^3.2.4" 16 | }, 17 | "devDependencies": { 18 | "@types/jest": "^25.1.1", 19 | "@types/node": "^13.7.0", 20 | "@types/react": "^16.9.19", 21 | "@types/react-burger-menu": "^2.6.0", 22 | "@types/react-dom": "^16.9.5", 23 | "react-scripts": "^2.1.8", 24 | "typescript": "^3.7.5" 25 | }, 26 | "peerDependencies": { 27 | "react": "^16.12.0", 28 | "react-dom": "^16.12.0", 29 | "react-redux": "^7.1.3", 30 | "redux": "^4.0.4" 31 | }, 32 | "scripts": { 33 | "start": "SKIP_PREFLIGHT_CHECK=true PORT=3005 react-scripts start", 34 | "build": "SKIP_PREFLIGHT_CHECK=true react-scripts build", 35 | "test": "react-scripts test --env=jsdom", 36 | "eject": "react-scripts eject", 37 | "predeploy": "npm run build", 38 | "deploy": "gh-pages -d build", 39 | "storybook": "start-storybook -p 9009 -s public", 40 | "build-storybook": "build-storybook -s public" 41 | }, 42 | "browserslist": [ 43 | ">0.2%", 44 | "not dead", 45 | "not ie <= 11", 46 | "not op_mini all" 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /test/enhanced.test.ts: -------------------------------------------------------------------------------- 1 | import RouteParser from 'route-parser' 2 | import * as enhance from '../src/enhancers' 3 | 4 | describe('enhanced route store', () => { 5 | test('fake', () => { 6 | // coverage 7 | expect(enhance.fake()).toEqual({}) 8 | }) 9 | test('save', () => { 10 | expect( 11 | enhance.save( 12 | { 13 | name: 'thing', 14 | path: '/another', 15 | }, 16 | {} 17 | ) 18 | ).toEqual({ 19 | thing: { 20 | ...enhance.enhanceRoute({ 21 | name: 'thing', 22 | path: '/another', 23 | }), 24 | }, 25 | }) 26 | }) 27 | test('enhanceRoute', () => { 28 | expect( 29 | enhance.enhanceRoute({ 30 | name: 'thing', 31 | path: '/another', 32 | }) 33 | ).toEqual({ 34 | stateFromParams: enhance.fake, 35 | paramsFromState: enhance.fake, 36 | updateState: {}, 37 | exitParams: {}, 38 | name: 'thing', 39 | path: '/another', 40 | '@parser': new RouteParser('/another'), 41 | state: {}, 42 | params: {}, 43 | }) 44 | expect( 45 | enhance.enhanceRoute({ 46 | name: 'thing', 47 | path: '/:another', 48 | }) 49 | ).toEqual({ 50 | stateFromParams: enhance.fake, 51 | paramsFromState: enhance.fake, 52 | updateState: {}, 53 | exitParams: undefined, 54 | name: 'thing', 55 | path: '/:another', 56 | '@parser': new RouteParser('/:another'), 57 | state: {}, 58 | params: {}, 59 | }) 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /docs/src/components/Browser.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import * as actions from 'ion-router/actions' 4 | import { createPath } from 'history' 5 | import { connect } from 'react-redux' 6 | import 'font-awesome/css/font-awesome.min.css' 7 | 8 | import '../App.css' 9 | 10 | const Browser = ({ url = '/', back, forward, reset, showSource, Content = () =>
hi
, store }) => { 11 | return ( 12 |
13 |
14 | 15 | 16 | 17 | 18 | 19 |
20 |
21 | 22 |
23 |
24 | )} 25 | 26 | 27 | Browser.propTypes = { 28 | url: PropTypes.string.isRequired, 29 | back: PropTypes.func.isRequired, 30 | reset: PropTypes.func.isRequired, 31 | forward: PropTypes.func.isRequired, 32 | showSource: PropTypes.func.isRequired, 33 | Content: PropTypes.any, 34 | } 35 | 36 | export default connect((state) => ({ 37 | url: createPath(state.routing.location), 38 | }), dispatch => ({ 39 | back: () => dispatch(actions.goBack()), 40 | forward: () => dispatch(actions.goForward()), 41 | reset: () => dispatch(actions.push('/')), 42 | }))(Browser) 43 | -------------------------------------------------------------------------------- /docs/src/examples/index.ts: -------------------------------------------------------------------------------- 1 | import raw from 'raw.macro' 2 | import Basic, { reducer } from './Basic' 3 | import Loading, { reducer as loadingReducer } from './Loading' 4 | import SubRoutes, { reducer as subroutesReducer } from './SubRoutes' 5 | import StateChanges, { reducer as statechangesReducer } from './StateChanges' 6 | import Typescript, { reducer as typescriptReducer } from './Typescript' 7 | 8 | const BasicSource = raw('./Basic.jsx') 9 | const LoadingSource = raw('./Loading.jsx') 10 | const SubRoutesSource = raw('./SubRoutes.jsx') 11 | const StateChangesSource = raw('./StateChanges.jsx') 12 | const TypescriptSource = raw('./Typescript.tsx') 13 | 14 | const examples: { 15 | [key: string]: { 16 | source: string 17 | component: any 18 | reducer: any 19 | name: string 20 | lang: 'jsx' | 'tsx' 21 | } 22 | } = { 23 | basic: { 24 | source: BasicSource, 25 | component: Basic, 26 | reducer, 27 | name: 'Basic Example', 28 | lang: 'jsx', 29 | }, 30 | loading: { 31 | source: LoadingSource, 32 | component: Loading, 33 | reducer: loadingReducer, 34 | name: 'Loading Component', 35 | lang: 'jsx', 36 | }, 37 | subroutes: { 38 | source: SubRoutesSource, 39 | component: SubRoutes, 40 | reducer: subroutesReducer, 41 | name: 'Dynamic Sub-routes', 42 | lang: 'jsx', 43 | }, 44 | statechanges: { 45 | source: StateChangesSource, 46 | component: StateChanges, 47 | reducer: statechangesReducer, 48 | name: 'Redux State Changes URL', 49 | lang: 'jsx', 50 | }, 51 | typescript: { 52 | source: TypescriptSource, 53 | component: Typescript, 54 | reducer: typescriptReducer, 55 | name: 'Typescript example', 56 | lang: 'tsx', 57 | }, 58 | } 59 | 60 | export default examples 61 | -------------------------------------------------------------------------------- /docs/src/examples/Loading.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import PropTypes from 'prop-types' 4 | import Routes from 'ion-router/Routes' 5 | import Route from 'ion-router/Route' 6 | import Toggle from 'ion-router/Toggle' 7 | 8 | const AsyncToggle = Toggle(() => true, state => { 9 | return !state.async.loading 10 | }) 11 | 12 | export const reducer = { 13 | async: (state = { loading: false, thing: '' }, action) => { 14 | if (!action || !action.type) return state 15 | switch (action.type) { 16 | case 'START_LOAD': 17 | return { 18 | ...state, 19 | loading: true 20 | } 21 | case 'SET_THING': 22 | return { 23 | ...state, 24 | loading: false, 25 | thing: action.payload 26 | } 27 | default: 28 | return state 29 | } 30 | } 31 | } 32 | 33 | const Thing = connect(state => ({ thing: state.async.thing }))(({ thing }) => ( 34 |
This is the loaded thing: {thing}
35 | )) 36 | 37 | function Loading() { 38 | return ( 39 |
LOADING...
40 | ) 41 | } 42 | 43 | function LoadingDemo(props) { 44 | return ( 45 |
46 |

This example demonstrates using a loading component

47 | 48 | 49 | 50 | 51 | 52 |
53 | ) 54 | } 55 | 56 | LoadingDemo.propTypes = { 57 | load: PropTypes.func.isRequired 58 | } 59 | 60 | export default connect(undefined, dispatch => ({ 61 | load: () => { 62 | // fake loading from an asychronous source using setTimeout 63 | dispatch({ type: 'START_LOAD', payload: true }) 64 | setTimeout(() => dispatch({ type: 'SET_THING', payload: '"I loaded the thing!"' }), 1000) 65 | } 66 | }))(LoadingDemo) 67 | -------------------------------------------------------------------------------- /src/NullComponent.tsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent, useRef } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import DisplaysChildren from './DisplaysChildren' 5 | 6 | interface NullComponentProps { 7 | '@@__loaded': boolean 8 | '@@__isActive': boolean 9 | } 10 | export default function NullComponent( 11 | Loading: React.FC, 12 | Component: React.FC, 13 | ElseComponent: React.FC, 14 | Wrapper: React.FC, 15 | wrapperProps: WrapperProps, 16 | debug: boolean, 17 | cons: typeof window['console'] = console 18 | ) { 19 | const Toggle = (props: NullComponentProps) => { 20 | const rendered = useRef(0) 21 | rendered.current++ 22 | 23 | const { 24 | '@@__loaded': loadedProp, 25 | '@@__isActive': activeProp, 26 | ...nullProps 27 | } = props 28 | if (debug) { 29 | cons.log(`Toggle: loaded: ${loadedProp}, active: ${activeProp}`) 30 | cons.log( 31 | 'Loading component', 32 | Loading, 33 | 'Component', 34 | Component, 35 | 'Else', 36 | ElseComponent 37 | ) 38 | } 39 | if (Wrapper === ((DisplaysChildren as unknown) as React.FC)) { 40 | return !loadedProp ? ( 41 | 42 | ) : activeProp ? ( 43 | 44 | ) : ( 45 | 46 | ) 47 | } 48 | return ( 49 | 50 | {!loadedProp ? ( 51 | 52 | ) : activeProp ? ( 53 | 54 | ) : ( 55 | 56 | )} 57 | 58 | ) 59 | } 60 | 61 | Toggle.propTypes = { 62 | '@@__loaded': PropTypes.bool, 63 | '@@__isActive': PropTypes.bool, 64 | } 65 | 66 | return React.memo(Toggle) 67 | } 68 | -------------------------------------------------------------------------------- /docs/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ion-router: powerful routing with redux and react 6 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/type-tests/index.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectType, expectError } from 'tsd' 2 | import { AnyAction } from 'redux' 3 | import makeRouter, { 4 | synchronousMakeRoutes, 5 | FullStateWithRouter, 6 | IonRouterActions, 7 | DeclareRoute, 8 | IonRouterState, 9 | } from '..' 10 | 11 | interface MyState { 12 | foo: 'ok' | '' 13 | } 14 | 15 | synchronousMakeRoutes( 16 | [ 17 | { 18 | name: 'index' as const, 19 | path: '/', 20 | parent: '', 21 | }, 22 | { 23 | name: 'two' as const, 24 | path: '/:second', 25 | parent: '', 26 | stateFromParams: ({ second }: { second: string }) => { 27 | return { second: second === 'ok' ? ('ok' as const) : ('' as const) } 28 | }, 29 | paramsFromState: ({ foo }) => ({ second: foo }), 30 | updateState: { 31 | second: second => ({ type: 'changeFoo', foo: second }), 32 | }, 33 | }, 34 | ], 35 | { 36 | isServer: false, 37 | enhancedRoutes: {}, 38 | } 39 | ) 40 | 41 | synchronousMakeRoutes< 42 | FullStateWithRouter & MyState, 43 | { type: 'changeFoo'; foo: 'ok' | '' } | IonRouterActions, 44 | [ 45 | DeclareRoute, 46 | DeclareRoute< 47 | MyState & { routing: IonRouterState }, 48 | { second: string }, 49 | { second: 'ok' | '' }, 50 | { type: 'changeFoo'; foo: 'ok' | '' } | IonRouterActions 51 | > 52 | ] 53 | >( 54 | [ 55 | { 56 | name: 'index' as const, 57 | path: '/', 58 | parent: '', 59 | }, 60 | { 61 | name: 'two' as const, 62 | path: '/:second', 63 | parent: '', 64 | stateFromParams: ({ second }: { second: string }) => { 65 | return { second: second === 'ok' ? ('ok' as const) : ('' as const) } 66 | }, 67 | paramsFromState: ({ foo }) => ({ second: foo }), 68 | updateState: { 69 | second: second => ({ type: 'changeFoo' as const, foo: second }), 70 | }, 71 | }, 72 | ], 73 | { 74 | isServer: false, 75 | enhancedRoutes: {}, 76 | } 77 | ) 78 | -------------------------------------------------------------------------------- /src/storeEnhancer.ts: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory, History } from 'history' 2 | import invariant from 'invariant' 3 | import { 4 | compose, 5 | Store, 6 | Action, 7 | Reducer, 8 | StoreEnhancerStoreCreator, 9 | PreloadedState, 10 | } from 'redux' 11 | 12 | import middleware, { actionHandlers } from './middleware' 13 | import { EnhancedRoutes } from './enhancers' 14 | import { IonRouterState } from './reducer' 15 | import { IonRouterActions } from './actions' 16 | 17 | export interface IonRouterOptions { 18 | routerOptions: { 19 | [key: string]: any 20 | isServer: boolean 21 | enhancedRoutes: EnhancedRoutes 22 | } 23 | } 24 | 25 | export function assertEnhancedStore>( 26 | store: any 27 | ): asserts store is Store & IonRouterOptions { 28 | invariant( 29 | store.routerOptions, 30 | 'ion-router error: store has not been initialized. Did you ' + 31 | 'use the store enhancer?' 32 | ) 33 | } 34 | 35 | const enhancer = ( 36 | history: History = createBrowserHistory(), 37 | handlers = actionHandlers, 38 | debug = false, 39 | options = {} 40 | ) => (createStore: StoreEnhancerStoreCreator) => >( 41 | reducer: Reducer, 42 | preloadedState: PreloadedState | undefined 43 | ) => { 44 | const store = { 45 | ...createStore(reducer, preloadedState), 46 | routerOptions: { 47 | isServer: false, 48 | enhancedRoutes: {}, 49 | ...options, 50 | }, 51 | } 52 | 53 | const newDispatch = compose(middleware(history, handlers, debug)(store))( 54 | store.dispatch as any 55 | ) 56 | ;((store as unknown) as Store< 57 | S & IonRouterState, 58 | (typeof store extends Store ? Act : never) & 59 | IonRouterActions 60 | > & 61 | IonRouterOptions).dispatch = newDispatch 62 | return (store as unknown) as Store< 63 | S & IonRouterState, 64 | (typeof store extends Store ? Act : never) & 65 | IonRouterActions 66 | > & 67 | IonRouterOptions 68 | } 69 | 70 | export default enhancer 71 | -------------------------------------------------------------------------------- /src/selectors.ts: -------------------------------------------------------------------------------- 1 | import { IonRouterState } from './reducer' 2 | 3 | export interface FullStateWithRouter { 4 | [stateSlice: string]: any 5 | routing: IonRouterState 6 | } 7 | 8 | export function matchedRoute( 9 | state: FullStateWithRouter, 10 | name: string | string[], 11 | strict = false 12 | ) { 13 | if (Array.isArray(name)) { 14 | const matches = state.routing.matchedRoutes.filter(route => 15 | name.includes(route) 16 | ) 17 | if (strict) return matches.length === name.length 18 | return !!matches.length 19 | } 20 | return state.routing.matchedRoutes.includes(name) 21 | } 22 | 23 | export function noMatches(state: FullStateWithRouter) { 24 | return state.routing.matchedRoutes.length === 0 25 | } 26 | 27 | export function oldState(state: FullStateWithRouter, route: string) { 28 | return state.routing.routes.routes[route].state 29 | } 30 | 31 | export function oldParams(state: FullStateWithRouter, route: string) { 32 | return state.routing.routes.routes[route].params 33 | } 34 | 35 | export function matchedRoutes(state: FullStateWithRouter) { 36 | return state.routing.matchedRoutes 37 | } 38 | 39 | export function location(state: FullStateWithRouter) { 40 | return state.routing.location 41 | } 42 | 43 | export function stateExists( 44 | state: { [key: string]: any }, 45 | template: { [key: string]: any }, 46 | fullState: { [key: string]: any } | undefined = undefined 47 | ): boolean { 48 | const full = fullState || state 49 | const keys = Object.keys(template) 50 | return keys.reduce((valid, key) => { 51 | if (!valid) return false 52 | if (template[key] instanceof Function) { 53 | return template[key](state[key], full) 54 | } 55 | switch (typeof template[key]) { 56 | case 'object': 57 | if (template[key] === null) { 58 | return state[key] === null 59 | } 60 | if (Array.isArray(template[key])) { 61 | return Array.isArray(state[key]) 62 | } 63 | if (state[key] === undefined && template[key] !== undefined) { 64 | return false 65 | } 66 | return stateExists(state[key], template[key], full) 67 | default: 68 | return typeof template[key] === typeof state[key] 69 | } 70 | }, true) 71 | } 72 | -------------------------------------------------------------------------------- /docs/src/components/Example.jsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { createMemoryHistory } from 'history' 4 | import { createStore, combineReducers, compose } from 'redux' 5 | import { connect, Provider } from 'react-redux' 6 | import makeRouter, { makeRouterStoreEnhancer } from 'ion-router' 7 | import Routes from 'ion-router/Routes' 8 | import routing from 'ion-router/reducer' 9 | 10 | import Browser from './Browser' 11 | import ShowSource from './ShowSource' 12 | import examples from '../examples' 13 | import Window from './Window' 14 | 15 | function Example({ example }) { 16 | const init = () => { 17 | const history = createMemoryHistory({ 18 | initialEntries: ['/'] 19 | }) 20 | const reducer = combineReducers({ 21 | ...examples[example].reducer, 22 | routing 23 | }) 24 | 25 | // set up the router and create the store 26 | const enhancer = makeRouterStoreEnhancer(history) 27 | 28 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ? // eslint-disable-line 29 | window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ name: `examples: ${example}` }) // eslint-disable-line 30 | : compose 31 | const newStore = createStore(reducer, undefined, 32 | composeEnhancers(enhancer)) 33 | makeRouter(connect, newStore) 34 | return newStore 35 | } 36 | const store = useRef(init()) 37 | store.current = init() 38 | const [showBrowser, setShowBrowser] = useState(true) 39 | 40 | return 42 |
43 | 44 | 45 | {showBrowser ? 46 | setShowBrowser(false)} 50 | /> :
} 51 | 52 | 53 |
54 |
55 | 56 | 57 |
58 |
59 | } /> 60 | } 61 | 62 | Example.propTypes = { 63 | example: PropTypes.string 64 | } 65 | 66 | export default Example 67 | -------------------------------------------------------------------------------- /run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | export NODE_ENV=test 3 | ./node_modules/karma/bin/karma start test/karma/karma.edge.conf.js 4 | ./node_modules/karma/bin/karma start test/karma/karma.ie10.conf.js 5 | ./node_modules/karma/bin/karma start test/karma/karma.ie11.conf.js 6 | ./node_modules/karma/bin/karma start test/karma/karma.iphone5s.conf.js 7 | ./node_modules/karma/bin/karma start test/karma/karma.iphone6.conf.js 8 | ./node_modules/karma/bin/karma start test/karma/karma.iphone6plus.conf.js 9 | #./node_modules/karma/bin/karma start test/karma/karma.android4.1.conf.js 10 | ./node_modules/karma/bin/karma start test/karma/karma.android4.2.conf.js 11 | ./node_modules/karma/bin/karma start test/karma/karma.android4.3.conf.js 12 | ./node_modules/karma/bin/karma start test/karma/karma.android4.4.conf.js 13 | #./node_modules/karma/bin/karma start test/karma/karma.android5.1.conf.js 14 | #./node_modules/karma/bin/karma start test/karma/karma.chrome43.conf.js 15 | #./node_modules/karma/bin/karma start test/karma/karma.chrome44.conf.js 16 | #./node_modules/karma/bin/karma start test/karma/karma.chrome45.conf.js 17 | #./node_modules/karma/bin/karma start test/karma/karma.chrome46.conf.js 18 | #./node_modules/karma/bin/karma start test/karma/karma.chrome47.conf.js 19 | #./node_modules/karma/bin/karma start test/karma/karma.chrome48.conf.js 20 | ./node_modules/karma/bin/karma start test/karma/karma.chrome49.conf.js 21 | ./node_modules/karma/bin/karma start test/karma/karma.chrome50.conf.js 22 | ./node_modules/karma/bin/karma start test/karma/karma.chrome50osx.conf.js 23 | #./node_modules/karma/bin/karma start test/karma/karma.firefox44.conf.js 24 | #./node_modules/karma/bin/karma start test/karma/karma.firefox45.conf.js 25 | ./node_modules/karma/bin/karma start test/karma/karma.firefox46.conf.js 26 | ./node_modules/karma/bin/karma start test/karma/karma.firefox46osx.conf.js 27 | ./node_modules/karma/bin/karma start test/karma/karma.opera.conf.js 28 | #./node_modules/karma/bin/karma start test/karma/karma.safari6.conf.js 29 | #./node_modules/karma/bin/karma start test/karma/karma.safari7.conf.js 30 | ./node_modules/karma/bin/karma start test/karma/karma.safari8.conf.js 31 | ./node_modules/karma/bin/karma start test/karma/karma.safari9.conf.js 32 | ./node_modules/.bin/lcov-result-merger './test/karma/coverage/**/lcov.info' 'lcov.info' 33 | ./node_modules/codeclimate-test-reporter/bin/codeclimate.js < lcov.info -------------------------------------------------------------------------------- /docs/src/examples/Basic.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import PropTypes from 'prop-types' 4 | import Routes from 'ion-router/Routes' 5 | import Route from 'ion-router/Route' 6 | import Toggle from 'ion-router/Toggle' 7 | import RouteToggle from 'ion-router/RouteToggle' 8 | import Link from 'ion-router/Link' 9 | 10 | const HiThereToggle = RouteToggle('hithere') 11 | const ThereToggle = Toggle(state => state.there === 'Greg') 12 | 13 | export const reducer = { 14 | there: (state = '', action) => { 15 | if (!action || !action.type) return state 16 | if (action.type === 'UPDATE_THERE') return action.payload || '' 17 | return state 18 | } 19 | } 20 | 21 | function Basic(props) { 22 | return ( 23 |
24 |

25 | This simple example demonstrates using Link, 26 | Routes, Route, and two toggles, 27 | RouteToggle and Toggle. 28 |

29 |
    30 |
  • Home
  • 31 |
  • 32 | Hi to Somebody 33 |
  • 34 |
  • Hi to Greg
  • 35 |
  • 36 | Change value of there in store: 37 | props.change(e.target.value)} /> 38 |
  • 39 |
40 | ( 42 |
43 | Hi {props.there}! 44 | ( 46 |
It's Greg!!
47 | )} 48 | /> 49 |
50 | )} 51 | else={() => ( 52 |
53 | Home 54 |
55 | )} 56 | /> 57 | 58 | 59 | params} 63 | paramsFromState={state => state} 64 | updateState={{ 65 | there: t => ({ type: 'UPDATE_THERE', payload: t }) 66 | }} 67 | /> 68 | 69 |
70 | ) 71 | } 72 | 73 | Basic.propTypes = { 74 | there: PropTypes.string 75 | } 76 | 77 | export default connect(state => state, dispatch => ({ 78 | change: there => dispatch({ type: 'UPDATE_THERE', payload: there }) 79 | }))(Basic) 80 | -------------------------------------------------------------------------------- /src/Route.tsx: -------------------------------------------------------------------------------- 1 | import React, { Children, useContext, useRef } from 'react' 2 | import Context, { RouterContext } from './Context' 3 | import { DeclareRoute } from './enhancers' 4 | import { FullStateWithRouter } from './selectors' 5 | 6 | export function fakeRouteHelper() { 7 | return {} 8 | } 9 | 10 | export interface Props< 11 | ReduxState extends FullStateWithRouter, 12 | Params extends { [key: string]: string }, 13 | ParamsState extends { [key: string]: any }, 14 | Action extends { type: string; [key: string]: any } 15 | > { 16 | name: string 17 | path: string 18 | paramsFromState?: DeclareRoute< 19 | ReduxState, 20 | Params, 21 | ParamsState, 22 | Action 23 | >['paramsFromState'] 24 | stateFromParams?: DeclareRoute< 25 | ReduxState, 26 | Params, 27 | ParamsState, 28 | Action 29 | >['stateFromParams'] 30 | updateState?: DeclareRoute< 31 | ReduxState, 32 | Params, 33 | ParamsState, 34 | Action 35 | >['updateState'] 36 | parentUrl?: string 37 | parent?: string 38 | children?: React.ReactElement 39 | } 40 | 41 | const defaultProps = { 42 | paramsFromState: fakeRouteHelper, 43 | stateFromParams: fakeRouteHelper, 44 | updateState: {}, 45 | } 46 | 47 | export function Route< 48 | ReduxState extends FullStateWithRouter, 49 | Params extends { [key: string]: string }, 50 | ParamsState extends { [key: string]: any }, 51 | Action extends { type: string; [key: string]: any } 52 | >(props: Props) { 53 | const info = useContext(Context) 54 | const added = useRef(false) 55 | const { parent, parentUrl, children, ...params } = props 56 | let url = parentUrl 57 | if (parent && info && info.routes && info.routes[parent]) { 58 | url = info.routes[parent].path 59 | } 60 | const slash = url && url[url.length - 1] === '/' ? '' : '/' 61 | const path = url ? `${url}${slash}${params.path}` : params.path 62 | if (!added.current && info) { 63 | info.addRoute({ 64 | ...defaultProps, 65 | ...params, 66 | parent, 67 | path, 68 | } as DeclareRoute) 69 | added.current = true 70 | } 71 | 72 | return ( 73 |
74 | {children && 75 | Children.map(children, child => 76 | React.cloneElement(child, { 77 | parentUrl: path, 78 | }) 79 | )} 80 |
81 | ) 82 | } 83 | 84 | export default Route 85 | -------------------------------------------------------------------------------- /docs/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /docs/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { Routes, Route, Link } from 'ion-router' 3 | import { scaleRotate as Menu } from 'react-burger-menu' 4 | import marked from 'marked' 5 | import raw from 'raw.macro' 6 | 7 | import './App.css' 8 | import Examples from './components/Examples' 9 | import MarkdownViewer from './components/MarkdownViewer' 10 | import ExamplesToggle from './toggles/ExamplesToggle' 11 | import * as actions from './redux/actions' 12 | import examples from './examples' 13 | 14 | const test = marked(raw('../md/README.md')) 15 | ;(Routes as React.FC).displayName = 'FancyRoutes' 16 | ;(Link as React.FC).displayName = 'FancyLink' 17 | 18 | function App() { 19 | const [open, setMenuOpen] = useState(false) 20 | return ( 21 |
22 | setMenuOpen(isOpen)} 27 | > 28 | setMenuOpen(false)}> 29 | Home 30 | 31 | setMenuOpen(false)} 35 | > 36 | Examples 37 | 38 |
    39 | {Object.keys(examples).map(example => ( 40 |
  • 41 | setMenuOpen(false)} 46 | > 47 | {examples[example].name} 48 | 49 |
  • 50 | ))} 51 |
52 |
53 |
54 |
55 |
56 |

ion-router

57 |
58 |
59 | } 62 | /> 63 |
64 |
65 |
66 | 67 | 68 | params} 72 | paramsFromState={state => state} 73 | updateState={{ 74 | example: name => actions.chooseExample(name), 75 | }} 76 | /> 77 | 78 | 79 |
80 | ) 81 | } 82 | 83 | export default App 84 | -------------------------------------------------------------------------------- /docs/src/examples/Typescript.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Dispatch } from 'redux' 3 | import { useDispatch } from 'react-redux' 4 | import { 5 | Routes, 6 | Route, 7 | Toggle, 8 | RouteToggle, 9 | Link, 10 | FullStateWithRouter, 11 | } from 'ion-router' 12 | 13 | interface StoreState { 14 | there: string 15 | } 16 | 17 | interface ChangeAction { 18 | type: 'UPDATE_THERE' 19 | payload: string 20 | } 21 | 22 | const HiThereToggle = RouteToggle('hithere') 23 | const ThereToggle = Toggle( 24 | (state: StoreState & FullStateWithRouter) => state.there === 'Greg' 25 | ) 26 | 27 | export const reducer = { 28 | there: (state = '', action: ChangeAction) => { 29 | if (!action || !action.type) return state 30 | if (action.type === 'UPDATE_THERE') return action.payload || '' 31 | return state 32 | }, 33 | } 34 | 35 | export default function Typescript(props: StoreState) { 36 | const dispatch = useDispatch>() 37 | const change = (newThere: string) => 38 | dispatch({ 39 | type: 'UPDATE_THERE', 40 | payload: newThere, 41 | }) 42 | return ( 43 |
44 |

45 | This simple example demonstrates using Link, 46 | Routes, Route, and two toggles, 47 | RouteToggle and Toggle. 48 |

49 |
    50 |
  • 51 | Home 52 |
  • 53 |
  • 54 | 55 | Hi to Somebody 56 | 57 |
  • 58 |
  • 59 | 60 | Hi to Greg 61 | 62 |
  • 63 |
  • 64 | Change value of there in store: 65 | change(e.target.value)} /> 66 |
  • 67 |
68 | ( 70 |
71 | Hi {props.there}! 72 |
It's Greg!!
} /> 73 |
74 | )} 75 | else={() =>
Home
} 76 | /> 77 | 78 | 79 | params} 83 | paramsFromState={state => ({ there: state.there })} 84 | updateState={{ 85 | there: (t): ChangeAction => ({ type: 'UPDATE_THERE', payload: t }), 86 | }} 87 | /> 88 | 89 |
90 | ) 91 | } 92 | -------------------------------------------------------------------------------- /src/Routes.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef, useCallback } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import * as actions from './actions' 5 | import { useStore, useDispatch, useSelector } from 'react-redux' 6 | import Context, { RouterContext } from './Context' 7 | import { FullStateWithRouter } from './selectors' 8 | import { IonRouterOptions } from './storeEnhancer' 9 | import { DeclareRoute } from './enhancers' 10 | import { Store } from 'redux' 11 | import { IonRouterState } from './reducer' 12 | 13 | export function Routes< 14 | ReduxState extends FullStateWithRouter, 15 | Params extends { [key: string]: string }, 16 | ParamsState extends { [key: string]: any }, 17 | Action extends { type: string; [key: string]: any } 18 | >({ children }: { children: React.ReactNode }) { 19 | const myRoutes = useRef< 20 | DeclareRoute[] 21 | >([]) 22 | const store: Store & IonRouterOptions = useStore< 23 | FullStateWithRouter 24 | >() as Store & IonRouterOptions 25 | const dispatch = useDispatch() 26 | const routes = useSelector< 27 | FullStateWithRouter, 28 | IonRouterState['routes']['routes'] 29 | >(state => state.routing.routes.routes) 30 | 31 | const addRoute = useCallback( 32 | (route: DeclareRoute) => { 33 | myRoutes.current.push(route) 34 | if (store.routerOptions.isServer) { 35 | dispatch(actions.addRoute(route)) 36 | } 37 | }, 38 | [dispatch, store] 39 | ) 40 | 41 | const [value, setValue] = useState({ 42 | dispatch, 43 | routes, 44 | addRoute, 45 | store, 46 | } as RouterContext) 47 | 48 | useEffect(() => { 49 | setValue({ 50 | dispatch, 51 | routes, 52 | addRoute, 53 | store, 54 | } as RouterContext) 55 | }, [dispatch, routes, store, addRoute]) 56 | 57 | useEffect(() => { 58 | if (myRoutes.current.length) { 59 | dispatch( 60 | actions.batchRoutes( 61 | myRoutes.current as DeclareRoute[] 62 | ) 63 | ) 64 | } 65 | return () => { 66 | if (myRoutes.current.length) { 67 | dispatch( 68 | actions.batchRemoveRoutes( 69 | myRoutes.current as DeclareRoute< 70 | FullStateWithRouter, 71 | any, 72 | any, 73 | any 74 | >[] 75 | ) 76 | ) 77 | } 78 | } 79 | }, [myRoutes.current]) 80 | 81 | return {children} 82 | } 83 | 84 | Routes.propTypes = { 85 | children: PropTypes.any, 86 | } 87 | 88 | export default Routes 89 | -------------------------------------------------------------------------------- /test/test_helper.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import * as rtl from '@testing-library/react' 3 | import '@testing-library/jest-dom/extend-expect' 4 | 5 | import { Provider, connect } from 'react-redux' 6 | import { 7 | createStore, 8 | combineReducers, 9 | applyMiddleware, 10 | compose, 11 | Middleware, 12 | AnyAction, 13 | Store, 14 | } from 'redux' 15 | import createHistory from 'history/createMemoryHistory' 16 | 17 | import reducer from '../src/reducer' 18 | import storeEnhancer, { IonRouterOptions } from '../src/storeEnhancer' 19 | import { FullStateWithRouter, IonRouterActions } from '../src' 20 | 21 | const fakeWeekReducer = (state = 1) => state 22 | 23 | function sagaStore( 24 | state: S, 25 | reducers = { routing: reducer, week: fakeWeekReducer }, 26 | middleware: Middleware[] = [], 27 | enhancer = storeEnhancer( 28 | createHistory({ 29 | initialEntries: ['/'], 30 | }) 31 | ) 32 | ) { 33 | const log: (IonRouterActions | AnyAction)[] = [] 34 | const logger: Middleware = _store => next => action => { 35 | log.push(action) 36 | return next(action) 37 | } 38 | 39 | const store = createStore( 40 | combineReducers(reducers), 41 | state, 42 | compose(enhancer as any, applyMiddleware(...middleware, logger)) 43 | ) 44 | return { 45 | log, 46 | store: (store as unknown) as Store & IonRouterOptions, 47 | } 48 | } 49 | 50 | function renderComponent( 51 | ComponentClass, 52 | props = {}, 53 | state = undefined, 54 | returnStore = false, 55 | mySagaStore = sagaStore(state), 56 | intoDocument: false | HTMLElement = false 57 | ) { 58 | class Tester extends Component { 59 | constructor(props) { 60 | super(props) 61 | this.state = props 62 | } 63 | componentDidUpdate(props) { 64 | if (props !== this.props) { 65 | this.setState(this.props) 66 | } 67 | } 68 | render() { 69 | return ( 70 | 71 | 72 | 73 | ) 74 | } 75 | } 76 | let ret 77 | rtl.act(() => { 78 | ret = rtl.render( 79 | , 80 | intoDocument ? { container: intoDocument } : undefined 81 | ) 82 | }) 83 | const { rerender } = ret 84 | ret.rerender = newProps => { 85 | rtl.act(() => { 86 | rerender( 87 | , 88 | intoDocument ? { container: intoDocument } : undefined 89 | ) 90 | }) 91 | } 92 | if (returnStore) { 93 | return [ret, mySagaStore.store, mySagaStore.log] 94 | } 95 | return ret 96 | } 97 | 98 | export { renderComponent, connect, sagaStore } // eslint-disable-line 99 | -------------------------------------------------------------------------------- /docs/src/examples/SubRoutes.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import PropTypes from 'prop-types' 4 | import Routes from 'ion-router/Routes' 5 | import Route from 'ion-router/Route' 6 | import RouteToggle from 'ion-router/RouteToggle' 7 | import Link from 'ion-router/Link' 8 | 9 | export const reducer = { 10 | subroutes: (state = false, action) => { 11 | if (!action || !action.type) return state 12 | if (action.type === 'SUBROUTE') { 13 | return action.payload 14 | } 15 | return state 16 | } 17 | } 18 | 19 | const ThingToggle = RouteToggle('things') 20 | const SubthingToggle = RouteToggle('subthing-things') 21 | 22 | const Null = ({ load }) =>
23 | ( 25 | 26 | )} 27 | else={() => ( 28 | Click here first 29 | )} 30 | /> 31 |
32 | 33 | Null.propTypes = { 34 | load: PropTypes.func.isRequired 35 | } 36 | 37 | function Subthings({ parentRoute, unload }) { 38 | return ( 39 |
40 |

This is the loaded child

41 | 42 | Show new dynamic route 43 | ( 45 |
46 | This is the subroute dynamic route 47 |
48 | )} 49 | /> 50 | 51 | 52 | 53 |
54 | ) 55 | } 56 | 57 | Subthings.propTypes = { 58 | parentRoute: PropTypes.string.isRequired, 59 | unload: PropTypes.func.isRequired 60 | } 61 | 62 | 63 | function SubRoutes({ load, subthings, routes, unload }) { 64 | const Sub = subthings 65 | return ( 66 |
67 |

This example demonstrates asynchronous loading of a sub-module with dynamic routes

68 |

Declared routes:

69 |
    70 | {routes.map(route =>
  • {route}
  • )} 71 |
72 | 73 | 74 | 75 | 76 | 77 | 78 |
79 | ) 80 | } 81 | 82 | SubRoutes.propTypes = { 83 | load: PropTypes.func.isRequired, 84 | unload: PropTypes.func.isRequired, 85 | subthings: PropTypes.any.isRequired, 86 | routes: PropTypes.array.isRequired, 87 | } 88 | 89 | export default connect(state => ({ 90 | subthings: state.subroutes ? Subthings : Null, 91 | routes: state.routing.routes.ids.map(id => `${id} (${state.routing.routes.routes[id].path})`) 92 | }), dispatch => ({ 93 | load: () => dispatch({ type: 'SUBROUTE', payload: true }), 94 | unload: () => dispatch({ type: 'SUBROUTE', payload: false }), 95 | }))(SubRoutes) 96 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - node 4 | before_script: 5 | - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 6 | - chmod +x ./cc-test-reporter 7 | - ./cc-test-reporter before-build 8 | script: 9 | - npm run build 10 | - npm run lint 11 | - npm test 12 | after_script: 13 | - ./cc-test-reporter upload-coverage coverage/lcov.info 14 | - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT 15 | addons: 16 | code_climate: 17 | repo_token: 397a76c7a85f125871038a8628f6db16f4ce916e74c2dcfeea3f21364f946f7a 18 | browserstack: 19 | username: gregorybeaver1 20 | access_key: 21 | secure: xiqQi/vfYMDTCocRW0gSpoZLJR0nt+YVkxkwK9VTDvxJNWjKZ4UwBL8SukVE12JeQ/9IzpGwYeCXFkUl7tuEW5OcQmS6E/oPoDsxbKu0nebWzA3WWByTZGmTMyg2hziYIEsXvV4lYkliSfOxtssrjcgXl8vJXBgBXCHebEshZ5h4n6qnULPumpqHvb0cs5j+fhuHBo5u/Kk7f82LjYyhVYrsTxEIx7ippszlKYr3pX9MhCuaCAnimQO1ki787YcIqFQYfFlffFl5L13kNSkCIsgyblOwuC1MclgXLerfgaDAMfSTCP1haFfvmDmVpGPoHCX8rwLg2KsJPNRb7tG34C4MFkA/nzbFRiaN/vul791wGPNYJzfl91uNxflIsMkajX5SQNaM3BOCxaC8JBgICfNLer8qkb19qAHrEJw+J/FK+2FTg/jOjFCL4U7ZnTxvkG0Mja5LG9iZjZu1vxGhRSchGhmPJLLQ+7dyJfbLchqR4iTjxa2iddy3lUrn3Sjq9WZiVAlSrxZlLAHLnJItfjAiGTX1rjXx55xTIwlvJxsLnJxexf7V3P0mvYMr1yO9p9oi4ZsXXhYRX/t4t55nT5rPbaoNl69bfZ945gKeLZ8U5vwksMESSn0QXj8xJbqRnCPUoWPuRkCJE26cduVTv1g391laU9x/dmBONL1Vb9I= 22 | env: 23 | global: 24 | - CC_TEST_REPORTER_ID=397a76c7a85f125871038a8628f6db16f4ce916e74c2dcfeea3f21364f946f7a 25 | - BS_AUTOMATE_PROJECT="$TRAVIS_REPO_SLUG" 26 | - BS_AUTOMATE_BUILD="Travis build No. $TRAVIS_BUILD_NUMBER for $TRAVIS_REPO_SLUG" 27 | - secure: JVMt/12KHonj87WiUxTK+WCU8eV1bWnd6IG1qSHU5VyDvuGdj+Ls8ACKiu7EsUfh0BGHGhn8/BNDIr5ZRqcKcFKpl4rkawFIO2BeMKeEIve8mwTLv+5g1RPavoU96XIepFnGhnBGlp/2wlP850JFv7ScczrSaIqanTz9HuSvSoyF5IgkNi4BxoyxCAZKUU1Rqvpc6Ybq38qjr4yyEj3gAoWRBM9UMfLtz3uD7KRRhAnX9F3LKbyuxoRPfH/iA/F9Ii9ZA1d+BX8mdHKPwCfyMmUIemvl9xcstjYZ50t4YikkmNbbzjBmXm6pRvVaNYHqSTcStfgXqgsVTxLx6GYkOzPd0J/83pSdcI+COL/tPfJ2ufIkampSCPrii0inCSei1OfwudJnBM7hk69AbdTXaiW0H5LdU1Y6GhUpmO4aSVQpfbmIkZBXrB6xwyfrYrRlxQmSOs+U6ginXaS7G2bC0I6xvqp4mo/WNI4pjemRkG0cO4HZYKLdkr8YXj8WProXgPMEou+A0cSP/4g+EP072smKIH96IvpOeNAKIH/3I/cOq5f3TWe4c5NqSc6TREdDrGpMKGEfoC/GjYB7k/Km7ScFKem7JUUDezElDakuOpeqcZ1aVppKIT5yKSk/yyxRo5aI4NuLS66zwCiE11lgTxKOUvUQD9Dz7XOf0HMIhXI= 28 | - secure: KwGW1nE7EcAzrlM1wMIpBOe2gRqqni8fkAPI5f74hswnyjgfhaTcq0OHBCzWkDF77k+8uTA9g4gtg1wlM/ReW3EkaPQyvp2NZuJyl6DEPVWhEHKOcNu2LwlZ/rI/y+fCtB4iU1132/LOnC5tHEZeeLUC+mekSORreZLkoSSU/shY5hg01VRJMvcFAAVT2WvZvf67LTcABqsIAiDvgsDlUoFZ9lekykaswZItfY1S1OpkwIMAVYCwmP2IHvR2/WbQwrOEk6OarCzBX1jrJdMJ7BfctZuFKPf/sgTZq8/CskIzIBsiLGOoIFAX2eTIXIQJBb7e1+CN9HCV/17qn1bKKZR8YBeMgxdvYWLq+LjizGhOJdjpwOioa0G3EE8G4EXRWdwF6Pz7xhvye37COPF0hqqOEOvv5tjAxpqiKw8Iuj2Dw1xsceqwL8QHrkeGmf4La2w94Wz+OR5oX0WXb6r/wIV0XqXWYtg0ziYrle2tSDmeoLOQdsimLidjdfulZJJUvs3ZH90JyfMm6uMTtrDCDYH+1WHjrplHpafDO/G/dX5BprHSj9Fs9xC8kpMe9kXQ7RIfIQN0AsHy1BHSg1hDF4YZDmZDsw1FR7ZuHvlEY/YZbZ+3xmAvieZOlL6fwWMLAAO/ZuJHFq/4NJRTRe3nMpl6CefI7FYFJ8TCsgzdMoM= 29 | -------------------------------------------------------------------------------- /docs/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | animation: App-logo-spin infinite 20s linear; 7 | height: 80px; 8 | } 9 | 10 | .App-header { 11 | background-color: #222; 12 | height: 10vh; 13 | min-height: 50px; 14 | padding: 20px; 15 | color: white; 16 | } 17 | 18 | .markdown-viewer { 19 | padding: 20px; 20 | height: 90vh; 21 | overflow-y: scroll; 22 | } 23 | 24 | .App-intro { 25 | font-size: large; 26 | text-align: left; 27 | } 28 | 29 | @keyframes App-logo-spin { 30 | from { transform: rotate(0deg); } 31 | to { transform: rotate(360deg); } 32 | } 33 | 34 | .browser { 35 | display: flex; 36 | flex-direction: column; 37 | border: 1px solid #dddddd; 38 | height: 100vh; 39 | width: 400px; 40 | border-radius: 5px; 41 | } 42 | 43 | .browser header { 44 | display: flex; 45 | flex-direction: row; 46 | padding: 10px; 47 | background-color: #dddddd; 48 | } 49 | 50 | .browser header input { 51 | flex-grow: 2; 52 | } 53 | 54 | .browser article { 55 | border: 2px ridge black; 56 | padding: 5px; 57 | width: 386px; 58 | overflow-y: scroll; 59 | flex-grow: 2; 60 | } 61 | 62 | .mobile-showsource { 63 | display: none; 64 | } 65 | 66 | @media (max-width: 500px) { 67 | .browser { 68 | width: 99vw; 69 | } 70 | .browser article { 71 | width: 95vw; 72 | } 73 | .mobile-showsource { 74 | display: block; 75 | } 76 | 77 | .example .source-panel .mobile-showsource { 78 | border: 1px solid black; 79 | } 80 | } 81 | 82 | .example { 83 | display: flex; 84 | flex-direction: row; 85 | } 86 | 87 | .example .source-panel { 88 | padding-left: 30px; 89 | overflow-y: scroll; 90 | height: 100vh; 91 | } 92 | 93 | /* Position and sizing of burger button */ 94 | .bm-burger-button { 95 | position: fixed; 96 | width: 36px; 97 | height: 30px; 98 | left: 36px; 99 | top: 36px; 100 | } 101 | 102 | /* Color/shape of burger icon bars */ 103 | .bm-burger-bars { 104 | background: white; /*#373a47;*/ 105 | } 106 | 107 | /* Position and sizing of clickable cross button */ 108 | .bm-cross-button { 109 | height: 24px; 110 | width: 24px; 111 | } 112 | 113 | /* Color/shape of close button cross */ 114 | .bm-cross { 115 | background: #bdc3c7; 116 | } 117 | 118 | /* General sidebar styles */ 119 | .bm-menu { 120 | background: #373a47; 121 | padding: 2.5em 1.5em 0; 122 | font-size: 1.15em; 123 | text-align: left; 124 | } 125 | 126 | /* Morph shape necessary with bubble or elastic */ 127 | .bm-morph-shape { 128 | fill: #373a47; 129 | } 130 | 131 | /* Wrapper for item list */ 132 | .bm-item-list { 133 | color: #b8b7ad; 134 | padding: 0.8em; 135 | } 136 | 137 | .bm-item-list li { 138 | list-style: none; 139 | } 140 | .bm-item-list ul { 141 | margin: 0; 142 | } 143 | 144 | /* Styling of overlay */ 145 | .bm-overlay { 146 | background: rgba(0, 0, 0, 0.3); 147 | } 148 | 149 | .menu-item { 150 | color: white; 151 | text-decoration: none; 152 | padding-bottom: 20px; 153 | } 154 | .menu-item:hover { 155 | color: red; 156 | } 157 | -------------------------------------------------------------------------------- /src/Toggle.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react' 2 | import { useSelector } from 'react-redux' 3 | 4 | import DisplaysChildren from './DisplaysChildren' 5 | import { FullStateWithRouter } from './selectors' 6 | 7 | export type ReduxSelector = < 8 | P extends { [key: string]: any } 9 | >( 10 | state: StoreState, 11 | props?: P 12 | ) => any 13 | 14 | export type LoadedSelector = < 15 | P extends { [key: string]: any } 16 | >( 17 | state: StoreState, 18 | props?: P 19 | ) => boolean 20 | 21 | export interface ToggleDefaults { 22 | component?: React.FC 23 | else?: React.FC 24 | loadingComponent?: React.FC 25 | } 26 | 27 | export interface MightDefineVars { 28 | [key: string]: any 29 | component?: any 30 | loadingComponent?: any 31 | else?: any 32 | } 33 | 34 | export interface ComponentLoadingMap { 35 | component?: keyof ExtraProps 36 | else?: keyof ExtraProps 37 | loadingComponent?: keyof ExtraProps 38 | } 39 | 40 | export type ToggleProps = ToggleDefaults & 41 | ExtraProps & { 42 | children?: React.ReactChild 43 | } 44 | 45 | function isKeyofExtraProps( 46 | key: any 47 | ): key is keyof ExtraProps { 48 | return !!key 49 | } 50 | 51 | const defaults: ToggleDefaults = { 52 | component: DisplaysChildren, 53 | else: () => null, 54 | loadingComponent: () => null, 55 | } 56 | defaults.else!.displayName = 'null' 57 | defaults.loadingComponent!.displayName = 'null' 58 | 59 | export default function OuterToggle< 60 | ExtraProps extends MightDefineVars, 61 | StoreState extends FullStateWithRouter 62 | >( 63 | selector: ReduxSelector, 64 | loaded: LoadedSelector = () => true, 65 | componentLoadingMap: ComponentLoadingMap = {} 66 | ) { 67 | return memo(function Toggle({ 68 | component: Component = defaults.component, 69 | else: ElseComponent = defaults.else, 70 | loadingComponent: LoadingComponent = defaults.loadingComponent, 71 | children, 72 | ...extra 73 | }: ToggleProps) { 74 | const map: (keyof ComponentLoadingMap)[] = [ 75 | 'component', 76 | 'loadingComponent', 77 | 'else', 78 | ] 79 | const useProps: ExtraProps = (extra as unknown) as ExtraProps 80 | map.forEach(item => { 81 | const key = componentLoadingMap[item] 82 | if (isKeyofExtraProps(key)) { 83 | useProps[item] = useProps[key] 84 | // no idea why this is required. Last resort of the desperate 85 | useProps[key] = (undefined as unknown) as ExtraProps[keyof ExtraProps] 86 | } 87 | }) 88 | 89 | const isLoaded = useSelector(state => loaded(state, useProps)) 90 | const toggleIsOn = useSelector(state => { 91 | if (!isLoaded) return false 92 | return selector(state, useProps) 93 | }) 94 | if (toggleIsOn) { 95 | if (!isLoaded) { 96 | return 97 | } 98 | return {children} 99 | } else { 100 | if (!isLoaded) { 101 | return 102 | } 103 | return 104 | } 105 | }) 106 | } 107 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ion-router", 3 | "version": "1.0.0-beta2", 4 | "description": "elegant powerful routing based on the simplicity of storing url as state", 5 | "main": "lib/index.js", 6 | "types": "lib/index.d.ts", 7 | "homepage": "https://cellog.github.io/ion-router", 8 | "directories": { 9 | "test": "tests" 10 | }, 11 | "scripts": { 12 | "build": "npm run clean && tsc && babel src --out-dir lib", 13 | "clean": "rimraf lib/", 14 | "local-test": "jest --watch --coverage ./test/*.test.*", 15 | "lint": "eslint --ext jsx --ext js src test", 16 | "test": "tsd && jest --coverage ./test/*.test.*", 17 | "release": "npm run build && npm publish" 18 | }, 19 | "tsd": { 20 | "directory": "src/type-tests" 21 | }, 22 | "jest": { 23 | "coverageDirectory": "./coverage" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/cellog/ion-router.git" 28 | }, 29 | "keywords": [ 30 | "redux", 31 | "react", 32 | "react-router", 33 | "route", 34 | "router", 35 | "routing" 36 | ], 37 | "author": "Gregory Beaver", 38 | "license": "MIT", 39 | "bugs": { 40 | "url": "https://github.com/cellog/ion-router/issues" 41 | }, 42 | "dependencies": { 43 | "history": "^4.5.1", 44 | "invariant": "^2.2.2", 45 | "route-parser": "0.0.5", 46 | "shallowequal": "^1.0.2" 47 | }, 48 | "peerDependencies": { 49 | "react": "^16.12.0", 50 | "prop-types": "^15.7.0", 51 | "redux": "^2.0.0 || ^3.0.0 || ^4.0.0", 52 | "react-dom": "^16.12.0", 53 | "react-redux": "^7.1.0" 54 | }, 55 | "devDependencies": { 56 | "@babel/cli": "^7.8.3", 57 | "@babel/core": "^7.8.3", 58 | "@babel/plugin-proposal-class-properties": "^7.8.3", 59 | "@babel/plugin-proposal-export-default-from": "^7.8.3", 60 | "@babel/plugin-proposal-object-rest-spread": "^7.8.3", 61 | "@babel/polyfill": "^7.8.3", 62 | "@babel/preset-env": "^7.8.3", 63 | "@babel/preset-react": "^7.8.3", 64 | "@babel/preset-typescript": "^7.8.3", 65 | "@babel/register": "^7.8.3", 66 | "@storybook/react": "^5.2.8", 67 | "@testing-library/dom": "^6.11.0", 68 | "@testing-library/jest-dom": "^5.0.0", 69 | "@testing-library/react": "^9.4.0", 70 | "@types/history": "^4.7.3", 71 | "@types/invariant": "^2.2.31", 72 | "@types/jest": "^24.9.0", 73 | "@types/node": "12.12.22", 74 | "@types/react": "^16.9.17", 75 | "@types/react-dom": "^16.9.4", 76 | "@types/react-redux": "^7.1.6", 77 | "@types/route-parser": "^0.1.3", 78 | "@types/testing-library__dom": "^6.11.1", 79 | "@types/testing-library__jest-dom": "^5.0.0", 80 | "@types/testing-library__react": "^9.1.2", 81 | "babel-eslint": "^10.0.3", 82 | "babel-jest": "^24.9.0", 83 | "babel-polyfill": "^6.26.0", 84 | "codeclimate-test-reporter": "^0.4.1", 85 | "core-js": "^2.6.11", 86 | "dom-testing-library": "^3.3.0", 87 | "enzyme": "^3.3.0", 88 | "enzyme-adapter-react-16": "^1.1.1", 89 | "eslint": "^5.12.0", 90 | "eslint-config-airbnb": "^14.1.0", 91 | "eslint-plugin-import": "^2.19.1", 92 | "eslint-plugin-jest": "^21.27.2", 93 | "eslint-plugin-jsx-a11y": "^3.0.2", 94 | "eslint-plugin-react": "^7.17.0", 95 | "estraverse-fb": "^1.3.2", 96 | "graceful-fs": "^4.1.11", 97 | "jest": "^24.9.0", 98 | "jest-dom": "^4.0.0", 99 | "jsdom": "^9.12.0", 100 | "lolex": "^1.5.2", 101 | "prop-types": "^15.7.2", 102 | "react": "^16.12.0", 103 | "react-dom": "^16.12.0", 104 | "react-redux": "^7.1.3", 105 | "redux": "^4.0.5", 106 | "rimraf": "^2.5.4", 107 | "tsd": "^0.11.0", 108 | "typescript": "^3.7.5" 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/enhancers.ts: -------------------------------------------------------------------------------- 1 | import RouteParser from 'route-parser' 2 | import { IonRouterRoute } from './actions' 3 | import { FullStateWithRouter } from './selectors' 4 | 5 | export interface DeclareRoute< 6 | ReduxState extends FullStateWithRouter, 7 | Params extends { [key: string]: string }, 8 | ParamsState extends { [key: string]: any }, 9 | Action extends { type: string; [key: string]: any } 10 | > { 11 | name: string 12 | path: string 13 | parent?: string 14 | stateFromParams?: (t: Params, s?: FullStateWithRouter) => ParamsState 15 | paramsFromState?: (t: ReduxState) => Params 16 | updateState?: MapInBetweenActions 17 | exitParams?: ((exitParams: Params) => Partial) | Partial 18 | } 19 | 20 | export type MapInBetweenActions = Partial< 21 | { 22 | [P in keyof ParamsState]: 23 | | ((param: ParamsState[P], state: ParamsState) => Action | Action[]) 24 | | undefined 25 | } 26 | > 27 | 28 | export interface EnhancedRoute< 29 | ReduxState extends FullStateWithRouter, 30 | Params extends { [key: string]: string }, 31 | ParamsState extends { [key: string]: any }, 32 | Action extends { type: string; [key: string]: any } 33 | > extends IonRouterRoute { 34 | stateFromParams: (t: Params, s?: FullStateWithRouter) => ParamsState 35 | paramsFromState: (t: ReduxState) => Params 36 | updateState: MapInBetweenActions 37 | exitParams?: ((exitParams: Params) => Partial) | Partial 38 | '@parser': RouteParser 39 | } 40 | 41 | export type GetUpdateStateReturn< 42 | E extends EnhancedRoute, 43 | key extends keyof E['updateState'] 44 | > = E['updateState'][key] extends (param: any, state: any) => infer R 45 | ? R 46 | : false 47 | 48 | export const fake = () => ({}) 49 | 50 | export function enhanceRoute< 51 | ReduxState extends FullStateWithRouter, 52 | Params extends { [key: string]: string }, 53 | ParamsState extends { [key: string]: any }, 54 | Action extends { type: string; [key: string]: any } 55 | >( 56 | routeParams: DeclareRoute 57 | ): EnhancedRoute { 58 | const parser = new RouteParser(routeParams.path) 59 | const check = parser.reverse({} as Params) 60 | const matched = check ? parser.match(check) : false 61 | const reversed = matched 62 | ? ((matched as unknown) as Partial) 63 | : undefined 64 | return { 65 | stateFromParams: (fake as unknown) as EnhancedRoute< 66 | ReduxState, 67 | Params, 68 | ParamsState, 69 | Action 70 | >['stateFromParams'], 71 | paramsFromState: (fake as unknown) as EnhancedRoute< 72 | ReduxState, 73 | Params, 74 | ParamsState, 75 | Action 76 | >['paramsFromState'], 77 | parent: undefined, 78 | updateState: {}, 79 | state: {} as ParamsState, 80 | params: {} as Params, 81 | exitParams: reversed, 82 | ...routeParams, 83 | '@parser': parser, 84 | } 85 | } 86 | 87 | export interface EnhancedRoutes { 88 | [name: string]: EnhancedRoute< 89 | FullStateWithRouter, 90 | { [key: string]: string }, 91 | { [key: string]: any }, 92 | any 93 | > 94 | } 95 | 96 | export function save< 97 | ReduxState extends FullStateWithRouter, 98 | Params extends { [key: string]: string }, 99 | ParamsState extends { [key: string]: any }, 100 | Action extends { type: string; [key: string]: any }, 101 | ERoutes extends EnhancedRoutes 102 | >( 103 | routerParams: DeclareRoute, 104 | enhancements: ERoutes 105 | ): ERoutes { 106 | return { 107 | ...enhancements, 108 | [routerParams.name]: enhanceRoute(routerParams), 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/reducer.ts: -------------------------------------------------------------------------------- 1 | import * as types from './types' 2 | import { 3 | stateRouteShape, 4 | IonRouterActions, 5 | RouteParams, 6 | RouteState, 7 | } from './actions' 8 | 9 | export interface IonRouterState { 10 | location: { 11 | pathname: string 12 | search: string 13 | hash: string 14 | } 15 | matchedRoutes: string[] 16 | routes: { 17 | ids: string[] 18 | routes: { 19 | [id: string]: { 20 | name: string 21 | path: string 22 | parent: string | undefined 23 | params: RouteParams 24 | state: RouteState 25 | } 26 | } 27 | } 28 | } 29 | 30 | const defaultState: IonRouterState = { 31 | location: { 32 | pathname: '', 33 | search: '', 34 | hash: '', 35 | }, 36 | matchedRoutes: [], 37 | routes: { 38 | ids: [], 39 | routes: {}, 40 | }, 41 | } 42 | 43 | export default ( 44 | state: IonRouterState = defaultState, 45 | action?: IonRouterActions 46 | ) => { 47 | if (!action || !action.type) return state 48 | let route 49 | let name 50 | let ids 51 | let routes 52 | switch (action.type) { 53 | case types.ROUTE: 54 | if ( 55 | action.payload.pathname === state.location.pathname && 56 | action.payload.search === state.location.search && 57 | action.payload.hash === state.location.hash 58 | ) 59 | return state 60 | return { 61 | ...state, 62 | location: { ...action.payload }, 63 | } 64 | case types.SET_PARAMS: 65 | return { 66 | ...state, 67 | routes: { 68 | ...state.routes, 69 | routes: { 70 | ...state.routes.routes, 71 | [action.payload.route]: { 72 | ...state.routes.routes[action.payload.route], 73 | params: action.payload.params, 74 | state: action.payload.state, 75 | }, 76 | }, 77 | }, 78 | } 79 | case types.MATCH_ROUTES: 80 | return { 81 | ...state, 82 | matchedRoutes: action.payload, 83 | } 84 | case types.BATCH_ROUTES: 85 | return { 86 | ...state, 87 | routes: { 88 | ids: [...state.routes.ids, ...action.payload.ids], 89 | routes: { 90 | ...state.routes.routes, 91 | ...action.payload.ids.reduce((defs, n) => { 92 | const r = action.payload.routes[n] 93 | return { 94 | ...defs, 95 | [r.name]: stateRouteShape(r), 96 | } 97 | }, {}), 98 | }, 99 | }, 100 | } 101 | case types.EDIT_ROUTE: 102 | route = action.payload 103 | return { 104 | ...state, 105 | routes: { 106 | ids: [...state.routes.ids, route.name], 107 | routes: { 108 | ...state.routes.routes, 109 | [route.name]: { 110 | name: route.name, 111 | path: route.path, 112 | parent: route.parent, 113 | params: route.params, 114 | state: route.state, 115 | }, 116 | }, 117 | }, 118 | } 119 | case types.REMOVE_ROUTE: 120 | name = action.payload 121 | ids = [...state.routes.ids] 122 | routes = { ...state.routes.routes } 123 | ids.splice(ids.indexOf(name), 1) 124 | delete routes[name] 125 | return { 126 | ...state, 127 | routes: { 128 | ids, 129 | routes, 130 | }, 131 | } 132 | case types.BATCH_REMOVE_ROUTES: 133 | ids = state.routes.ids.filter(id => !action.payload.ids.includes(id)) 134 | routes = ids.reduce( 135 | (newroutes, id) => ({ ...newroutes, [id]: state.routes.routes[id] }), 136 | {} 137 | ) 138 | return { 139 | ...state, 140 | routes: { 141 | ids, 142 | routes, 143 | }, 144 | } 145 | default: 146 | return state 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as actions from './actions' 2 | import * as enhancers from './enhancers' 3 | import middleware from './middleware' 4 | 5 | import makeRouterStoreEnhancer, { IonRouterOptions } from './storeEnhancer' 6 | 7 | export { makeRouterStoreEnhancer } 8 | export { middleware as makeRouterMiddleware } 9 | 10 | export { actionHandlers } from './middleware' 11 | import reducer from './reducer' 12 | import { FullStateWithRouter } from './selectors' 13 | import { Store, AnyAction } from 'redux' 14 | 15 | export { reducer, IonRouterOptions } 16 | 17 | export { 18 | IonRouterRoute, 19 | ActionVerbs, 20 | ActionHistoryKeys, 21 | isCallableVerb, 22 | RouteParams, 23 | RouteState, 24 | AllUrlActions, 25 | IonRouterActions, 26 | UrlAction, 27 | stateRouteShape, 28 | push, 29 | replace, 30 | go, 31 | goBack, 32 | goForward, 33 | MatchRoutesAction, 34 | matchRoutes, 35 | StateNotRequiredLocation, 36 | RouteAction, 37 | route, 38 | EditRouteAction, 39 | addRoute, 40 | BatchAddRoutesAction, 41 | BatchRemoveRoutesAction, 42 | batchRoutes, 43 | RemoveRouteAction, 44 | removeRoute, 45 | batchRemoveRoutes, 46 | SetParamsAndStateAction, 47 | setParamsAndState, 48 | ExitRoutesAction, 49 | exitRoutes, 50 | EnterRoutesAction, 51 | enterRoutes, 52 | PendingUpdatesAction, 53 | pending, 54 | CommittedUpdatesAction, 55 | commit, 56 | } from './actions' 57 | export { RouterContext, Context } from './Context' 58 | export { DisplaysChildren } from './DisplaysChildren' 59 | export { 60 | DeclareRoute, 61 | MapInBetweenActions, 62 | EnhancedRoute, 63 | GetUpdateStateReturn, 64 | enhanceRoute, 65 | EnhancedRoutes, 66 | save, 67 | } from './enhancers' 68 | export { 69 | filter, 70 | diff, 71 | changed, 72 | urlFromState, 73 | getStateUpdates, 74 | updateState, 75 | template, 76 | exitRoute, 77 | stateFromLocation, 78 | matchRoutesHelper, 79 | makeRoute, 80 | batchRoutesHelper, 81 | removeRouteHelper, 82 | batchRemoveRoutesHelper, 83 | } from './helpers' 84 | export { Link } from './Link' 85 | export { 86 | MiddlewareListener, 87 | HandlerResult, 88 | ActionHandler, 89 | processHandler, 90 | createMiddleware, 91 | } from './middleware' 92 | export { IonRouterState } from './reducer' 93 | export { Route } from './Route' 94 | export { Routes } from './Routes' 95 | export { RouteToggle } from './RouteToggle' 96 | export { 97 | matchedRoute, 98 | noMatches, 99 | oldParams, 100 | oldState, 101 | matchedRoutes, 102 | location, 103 | stateExists, 104 | FullStateWithRouter, 105 | } from './selectors' 106 | import Toggle from './Toggle' 107 | export { Toggle } 108 | 109 | // for unit-testing purposes 110 | export function synchronousMakeRoutes< 111 | StoreState extends FullStateWithRouter, 112 | Actions extends AnyAction | actions.IonRouterActions, 113 | E extends enhancers.DeclareRoute[] 114 | >(routes: E, opts: IonRouterOptions['routerOptions']) { 115 | const action = actions.batchRoutes( 116 | routes 117 | ) 118 | opts.enhancedRoutes = Object.keys(action.payload.routes).reduce( 119 | (en, route) => enhancers.save(action.payload.routes[route], en), 120 | opts.enhancedRoutes 121 | ) 122 | return action 123 | } 124 | 125 | export default function makeRouter< 126 | StoreState extends FullStateWithRouter, 127 | A extends AnyAction | actions.IonRouterActions, 128 | E extends enhancers.DeclareRoute[] 129 | >( 130 | connect: any, 131 | store: Store & IonRouterOptions, 132 | routeDefinitions: E, 133 | isServer = false 134 | ) { 135 | store.routerOptions.isServer = isServer 136 | if (routeDefinitions) { 137 | store.dispatch( 138 | synchronousMakeRoutes( 139 | routeDefinitions, 140 | store.routerOptions 141 | ) as A 142 | ) 143 | // re-send now that routes exist 144 | store.dispatch(actions.route(store.getState().routing.location) as A) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/Link.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | MouseEvent, 3 | useCallback, 4 | useContext, 5 | useState, 6 | useEffect, 7 | } from 'react' 8 | import RouteParser from 'route-parser' 9 | import invariant from 'invariant' 10 | 11 | import * as actions from './actions' 12 | import Context, { RouterContext } from './Context' 13 | import { Location } from 'history' 14 | 15 | interface Props { 16 | route?: string 17 | children: React.ReactNode 18 | onClick?: (e: MouseEvent) => void 19 | to?: string 20 | replace?: string 21 | href?: string 22 | } 23 | 24 | function createRouteParser(route: string, routeInfo: RouterContext) { 25 | if (route && routeInfo.routes[route]) { 26 | return new RouteParser(routeInfo.routes[route].path) 27 | } else { 28 | return false 29 | } 30 | } 31 | 32 | function isLocation(s: any): s is Location { 33 | return s && s.pathname 34 | } 35 | 36 | type ValidHTMLAnchorProps = 37 | | 'download' 38 | | 'hreflang' 39 | | 'referrerPolicy' 40 | | 'rel' 41 | | 'target' 42 | | 'type' 43 | | 'id' 44 | | 'accessKey' 45 | | 'className' 46 | | 'contentEditable' 47 | | 'dir' 48 | | 'draggable' 49 | | 'hidden' 50 | | 'lang' 51 | | 'spellcheck' 52 | | 'style' 53 | | 'tabIndex' 54 | | 'title' 55 | 56 | export type HTMLAnchor = { 57 | [P in ValidHTMLAnchorProps]: HTMLAnchorElement[P] 58 | } 59 | 60 | const validProps: ValidHTMLAnchorProps[] = [ 61 | 'download', 62 | 'hreflang', 63 | 'referrerPolicy', 64 | 'rel', 65 | 'target', 66 | 'type', 67 | 'id', 68 | 'accessKey', 69 | 'className', 70 | 'contentEditable', 71 | 'dir', 72 | 'draggable', 73 | 'hidden', 74 | 'lang', 75 | 'spellcheck', 76 | 'style', 77 | 'tabIndex', 78 | 'title', 79 | ] 80 | 81 | export function Link( 82 | props: Props & Partial & ExtraProps 83 | ) { 84 | const { to, replace, onClick, href, children, route, ...extra } = props 85 | const routeInfo = useContext(Context) 86 | const [routeState, setRoute] = useState( 87 | routeInfo ? createRouteParser(route!, routeInfo) : false 88 | ) 89 | useEffect(() => { 90 | if (route && routeInfo && routeInfo.routes[route]) { 91 | setRoute(new RouteParser(routeInfo.routes[route].path)) 92 | } 93 | }, [route, routeInfo]) 94 | const click = useCallback( 95 | e => { 96 | e.preventDefault() 97 | let url: string | Location 98 | const action = replace ? 'replace' : 'push' 99 | if (route) { 100 | url = routeState 101 | ? routeState.reverse({ to, replace, href, ...extra }) || '' 102 | : '' 103 | } else if (replace) { 104 | url = replace 105 | } else { 106 | url = to || '' 107 | } 108 | routeInfo && routeInfo.dispatch(actions[action](url)) 109 | if (onClick) { 110 | onClick(e) 111 | } 112 | }, 113 | [replace, route, routeState, to, routeInfo, href, extra] 114 | ) 115 | 116 | const aProps = Object.keys(props).reduce< 117 | { 118 | [P in keyof (Props & HTMLAnchorElement)]?: (Props & 119 | HTMLAnchorElement & 120 | ExtraProps)[P] 121 | } 122 | >((newProps, key: keyof (Props & HTMLAnchor & ExtraProps)) => { 123 | if (validProps.includes(key as ValidHTMLAnchorProps)) 124 | (newProps as any)[key] = props[key] 125 | if ((key as string).slice(0, 5) === 'data-') 126 | (newProps as any)[key] = props[key] 127 | return newProps 128 | }, {}) 129 | invariant( 130 | !href, 131 | 'href should not be passed to Link, use "to," "replace" or "route" (passed "%s")', 132 | href 133 | ) 134 | let landing: string | Location = replace || to || '' 135 | if (routeState) { 136 | landing = routeState.reverse(props) || '' 137 | } else if (isLocation(landing)) { 138 | landing = `${landing.pathname}${'' + landing.search}${'' + landing.hash}` 139 | } 140 | return ( 141 | 142 | {children} 143 | 144 | ) 145 | } 146 | 147 | export default Link 148 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import makeRouter, * as index from '../src' 2 | import * as actions from '../src/actions' 3 | import * as enhancers from '../src/enhancers' 4 | import { Store } from 'redux' 5 | import { IonRouterOptions } from '../src' 6 | 7 | describe('ion-router', () => { 8 | test('synchronousMakeRoutes', () => { 9 | const routes = [ 10 | { 11 | name: 'campers', 12 | path: '/campers/:year(/:id)', 13 | paramsFromState: state => ({ 14 | id: state.campers.selectedCamper 15 | ? state.campers.selectedCamper 16 | : undefined, 17 | year: state.currentYear + '', // eslint-disable-line 18 | }), 19 | stateFromParams: params => ({ 20 | id: params.id ? params.id : false, 21 | year: +params.year, 22 | }), 23 | updateState: { 24 | id: id => ({ type: 'select', payload: id }), 25 | year: year => ({ type: 'year', payload: year }), 26 | }, 27 | }, 28 | { 29 | name: 'ensembles', 30 | path: '/ensembles(/:id)', 31 | paramsFromState: state => ({ 32 | id: state.ensembleTypes.selectedEnsembleType 33 | ? state.ensembleTypes.selectedEnsembleType 34 | : undefined, 35 | }), 36 | stateFromParams: params => ({ 37 | id: params.id ? params.id : false, 38 | }), 39 | updateState: { 40 | id: id => ({ type: 'ensemble', payload: id }), 41 | }, 42 | }, 43 | { 44 | name: 'foo', 45 | path: '/my/:fancy/path(/:wow/*supercomplicated(/:thing))', 46 | }, 47 | ] 48 | const opts = { 49 | isServer: false, 50 | enhancedRoutes: {}, 51 | } 52 | expect(index.synchronousMakeRoutes(routes, opts)).toEqual( 53 | actions.batchRoutes(routes) 54 | ) 55 | expect(opts.enhancedRoutes).toEqual({ 56 | campers: { 57 | ...enhancers.enhanceRoute(routes[0]), 58 | parent: undefined, 59 | }, 60 | ensembles: { 61 | ...enhancers.enhanceRoute(routes[1]), 62 | parent: undefined, 63 | }, 64 | foo: { 65 | ...enhancers.enhanceRoute(routes[2]), 66 | parent: undefined, 67 | }, 68 | }) 69 | }) 70 | describe('main', () => { 71 | test('sets options server', () => { 72 | const store = ({ 73 | routerOptions: { 74 | isServer: false, 75 | enhancedRoutes: {}, 76 | }, 77 | } as unknown) as Store & IonRouterOptions 78 | index.default(() => () => null, store, undefined, true) 79 | expect(store.routerOptions.isServer).toBe(true) 80 | }) 81 | test('sets up server routes', () => { 82 | const log = [] 83 | const store = ({ 84 | getState: () => ({ 85 | routing: { location: { pathname: 'hi', search: '', hash: '' } }, 86 | }), 87 | dispatch: action => log.push(action), 88 | routerOptions: { 89 | enhancedRoutes: {}, 90 | }, 91 | } as unknown) as Store & IonRouterOptions 92 | const routes = [ 93 | { 94 | name: 'hi', 95 | path: '/hi', 96 | }, 97 | { 98 | name: 'there', 99 | path: '/there', 100 | }, 101 | ] 102 | makeRouter(() => () => null, store, routes, true) 103 | expect(log).toEqual([ 104 | actions.batchRoutes(routes), 105 | actions.route({ pathname: 'hi', search: '', hash: '' }), 106 | ]) 107 | expect(store.routerOptions).toEqual({ 108 | enhancedRoutes: { 109 | hi: { 110 | ...enhancers.enhanceRoute({ 111 | name: 'hi', 112 | path: '/hi', 113 | parent: undefined, 114 | }), 115 | }, 116 | there: { 117 | ...enhancers.enhanceRoute({ 118 | name: 'there', 119 | path: '/there', 120 | parent: undefined, 121 | }), 122 | }, 123 | }, 124 | isServer: true, 125 | }) 126 | }) 127 | }) 128 | }) 129 | -------------------------------------------------------------------------------- /docs/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 33 | ion-router: powerful routing with redux and react 34 | 35 | 64 | 65 | 66 | 67 |
68 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /docs/src/examples/StateChanges.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { connect } from 'react-redux' 3 | import PropTypes from 'prop-types' 4 | import Routes from 'ion-router/Routes' 5 | import Route from 'ion-router/Route' 6 | 7 | const defaultState = { 8 | first: '', 9 | second: 0, 10 | third: [] 11 | } 12 | 13 | export const reducer = { 14 | statechange: (state = defaultState, action) => { 15 | if (!action || !action.type) return state 16 | switch (action.type) { 17 | case 'ONE': 18 | return { 19 | ...state, 20 | first: action.payload 21 | } 22 | case 'TWO': 23 | return { 24 | ...state, 25 | second: action.payload 26 | } 27 | case 'THREE': 28 | return { 29 | ...state, 30 | third: action.payload 31 | } 32 | default: 33 | return state 34 | } 35 | } 36 | } 37 | 38 | const removeItem = (arr, idx) => { 39 | const ret = [...arr] 40 | ret.splice(idx, 1) 41 | return ret 42 | } 43 | 44 | class StateChanges extends Component { 45 | constructor(props) { 46 | super(props) 47 | this.state = { 48 | newItem: '' 49 | } 50 | } 51 | 52 | render() { 53 | return ( 54 |
55 |

This demonstration has no links, but updates URL

56 |
    57 |
  • 58 | First state: 59 | this.props.setFirst(e.target.value)} 62 | value={this.props.first} 63 | /> 64 |
  • 65 |
  • 66 | Second state: 67 | this.props.setSecond(e.target.value)} 70 | value={this.props.second} 71 | /> 72 |
  • 73 |
  • 74 | Third state: 75 |
      76 | {this.props.third.map((item, i) => ( 77 |
    • 78 | {item} 79 | 86 |
    • 87 | ))} 88 |
    89 | this.setState({ newItem: e.target.value })} 94 | /> 95 | 100 |
  • 101 |
102 | 103 | ({ 107 | first: params.first || '', 108 | second: params.second || 0, 109 | third: params.third ? params.third.split('/') : [] 110 | })} 111 | paramsFromState={state => ({ 112 | first: state.statechange.first, 113 | second: state.statechange.second ? state.statechange.second : undefined, 114 | third: state.statechange.third.join('/') 115 | })} 116 | updateState={{ 117 | first: first => ({ type: 'ONE', payload: first }), 118 | second: second => ({ type: 'TWO', payload: second }), 119 | third: third => ({ type: 'THREE', payload: third }), 120 | }} 121 | /> 122 | 123 |
124 | ) 125 | } 126 | } 127 | 128 | StateChanges.propTypes = { 129 | first: PropTypes.string.isRequired, 130 | second: PropTypes.number.isRequired, 131 | third: PropTypes.array.isRequired, 132 | setFirst: PropTypes.func.isRequired, 133 | setSecond: PropTypes.func.isRequired, 134 | setThird: PropTypes.func.isRequired, 135 | } 136 | 137 | export default connect(state => ({ 138 | first: state.statechange.first, 139 | second: state.statechange.second, 140 | third: state.statechange.third 141 | }), dispatch => ({ 142 | setFirst: first => dispatch({ type: 'ONE', payload: first }), 143 | setSecond: second => dispatch({ type: 'TWO', payload: +second }), 144 | setThird: third => dispatch({ type: 'THREE', payload: third }), 145 | }))(StateChanges) 146 | -------------------------------------------------------------------------------- /test/RouteToggle.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import RouteToggle from '../src/RouteToggle' 4 | import { renderComponent } from './test_helper' 5 | import * as rtl from '@testing-library/react' 6 | 7 | describe('RouteToggle', () => { 8 | afterEach(() => rtl.cleanup()) 9 | const Component = ( 10 | props: any // eslint-disable-next-line 11 | ) => ( 12 |
13 | hi{' '} 14 | {Object.keys(props).map(prop => ( 15 |
16 | {props[prop]} 17 |
18 | ))} 19 |
20 | ) 21 | let Route, state // eslint-disable-line 22 | describe('initialized', () => { 23 | beforeEach(() => { 24 | Route = RouteToggle('test') 25 | }) 26 | test('renders the component if the route matches', () => { 27 | const container = renderComponent( 28 | Route, 29 | { component: Component, foo: 'bar' }, 30 | { 31 | week: 1, 32 | routing: { 33 | location: { 34 | pathname: '', 35 | hash: '', 36 | search: '', 37 | }, 38 | routes: { 39 | test: { 40 | name: 'test', 41 | path: '/test', 42 | }, 43 | }, 44 | matchedRoutes: ['test'], 45 | }, 46 | } 47 | ) 48 | expect(container.getByTestId('foo')).toHaveTextContent('bar') 49 | }) 50 | test('does not render the component if the route matches', () => { 51 | const container = renderComponent( 52 | Route, 53 | { component: Component, foo: 'bar' }, 54 | { 55 | week: 1, 56 | routing: { 57 | location: { 58 | pathname: '', 59 | hash: '', 60 | search: '', 61 | }, 62 | routes: { 63 | test: { 64 | name: 'test', 65 | path: '/test', 66 | }, 67 | }, 68 | matchedRoutes: ['no'], 69 | }, 70 | } 71 | ) 72 | expect(container.queryByTestId('foo')).toBe(null) 73 | }) 74 | test('does not render the component if the route matches, but other does not', () => { 75 | const Route = RouteToggle('test', () => false) 76 | 77 | const container = renderComponent( 78 | Route, 79 | { component: Component, foo: 'bar' }, 80 | { 81 | week: 1, 82 | routing: { 83 | location: { 84 | pathname: '', 85 | hash: '', 86 | search: '', 87 | }, 88 | routes: { 89 | test: { 90 | name: 'test', 91 | path: '/test', 92 | }, 93 | }, 94 | matchedRoutes: ['test'], 95 | }, 96 | } 97 | ) 98 | expect(container.queryByTestId('foo')).toBe(null) 99 | }) 100 | test('does not call state if loaded returns false', () => { 101 | const spy = jest.fn() 102 | spy.mockReturnValue(true) 103 | const loaded = jest.fn() 104 | spy.mockReturnValue(false) 105 | const R = RouteToggle('test', spy, loaded) 106 | const container = renderComponent( 107 | R, 108 | { component: Component, foo: 'bar', week: 1 }, 109 | { 110 | week: 1, 111 | routing: { 112 | location: { 113 | pathname: '', 114 | hash: '', 115 | search: '', 116 | }, 117 | routes: { 118 | test: { 119 | name: 'test', 120 | path: '/test', 121 | }, 122 | }, 123 | matchedRoutes: ['no'], 124 | }, 125 | } 126 | ) 127 | 128 | expect(spy.mock.calls.length).toBe(0) 129 | expect(loaded.mock.calls.length).toBe(2) 130 | expect(container.queryByTestId('foo')).toBe(null) 131 | }) 132 | test('componentLoadingMap', () => { 133 | const R = RouteToggle( 134 | 'test', 135 | () => true, 136 | () => true, 137 | { 138 | component: 'bobby', 139 | loadingComponent: 'frenzel', 140 | else: 'blah', 141 | } 142 | ) 143 | const Show = props => ( 144 |
    145 | {Object.keys(props).map(prop => ( 146 |
  • 147 | {JSON.stringify(props[prop])} 148 |
  • 149 | ))} 150 |
151 | ) 152 | const container = renderComponent( 153 | R, 154 | { component: Show, bobby: 'hi', frenzel: 'there', blah: 'oops' }, 155 | { 156 | week: 1, 157 | routing: { 158 | location: { 159 | pathname: '', 160 | hash: '', 161 | search: '', 162 | }, 163 | routes: { 164 | test: { 165 | name: 'test', 166 | path: '/test', 167 | }, 168 | }, 169 | matchedRoutes: ['test'], 170 | }, 171 | } 172 | ) 173 | expect(container.getByTestId('component')).toHaveTextContent('"hi"') 174 | expect(container.getByTestId('bobby')).toHaveTextContent('') 175 | expect(container.getByTestId('loadingComponent')).toHaveTextContent( 176 | '"there"' 177 | ) 178 | expect(container.getByTestId('frenzel')).toHaveTextContent('') 179 | expect(container.getByTestId('else')).toHaveTextContent('"oops"') 180 | expect(container.getByTestId('blah')).toHaveTextContent('') 181 | }) 182 | }) 183 | }) 184 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | // "incremental": true, /* Enable incremental compilation */ 5 | "target": "es2018" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, 6 | "module": "ESNext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 7 | "lib": [ 8 | "DOM", 9 | "ESNext" 10 | ] /* Specify library files to be included in the compilation. */, 11 | "allowJs": true /* Allow javascript files to be compiled. */, 12 | "checkJs": false /* Report errors in .js files. */, 13 | "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, 14 | "declaration": true /* Generates corresponding '.d.ts' file. */, 15 | "declarationMap": true /* Generates a sourcemap for each corresponding '.d.ts' file. */, 16 | "sourceMap": true /* Generates corresponding '.map' file. */, 17 | // "outFile": "./", /* Concatenate and emit output to single file. */ 18 | "outDir": "./lib" /* Redirect output structure to the directory. */, 19 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 20 | // "composite": true, /* Enable project compilation */ 21 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 22 | // "removeComments": true, /* Do not emit comments to output. */ 23 | // "noEmit": true /* Do not emit outputs. */, 24 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 25 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 26 | "isolatedModules": false /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */, 27 | 28 | /* Strict Type-Checking Options */ 29 | "strict": true /* Enable all strict type-checking options. */, 30 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 31 | // "strictNullChecks": true, /* Enable strict null checks. */ 32 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 33 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 34 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 35 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 36 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 37 | 38 | /* Additional Checks */ 39 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 40 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 41 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 42 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 43 | 44 | /* Module Resolution Options */ 45 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 46 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 47 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 48 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 49 | // "typeRoots": [], /* List of folders to include type definitions from. */ 50 | // "types": [], /* Type declaration files to be included in compilation. */ 51 | "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */, 52 | "resolveJsonModule": true, 53 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 54 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 55 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 56 | 57 | /* Source Map Options */ 58 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 61 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 62 | 63 | /* Experimental Options */ 64 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 65 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 66 | 67 | /* Advanced Options */ 68 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 69 | // "plugins": [] 70 | }, 71 | "include": ["./src"], 72 | "exclude": ["./src/__mocks__", "./src/type-tests"] 73 | } 74 | -------------------------------------------------------------------------------- /test/actions.test.ts: -------------------------------------------------------------------------------- 1 | import * as actions from '../src/actions' 2 | import * as types from '../src/types' 3 | 4 | describe('actions', () => { 5 | test('push', () => { 6 | expect(actions.push('/hi')).toEqual({ 7 | type: types.ACTION, 8 | payload: { 9 | verb: 'push', 10 | route: '/hi', 11 | state: {}, 12 | }, 13 | }) 14 | expect(actions.push('/hi', { some: 'state' })).toEqual({ 15 | type: types.ACTION, 16 | payload: { 17 | verb: 'push', 18 | route: '/hi', 19 | state: { some: 'state' }, 20 | }, 21 | }) 22 | }) 23 | test('replace', () => { 24 | expect(actions.replace('/hi')).toEqual({ 25 | type: types.ACTION, 26 | payload: { 27 | verb: 'replace', 28 | route: '/hi', 29 | state: {}, 30 | }, 31 | }) 32 | expect(actions.replace('/hi', { some: 'state' })).toEqual({ 33 | type: types.ACTION, 34 | payload: { 35 | verb: 'replace', 36 | route: '/hi', 37 | state: { some: 'state' }, 38 | }, 39 | }) 40 | }) 41 | test('go', () => { 42 | expect(actions.go(5)).toEqual({ 43 | type: types.ACTION, 44 | payload: { 45 | verb: 'go', 46 | distance: 5, 47 | }, 48 | }) 49 | }) 50 | test('goBack', () => { 51 | expect(actions.goBack()).toEqual({ 52 | type: types.ACTION, 53 | payload: { 54 | verb: 'goBack', 55 | }, 56 | }) 57 | }) 58 | test('goForward', () => { 59 | expect(actions.goForward()).toEqual({ 60 | type: types.ACTION, 61 | payload: { 62 | verb: 'goForward', 63 | }, 64 | }) 65 | }) 66 | test('route', () => { 67 | expect( 68 | actions.route({ 69 | pathname: '/hi', 70 | search: '', 71 | hash: '', 72 | }) 73 | ).toEqual({ 74 | type: types.ROUTE, 75 | payload: { 76 | pathname: '/hi', 77 | search: '', 78 | hash: '', 79 | }, 80 | }) 81 | }) 82 | test('matchRoutes', () => { 83 | expect(actions.matchRoutes(['route1', 'route2'])).toEqual({ 84 | type: types.MATCH_ROUTES, 85 | payload: ['route1', 'route2'], 86 | }) 87 | }) 88 | test('addRoute', () => { 89 | expect( 90 | actions.addRoute({ name: 'foo', path: '/hi/:there', parent: undefined }) 91 | ).toEqual({ 92 | type: types.EDIT_ROUTE, 93 | payload: { 94 | name: 'foo', 95 | path: '/hi/:there', 96 | parent: undefined, 97 | params: {}, 98 | state: {}, 99 | }, 100 | }) 101 | }) 102 | test('batchRoutes', () => { 103 | expect( 104 | actions.batchRoutes([ 105 | { 106 | name: 'foo', 107 | path: '/hi/there', 108 | }, 109 | { 110 | name: 'bar', 111 | path: '/bar/ber', 112 | parent: 'foo', 113 | }, 114 | ]) 115 | ).toEqual({ 116 | type: types.BATCH_ROUTES, 117 | payload: { 118 | ids: ['foo', 'bar'], 119 | routes: { 120 | foo: { 121 | name: 'foo', 122 | path: '/hi/there', 123 | parent: undefined, 124 | params: {}, 125 | state: {}, 126 | }, 127 | bar: { 128 | name: 'bar', 129 | path: '/bar/ber', 130 | parent: 'foo', 131 | params: {}, 132 | state: {}, 133 | }, 134 | }, 135 | }, 136 | }) 137 | }) 138 | 139 | test('batchRemoveRoutes', () => { 140 | expect( 141 | actions.batchRemoveRoutes([ 142 | { 143 | name: 'foo', 144 | path: '/hi/there', 145 | }, 146 | { 147 | name: 'bar', 148 | path: '/bar/ber', 149 | parent: 'foo', 150 | }, 151 | ]) 152 | ).toEqual({ 153 | type: types.BATCH_REMOVE_ROUTES, 154 | payload: { 155 | ids: ['foo', 'bar'], 156 | routes: { 157 | foo: { 158 | name: 'foo', 159 | path: '/hi/there', 160 | parent: undefined, 161 | params: {}, 162 | state: {}, 163 | }, 164 | bar: { 165 | name: 'bar', 166 | path: '/bar/ber', 167 | parent: 'foo', 168 | params: {}, 169 | state: {}, 170 | }, 171 | }, 172 | }, 173 | }) 174 | }) 175 | test('removeRoute', () => { 176 | expect(actions.removeRoute('foo')).toEqual({ 177 | type: types.REMOVE_ROUTE, 178 | payload: 'foo', 179 | }) 180 | }) 181 | test('setParamsAndState', () => { 182 | expect( 183 | actions.setParamsAndState( 184 | 'route', 185 | { 186 | foo: 'bar', 187 | }, 188 | { 189 | bar: 'bar', 190 | } 191 | ) 192 | ).toEqual({ 193 | type: types.SET_PARAMS, 194 | payload: { 195 | route: 'route', 196 | params: { 197 | foo: 'bar', 198 | }, 199 | state: { 200 | bar: 'bar', 201 | }, 202 | }, 203 | }) 204 | }) 205 | test('enterRoutes', () => { 206 | expect(actions.enterRoutes(['hi'])).toEqual({ 207 | type: types.ENTER_ROUTES, 208 | payload: ['hi'], 209 | }) 210 | }) 211 | test('exitRoutes', () => { 212 | expect(actions.exitRoutes(['hi'])).toEqual({ 213 | type: types.EXIT_ROUTES, 214 | payload: ['hi'], 215 | }) 216 | }) 217 | test('pending', () => { 218 | expect(actions.pending()).toEqual({ 219 | type: types.PENDING_UPDATES, 220 | payload: null, 221 | }) 222 | }) 223 | test('committed', () => { 224 | expect(actions.commit()).toEqual({ 225 | type: types.COMMITTED_UPDATES, 226 | payload: null, 227 | }) 228 | }) 229 | }) 230 | -------------------------------------------------------------------------------- /test/Route.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Route, { fakeRouteHelper as fake } from '../src/Route' 3 | import Routes from '../src/Routes' 4 | import * as actions from '../src/actions' 5 | import * as enhancers from '../src/enhancers' 6 | import { renderComponent, sagaStore } from './test_helper' 7 | import { Provider } from 'react-redux' 8 | import { FullStateWithRouter } from '../src' 9 | 10 | jest.mock('../src/Context') 11 | 12 | describe('Route', () => { 13 | const paramsFromState = state => ({ 14 | id: state.ensembleTypes.selectedEnsembleType 15 | ? state.ensembleTypes.selectedEnsembleType === true 16 | ? 'new' 17 | : state.ensembleTypes.selectedEnsembleType 18 | : undefined, 19 | }) 20 | const stateFromParams = params => ({ 21 | id: params.id ? (params.id === 'new' ? true : params.id) : false, 22 | }) 23 | const updateState = { 24 | id: (id: string | true) => { 25 | const ret: { 26 | type: 'selectEnsemble' | 'newTempEnsemble' 27 | id?: string | true 28 | }[] = [{ type: 'selectEnsemble' as const, id }] 29 | if (id === true) { 30 | ret.unshift({ type: 'newTempEnsemble' as const }) 31 | } 32 | return ret 33 | }, 34 | } 35 | let component, store: ReturnType, log // eslint-disable-line 36 | function make( 37 | props = {}, 38 | Comp: React.ElementType = Routes, 39 | state = {}, 40 | s = undefined 41 | ) { 42 | const info = renderComponent(Comp, props, state, true, s) 43 | component = info[0] 44 | store = info[1] 45 | log = info[2] 46 | } 47 | test('immediately dispatches a route creation action', () => { 48 | store = sagaStore({ 49 | routing: { 50 | location: { 51 | hash: '', 52 | search: '', 53 | pathname: '/', 54 | }, 55 | matchedRoutes: [], 56 | routes: { 57 | ids: [], 58 | routes: {}, 59 | }, 60 | }, 61 | }) 62 | const R = () => ( 63 | 64 | 65 | 72 | 73 | 74 | ) 75 | make({}, R, {}, store) 76 | expect(log).toEqual([ 77 | actions.route({ 78 | pathname: '/', 79 | search: '', 80 | hash: '', 81 | state: undefined, 82 | key: undefined, 83 | }), 84 | actions.batchRoutes([ 85 | { 86 | name: 'ensembles', 87 | path: '/ensembles/:id', 88 | paramsFromState, 89 | stateFromParams, 90 | parent: undefined, 91 | updateState, 92 | }, 93 | ]), 94 | ]) 95 | }) 96 | test('uses parent', () => { 97 | const mystore = sagaStore({ 98 | routing: { 99 | matchedRoutes: [], 100 | location: { 101 | pathname: '', 102 | hash: '', 103 | search: '', 104 | }, 105 | routes: { 106 | ids: ['foo'], 107 | routes: { 108 | foo: { 109 | name: 'foo', 110 | path: '/testing/', 111 | parent: '', 112 | params: {}, 113 | state: {}, 114 | }, 115 | }, 116 | }, 117 | }, 118 | }) 119 | mystore.store.routerOptions.enhancedRoutes = enhancers.save( 120 | { 121 | name: 'foo', 122 | path: '/testing/', 123 | }, 124 | {} 125 | ) 126 | const R = () => ( 127 | 128 | 129 | 130 | 131 | 132 | ) 133 | make({}, R, undefined, mystore) 134 | expect(log).toEqual([ 135 | actions.route({ 136 | pathname: '/', 137 | search: '', 138 | hash: '', 139 | state: undefined, 140 | key: undefined, 141 | }), 142 | actions.batchRoutes([ 143 | { 144 | name: 'test', 145 | path: '/testing/mine/', 146 | parent: 'foo', 147 | paramsFromState: fake, 148 | stateFromParams: fake, 149 | updateState: {}, 150 | }, 151 | ]), 152 | ]) 153 | }) 154 | test('passes url down to children', () => { 155 | fake() // for coverage 156 | store = sagaStore(({} as unknown) as FullStateWithRouter) 157 | const R = () => ( 158 | 159 | 160 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | ) 174 | make({}, R, {}, store) 175 | expect(log).toEqual([ 176 | actions.route({ 177 | pathname: '/', 178 | search: '', 179 | hash: '', 180 | state: undefined, 181 | key: undefined, 182 | }), 183 | actions.batchRoutes([ 184 | { 185 | name: 'ensembles', 186 | path: '/ensembles/:id', 187 | paramsFromState, 188 | stateFromParams, 189 | updateState, 190 | }, 191 | { 192 | name: 'test', 193 | path: '/ensembles/:id/hi/', 194 | paramsFromState: fake, 195 | stateFromParams: fake, 196 | updateState: {}, 197 | }, 198 | { 199 | name: 'gronk', 200 | path: '/ensembles/:id/hi/fi', 201 | paramsFromState: fake, 202 | stateFromParams: fake, 203 | updateState: {}, 204 | }, 205 | ]), 206 | ]) 207 | }) 208 | }) 209 | -------------------------------------------------------------------------------- /test/reducer.test.ts: -------------------------------------------------------------------------------- 1 | import reducer from '../src/reducer' 2 | import * as actions from '../src/actions' 3 | 4 | describe('reducer', () => { 5 | test('ROUTE', () => { 6 | const state = { ...reducer() } 7 | expect( 8 | reducer( 9 | state, 10 | actions.route({ 11 | pathname: '', 12 | search: '', 13 | hash: '', 14 | }) 15 | ) 16 | ).toBe(state) 17 | expect( 18 | reducer( 19 | state, 20 | actions.route({ 21 | pathname: '/foo', 22 | search: '', 23 | hash: '', 24 | }) 25 | ) 26 | ).toEqual({ 27 | ...state, 28 | location: { 29 | pathname: '/foo', 30 | search: '', 31 | hash: '', 32 | }, 33 | }) 34 | }) 35 | test('MATCH_ROUTES', () => { 36 | const state = { ...reducer() } 37 | expect(reducer(state, actions.matchRoutes(['foo']))).toEqual({ 38 | ...state, 39 | matchedRoutes: ['foo'], 40 | }) 41 | }) 42 | test('SET_PARAMS', () => { 43 | const state = { ...reducer() } 44 | state.routes = { 45 | ids: ['foo', 'bar'], 46 | routes: { 47 | foo: { 48 | name: 'foo', 49 | params: { 50 | foo: undefined, 51 | }, 52 | state: { 53 | bar: undefined, 54 | }, 55 | }, 56 | bar: { 57 | name: 'bar', 58 | params: { 59 | foo: undefined, 60 | }, 61 | state: { 62 | bar: undefined, 63 | }, 64 | }, 65 | }, 66 | } 67 | expect( 68 | reducer( 69 | state, 70 | actions.setParamsAndState( 71 | 'foo', 72 | { 73 | foo: 'bar', 74 | }, 75 | { 76 | bar: 'bar', 77 | } 78 | ) 79 | ) 80 | ).toEqual({ 81 | ...state, 82 | routes: { 83 | ...state.routes, 84 | routes: { 85 | ...state.routes.routes, 86 | foo: { 87 | name: 'foo', 88 | params: { 89 | foo: 'bar', 90 | }, 91 | state: { 92 | bar: 'bar', 93 | }, 94 | }, 95 | }, 96 | }, 97 | }) 98 | }) 99 | test('EDIT_ROUTE', () => { 100 | const state = { ...reducer() } 101 | state.routes = { 102 | ids: ['foo', 'bar'], 103 | routes: { 104 | foo: { 105 | name: 'foo', 106 | params: { 107 | foo: undefined, 108 | }, 109 | state: { 110 | bar: undefined, 111 | }, 112 | }, 113 | bar: { 114 | name: 'bar', 115 | params: { 116 | foo: undefined, 117 | }, 118 | state: { 119 | bar: undefined, 120 | }, 121 | }, 122 | }, 123 | } 124 | expect( 125 | reducer( 126 | state, 127 | actions.addRoute({ 128 | name: 'hi', 129 | path: '/hi/:there', 130 | stateFromParams: hi => hi, 131 | }) 132 | ) 133 | ).toEqual({ 134 | ...state, 135 | routes: { 136 | ids: ['foo', 'bar', 'hi'], 137 | routes: { 138 | ...state.routes.routes, 139 | hi: { 140 | name: 'hi', 141 | path: '/hi/:there', 142 | parent: undefined, 143 | params: {}, 144 | state: {}, 145 | }, 146 | }, 147 | }, 148 | }) 149 | }) 150 | test('BATCH_ROUTE', () => { 151 | const state = { ...reducer() } 152 | state.routes = { 153 | ids: ['foo', 'bar'], 154 | routes: { 155 | foo: { 156 | name: 'foo', 157 | path: '/foo(/:foo)', 158 | params: { 159 | foo: undefined, 160 | }, 161 | state: { 162 | bar: undefined, 163 | }, 164 | }, 165 | bar: { 166 | name: 'bar', 167 | path: '/bar(/:foo)', 168 | params: { 169 | foo: undefined, 170 | }, 171 | state: { 172 | bar: undefined, 173 | }, 174 | }, 175 | }, 176 | } 177 | const action = actions.batchRoutes([ 178 | { 179 | name: 'fer', 180 | path: '/fer', 181 | }, 182 | { 183 | name: 'far', 184 | path: '/far', 185 | parent: 'foo', 186 | }, 187 | ]) 188 | const newstate = { 189 | ...state, 190 | routes: { 191 | ids: [...state.routes.ids, 'fer', 'far'], 192 | routes: { 193 | ...state.routes.routes, 194 | fer: { 195 | name: 'fer', 196 | path: '/fer', 197 | parent: undefined, 198 | params: {}, 199 | state: {}, 200 | }, 201 | far: { 202 | name: 'far', 203 | path: '/far', 204 | parent: 'foo', 205 | params: {}, 206 | state: {}, 207 | }, 208 | }, 209 | }, 210 | } 211 | expect(reducer(state, action)).toEqual(newstate) 212 | }) 213 | test('REMOVE_ROUTE', () => { 214 | const start = { ...reducer() } 215 | const state = reducer( 216 | start, 217 | actions.addRoute({ name: 'hi', path: '/hi/:there' }) 218 | ) 219 | expect(reducer(state, actions.removeRoute('hi'))).toEqual(start) 220 | }) 221 | test('BATCH_REMOVE_ROUTES', () => { 222 | const fstate = { 223 | ...reducer( 224 | undefined, 225 | actions.batchRoutes([ 226 | { 227 | name: 'fer', 228 | path: '/fer', 229 | }, 230 | { 231 | name: 'far', 232 | path: '/far', 233 | parent: 'foo', 234 | }, 235 | ]) 236 | ), 237 | } 238 | expect( 239 | reducer( 240 | fstate, 241 | actions.batchRemoveRoutes([ 242 | { 243 | name: 'fer', 244 | path: '/fer', 245 | }, 246 | ]) 247 | ) 248 | ).toEqual({ 249 | ...fstate, 250 | routes: { 251 | ids: ['far'], 252 | routes: { 253 | far: fstate.routes.routes.far, 254 | }, 255 | }, 256 | }) 257 | }) 258 | test('unknown type', () => { 259 | const state = reducer() 260 | expect( 261 | reducer(state, ({ 262 | type: '@#%Y@#$*(##$', 263 | } as unknown) as actions.IonRouterActions) 264 | ).toBe(state) 265 | }) 266 | }) 267 | -------------------------------------------------------------------------------- /test/Link.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { Children } from 'react' 2 | 3 | import { Link } from '../src/Link' 4 | import { push, replace } from '../src/actions' 5 | import { renderComponent } from './test_helper' 6 | import * as rtl from '@testing-library/react' 7 | import Context from '../src/Context' 8 | 9 | describe('Link', () => { 10 | const Show = context => props => ( 11 | 12 | hi 13 | 14 | ) 15 | test('dispatches replace', () => { 16 | const dispatch = jest.fn() 17 | const component = renderComponent(Show({ dispatch }), { replace: '/hi' }) 18 | rtl.fireEvent.click(component.getByText('hi')) 19 | expect(dispatch).toHaveBeenCalledWith(replace('/hi')) 20 | }) 21 | test('dispatches push', () => { 22 | const dispatch = jest.fn() 23 | const component = renderComponent(Show({ dispatch }), { to: '/hi' }) 24 | rtl.fireEvent.click(component.getByText('hi')) 25 | expect(dispatch).toHaveBeenCalledWith(push('/hi')) 26 | }) 27 | test('calls original onClick', () => { 28 | const dispatch = jest.fn() 29 | const onClick = jest.fn() 30 | const component = renderComponent(Show({ dispatch }), { 31 | to: '/hi', 32 | onClick, 33 | }) 34 | rtl.fireEvent.click(component.getByText('hi')) 35 | expect(dispatch.mock.calls.length).toBe(1) 36 | expect(dispatch.mock.calls[0]).toEqual([push('/hi')]) 37 | expect(onClick).toBeCalled() 38 | }) 39 | test('renders children', () => { 40 | const dispatch = jest.fn() 41 | const Far = () => ( 42 | {}, 47 | store: { 48 | dispatch, 49 | getState: jest.fn(), 50 | subscribe: jest.fn(), 51 | replaceReducer: jest.fn(), 52 | [Symbol.observable]: jest.fn(), 53 | routerOptions: { 54 | isServer: false, 55 | enhancedRoutes: {}, 56 | }, 57 | }, 58 | }} 59 | > 60 | null}> 61 |
foo
62 | 63 |
64 | ) 65 | const component = renderComponent(Far, { dispatch, replace: '/hi' }) 66 | expect(component.queryByText('foo')).not.toBe(null) 67 | }) 68 | test('dispatches actions when initialized', () => {}) 69 | describe('errors', () => { 70 | let c 71 | beforeEach(() => { 72 | c = console.error // eslint-disable-line 73 | console.error = () => null // eslint-disable-line 74 | }) 75 | afterEach(() => { 76 | console.error = c // eslint-disable-line 77 | }) 78 | test('errors (in dev) on href passed in', () => { 79 | expect(() => 80 | renderComponent(Show({ dispatch: () => {} }), { href: '/hi' }, {}, true) 81 | ).toThrow( 82 | 'href should not be passed to Link, use "to," "replace" or "route" (passed "/hi")' 83 | ) 84 | }) 85 | }) 86 | describe('generates the correct path when route option is used', () => { 87 | test('push', () => { 88 | const dispatch = jest.fn() 89 | const component = renderComponent( 90 | Show({ 91 | dispatch, 92 | routes: { 93 | hi: { 94 | name: 'hi', 95 | path: '/hi/:there', 96 | }, 97 | }, 98 | }), 99 | { 100 | route: 'hi', 101 | there: 'baby', 102 | } 103 | ) 104 | rtl.fireEvent.click(component.getByText('hi')) 105 | expect(dispatch).toBeCalledWith(push('/hi/baby')) 106 | }) 107 | test('replace', () => { 108 | const dispatch = jest.fn() 109 | const component = renderComponent( 110 | Show({ 111 | dispatch, 112 | routes: { 113 | hi: { 114 | name: 'hi', 115 | path: '/hi/:there', 116 | }, 117 | }, 118 | }), 119 | { 120 | route: 'hi', 121 | there: 'baby', 122 | replace: true, 123 | } 124 | ) 125 | rtl.fireEvent.click(component.getByText('hi')) 126 | expect(dispatch).toBeCalledWith(replace('/hi/baby')) 127 | }) 128 | test('replaces route when props change', () => { 129 | let dispatch = jest.fn() 130 | const contextValue = { 131 | dispatch, 132 | routes: { 133 | hi: { 134 | name: 'hi', 135 | path: '/hi/:there', 136 | }, 137 | there: { 138 | name: 'there', 139 | path: '/there/:there', 140 | }, 141 | }, 142 | } 143 | const S = Show(contextValue) 144 | const component = rtl.render() 145 | rtl.fireEvent.click(component.getByText('hi')) 146 | expect(dispatch).toBeCalledWith(replace('/hi/baby')) 147 | contextValue.dispatch = jest.fn() 148 | const S2 = Show({ 149 | ...contextValue, 150 | }) 151 | component.rerender() 152 | rtl.fireEvent.click(component.getByText('hi')) 153 | expect(contextValue.dispatch).toBeCalledWith(replace('/there/baby')) 154 | }) 155 | }) 156 | 157 | test('only valid props are passed to the a tag', () => { 158 | const PropGetter = ({ children }) => { 159 | const props = Children.toArray(children)[0].props 160 | return ( 161 |
    162 | {Object.keys(props).map(prop => ( 163 |
  • {prop}
  • 164 | ))} 165 |
166 | ) 167 | } 168 | const Me = props => ( 169 | 170 | hi 171 | 172 | ) 173 | const component = renderComponent(Me, { 174 | ...[ 175 | 'download', 176 | 'hrefLang', 177 | 'referrerPolicy', 178 | 'rel', 179 | 'target', 180 | 'type', 181 | 'id', 182 | 'accessKey', 183 | 'className', 184 | 'contentEditable', 185 | 'contextMenu', 186 | 'dir', 187 | 'draggable', 188 | 'hidden', 189 | 'itemID', 190 | 'itemProp', 191 | 'itemRef', 192 | 'itemScope', 193 | 'itemType', 194 | 'lang', 195 | 'spellCheck', 196 | 'style', 197 | 'tabIndex', 198 | 'title', 199 | ].reduce((coll, item) => ({ ...coll, [item]: {} }), {}), 200 | 'data-hi': 'there', 201 | foo: 'bar', 202 | to: 'hi', 203 | dispatch: () => null, 204 | }) 205 | const a = [ 206 | 'download', 207 | 'hrefLang', 208 | 'referrerPolicy', 209 | 'rel', 210 | 'target', 211 | 'type', 212 | 'id', 213 | 'accessKey', 214 | 'className', 215 | 'contentEditable', 216 | 'contextMenu', 217 | 'dir', 218 | 'draggable', 219 | 'hidden', 220 | 'itemID', 221 | 'itemProp', 222 | 'itemRef', 223 | 'itemScope', 224 | 'itemType', 225 | 'lang', 226 | 'spellCheck', 227 | 'style', 228 | 'tabIndex', 229 | 'title', 230 | 231 | 'data-hi', 232 | 'children', 233 | ] 234 | a.forEach(prop => expect(component.queryByText(prop)).not.toBe(null)) 235 | }) 236 | }) 237 | -------------------------------------------------------------------------------- /test/selectors.test.ts: -------------------------------------------------------------------------------- 1 | import * as selectors from '../src/selectors' 2 | 3 | describe('selectors', () => { 4 | test('matchedRoute', () => { 5 | const state: selectors.FullStateWithRouter = { 6 | routing: { 7 | location: { 8 | pathname: '', 9 | search: '', 10 | hash: '', 11 | }, 12 | matchedRoutes: ['foo', 'gronk'], 13 | routes: { 14 | ids: [], 15 | routes: {}, 16 | }, 17 | }, 18 | } 19 | expect(selectors.matchedRoute(state, 'foo')).toEqual(true) 20 | expect(selectors.matchedRoute(state, 'bar')).toEqual(false) 21 | expect(selectors.matchedRoute(state, ['foo'])).toEqual(true) 22 | expect(selectors.matchedRoute(state, ['foo', 'bar'])).toEqual(true) 23 | expect(selectors.matchedRoute(state, ['foo', 'bar'], true)).toEqual(false) 24 | expect(selectors.matchedRoute(state, ['bar'])).toEqual(false) 25 | expect(selectors.matchedRoute(state, ['foo', 'gronk'])).toEqual(true) 26 | expect(selectors.matchedRoute(state, ['foo', 'gronk'], true)).toEqual(true) 27 | }) 28 | const mystate: selectors.FullStateWithRouter = { 29 | routing: { 30 | location: { 31 | pathname: '', 32 | search: '', 33 | hash: '', 34 | }, 35 | matchedRoutes: [], 36 | routes: { 37 | ids: ['foo'], 38 | routes: { 39 | foo: { 40 | name: 'foo', 41 | state: { 42 | hi: 'hi', 43 | }, 44 | params: { 45 | hi: 'there', 46 | }, 47 | path: '', 48 | parent: '', 49 | }, 50 | }, 51 | }, 52 | }, 53 | } 54 | test('oldState', () => { 55 | expect(selectors.oldState(mystate, 'foo')).toEqual({ 56 | hi: 'hi', 57 | }) 58 | }) 59 | test('oldState', () => { 60 | expect(selectors.oldParams(mystate, 'foo')).toEqual({ 61 | hi: 'there', 62 | }) 63 | }) 64 | test('stateExists', () => { 65 | expect(selectors.stateExists({}, { hi: false })).toEqual(false) 66 | expect(selectors.stateExists({ hi: 'there' }, { hi: '' })).toEqual(true) 67 | expect( 68 | selectors.stateExists( 69 | { 70 | hi: { 71 | subthing: 'there', 72 | }, 73 | }, 74 | { 75 | hi: { 76 | subthing: '', 77 | }, 78 | } 79 | ) 80 | ).toEqual(true) 81 | expect( 82 | selectors.stateExists( 83 | { 84 | hi: { 85 | subthing: {}, 86 | }, 87 | }, 88 | { 89 | hi: { 90 | subthing: { 91 | another: false, 92 | }, 93 | }, 94 | } 95 | ) 96 | ).toEqual(false) 97 | expect( 98 | selectors.stateExists( 99 | { 100 | hi: { 101 | ids: [], 102 | things: {}, 103 | selectedThing: 'whatever', 104 | }, 105 | }, 106 | { 107 | hi: { 108 | ids: [], 109 | things: {}, 110 | }, 111 | } 112 | ) 113 | ).toEqual(true) 114 | expect( 115 | selectors.stateExists( 116 | { 117 | hi: { 118 | ids: [], 119 | things: {}, 120 | selectedThing: 'whatever', 121 | }, 122 | }, 123 | { 124 | hi: { 125 | ids: [], 126 | things: {}, 127 | selectedThing: false, 128 | }, 129 | } 130 | ) 131 | ).toEqual(false) 132 | expect( 133 | selectors.stateExists( 134 | { 135 | hi: { 136 | ids: [], 137 | things: {}, 138 | selectedThing: 'whatever', 139 | }, 140 | }, 141 | { 142 | hi: { 143 | ids: [], 144 | things: {}, 145 | selectedThing: (thing, state) => 146 | state.hi.ids.indexOf(thing) !== -1 && state.hi.things[thing], 147 | }, 148 | } 149 | ) 150 | ).toEqual(false) 151 | expect( 152 | selectors.stateExists( 153 | { 154 | hi: { 155 | ids: ['whatever'], 156 | sin: null, 157 | things: { 158 | whatever: {}, 159 | }, 160 | selectedThing: 'whatever', 161 | }, 162 | }, 163 | { 164 | hi: { 165 | ids: [], 166 | sin: null, 167 | things: {}, 168 | selectedThing: (thing, state) => 169 | state.hi.ids.indexOf(thing) !== -1 && !!state.hi.things[thing], 170 | }, 171 | } 172 | ) 173 | ).toEqual(true) 174 | expect( 175 | selectors.stateExists( 176 | { 177 | hi: { 178 | ids: false, 179 | sin: null, 180 | }, 181 | }, 182 | { 183 | hi: { 184 | ids: [], 185 | sin: null, 186 | }, 187 | } 188 | ) 189 | ).toEqual(false) 190 | expect( 191 | selectors.stateExists( 192 | {}, 193 | { 194 | rep: { 195 | composers: { 196 | ids: [], 197 | composers: {}, 198 | selectedComposer: false, 199 | }, 200 | pieces: { 201 | ids: [], 202 | pieces: {}, 203 | selectedPiece: false, 204 | }, 205 | }, 206 | } 207 | ) 208 | ).toEqual(false) 209 | }) 210 | test('matchedRoutes', () => { 211 | expect( 212 | selectors.matchedRoutes({ 213 | routing: { 214 | location: { 215 | pathname: 'hi', 216 | search: '', 217 | hash: '', 218 | }, 219 | matchedRoutes: ['hi'], 220 | routes: { 221 | ids: [], 222 | routes: {}, 223 | }, 224 | }, 225 | }) 226 | ).toEqual(['hi']) 227 | }) 228 | test('location', () => { 229 | expect( 230 | selectors.location({ 231 | routing: { 232 | location: { 233 | pathname: 'hi', 234 | search: '', 235 | hash: '', 236 | }, 237 | routes: { 238 | ids: [], 239 | routes: {}, 240 | }, 241 | matchedRoutes: [], 242 | }, 243 | }) 244 | ).toEqual({ 245 | pathname: 'hi', 246 | search: '', 247 | hash: '', 248 | }) 249 | }) 250 | test('noMatches', () => { 251 | expect( 252 | selectors.noMatches({ 253 | routing: { 254 | location: { 255 | pathname: 'hi', 256 | search: '', 257 | hash: '', 258 | }, 259 | routes: { 260 | ids: [], 261 | routes: {}, 262 | }, 263 | matchedRoutes: [], 264 | }, 265 | }) 266 | ).toEqual(true) 267 | expect( 268 | selectors.noMatches({ 269 | routing: { 270 | location: { 271 | pathname: 'hi', 272 | search: '', 273 | hash: '', 274 | }, 275 | routes: { 276 | ids: [], 277 | routes: {}, 278 | }, 279 | matchedRoutes: ['hi'], 280 | }, 281 | }) 282 | ).toEqual(false) 283 | }) 284 | }) 285 | -------------------------------------------------------------------------------- /test/Toggle.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Toggle from '../src/Toggle' 3 | import { renderComponent, sagaStore } from './test_helper' 4 | import * as rtl from '@testing-library/react' 5 | 6 | describe('Toggle', () => { 7 | let rendered 8 | beforeEach(() => { 9 | rendered = 0 10 | rtl.cleanup() 11 | }) 12 | 13 | const Component = (props: any) => { 14 | rendered++ 15 | return ( 16 | // eslint-disable-next-line 17 |
18 | hi{' '} 19 | {Object.keys(props).map(prop => ( 20 |
21 | {props[prop]} 22 |
23 | ))} 24 |
25 | ) 26 | } 27 | let Route, state // eslint-disable-line 28 | afterEach(() => rtl.cleanup()) 29 | test("should not freak out if we don't initialize", () => { 30 | const R = Toggle(() => true) 31 | expect(() => 32 | renderComponent(R, { component: Component, foo: 'bar' }, { week: 1 }) 33 | ).not.toThrow( 34 | 'call connectToggle with the connect function from react-redux to ' + 35 | 'initialize Toggle (see https://github.com/cellog/ion-router/issues/1)' 36 | ) 37 | }) 38 | describe('initialized', () => { 39 | let store 40 | beforeEach(() => { 41 | store = sagaStore(state) 42 | Route = Toggle((s, p) => { 43 | return s.week || p.week 44 | }) 45 | }) 46 | test('renders the component if the state tester returns true', async () => { 47 | const container = renderComponent( 48 | Route, 49 | { component: Component, foo: 'bar' }, 50 | { week: 1 }, 51 | false, 52 | store 53 | ) 54 | await rtl.waitForElement(() => container.getByTestId('foo')) 55 | expect(container.getByTestId('foo')).toHaveTextContent('bar') 56 | }) 57 | test('re-renders if props change', async () => { 58 | const container = renderComponent( 59 | Route, 60 | { component: Component, foo: 'bar' }, 61 | { week: 1 }, 62 | false, 63 | store 64 | ) 65 | await rtl.waitForElement(() => container.getByTestId('foo')) 66 | container.rerender( 67 | { component: Component, foo: 'baz' }, 68 | { week: 1 }, 69 | false, 70 | store 71 | ) 72 | await rtl.waitForElement(() => container.getByText('baz')) 73 | expect(container.getByTestId('foo')).toHaveTextContent('baz') 74 | expect(rendered).toBe(2) 75 | }) 76 | test("does not re-render if props don't change", async () => { 77 | const container = renderComponent( 78 | Route, 79 | { component: Component, foo: 'bar' }, 80 | { week: 1 }, 81 | false, 82 | store 83 | ) 84 | await rtl.waitForElement(() => container.getByTestId('foo')) 85 | container.rerender( 86 | { component: Component, foo: 'bar' }, 87 | { week: 1 }, 88 | false, 89 | store 90 | ) 91 | container.rerender( 92 | { component: Component, foo: 'bar' }, 93 | { week: 1 }, 94 | false, 95 | store 96 | ) 97 | await rtl.waitForElement(() => container.getByText('bar')) 98 | expect(container.getByTestId('foo')).toHaveTextContent('bar') 99 | expect(rendered).toBe(1) 100 | }) 101 | test('renders the component if the state tester returns true from props', async () => { 102 | const container = renderComponent( 103 | Route, 104 | { component: Component, foo: 'bar', week: 1 }, 105 | { week: 0 } 106 | ) 107 | await rtl.waitForElement(() => container.getByTestId('foo')) 108 | expect(container.getByTestId('foo')).toHaveTextContent('bar') 109 | }) 110 | test('renders null if the state tester returns false', () => { 111 | const container = renderComponent( 112 | Route, 113 | { component: Component, foo: 'bar' }, 114 | { week: 0 } 115 | ) 116 | expect(container.queryByTestId('foo')).toBe(null) 117 | }) 118 | test('renders else if the state tester returns false', () => { 119 | const Else = () =>
else
120 | const container = renderComponent( 121 | Route, 122 | { component: Component, else: Else }, 123 | { week: 0 } 124 | ) 125 | expect(container.getByText('else')).toHaveTextContent('else') 126 | }) 127 | test('does not call state if loaded returns false', () => { 128 | const spy = jest.fn() 129 | spy.mockReturnValue(true) 130 | const loaded = jest.fn() 131 | loaded.mockReturnValue(false) 132 | const R = Toggle(spy, loaded) 133 | renderComponent( 134 | R, 135 | { component: Component, foo: 'bar', week: 1 }, 136 | { week: 0 } 137 | ) 138 | 139 | expect(spy.mock.calls.length).toBe(0) 140 | expect(loaded.mock.calls.length).toBe(2) 141 | }) 142 | test('renders loading element if state is still loading', () => { 143 | const R = Toggle( 144 | () => true, 145 | () => false 146 | ) 147 | const container = renderComponent(R, { 148 | component: Component, 149 | loadingComponent: () =>
Loading...
, 150 | }) // eslint-disable-line react/display-name 151 | expect(container.queryByText('Loading...')).not.toBe(null) 152 | }) 153 | test('componentLoadingMap', () => { 154 | const R = Toggle( 155 | () => true, 156 | () => true, 157 | { 158 | component: 'bobby', 159 | loadingComponent: 'frenzel', 160 | else: 'blah', 161 | } 162 | ) 163 | const Show = props => ( 164 |
    165 | {Object.keys(props).map(prop => ( 166 |
  • 167 | {JSON.stringify(props[prop])} 168 |
  • 169 | ))} 170 |
171 | ) 172 | const container = renderComponent(R, { 173 | component: Show, 174 | bobby: 'hi', 175 | frenzel: 'there', 176 | blah: 'oops', 177 | }) 178 | expect(container.getByTestId('component')).toHaveTextContent('"hi"') 179 | expect(container.getByTestId('bobby')).toHaveTextContent('') 180 | expect(container.getByTestId('loadingComponent')).toHaveTextContent( 181 | '"there"' 182 | ) 183 | expect(container.getByTestId('frenzel')).toHaveTextContent('') 184 | expect(container.getByTestId('else')).toHaveTextContent('"oops"') 185 | expect(container.getByTestId('blah')).toHaveTextContent('') 186 | }) 187 | test('no specified component', () => { 188 | const R = Toggle( 189 | () => true, 190 | () => true 191 | ) 192 | const J = () => ( 193 | 194 |
testing
195 |
196 | ) 197 | const container = renderComponent(J) 198 | expect(container.queryByText('testing')).not.toBe(null) 199 | }) 200 | test('renders children', () => { 201 | const R = Toggle( 202 | () => true, 203 | () => true 204 | ) 205 | const J = props => {props.children} // eslint-disable-line 206 | const container = renderComponent(J, { 207 | children: ( 208 |
    209 |
  • hi
  • 210 |
  • there
  • 211 |
212 | ), 213 | }) 214 | expect(container.queryByText('hi')).not.toBe(null) 215 | expect(container.queryByText('there')).not.toBe(null) 216 | }) 217 | }) 218 | }) 219 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from 'history' 2 | import { createPath } from 'history' 3 | import invariant from 'invariant' 4 | 5 | import * as types from './types' 6 | import * as actions from './actions' 7 | import * as helpers from './helpers' 8 | import { Store, Action } from 'redux' 9 | import { IonRouterOptions, assertEnhancedStore } from './storeEnhancer' 10 | import { EnhancedRoutes } from './enhancers' 11 | import { FullStateWithRouter } from './selectors' 12 | 13 | export type MiddlewareListener = < 14 | S extends FullStateWithRouter, 15 | A extends Action, 16 | T extends any 17 | >( 18 | store: Store & IonRouterOptions, 19 | next: (action: A) => T, 20 | action: A 21 | ) => T 22 | 23 | function ignore>( 24 | store: Store, 25 | next: (action: A) => any, 26 | action: A 27 | ) { 28 | return next(action) 29 | } 30 | 31 | export const ignoreKey: '#@#$@$#@$@#$@#$@#$@#$@#$@#$@#$@#$@#$@#$ignore' = '#@#$@$#@$@#$@#$@#$@#$@#$@#$@#$@#$@#$@#$ignore' as const 32 | 33 | function pass(newEnhancedRoutes: EnhancedRoutes) { 34 | return { 35 | newEnhancedRoutes, 36 | toDispatch: [], 37 | } 38 | } 39 | 40 | export interface HandlerResult { 41 | newEnhancedRoutes: EnhancedRoutes 42 | toDispatch: ( 43 | | actions.IonRouterActions 44 | | { type: string; [key: string]: any } 45 | )[] 46 | } 47 | 48 | export type ActionHandler = ( 49 | routes: EnhancedRoutes, 50 | state: FullStateWithRouter, 51 | action: A, 52 | updateParams?: boolean 53 | ) => HandlerResult 54 | 55 | export interface ActionHandlers { 56 | '#@#$@$#@$@#$@#$@#$@#$@#$@#$@#$@#$@#$@#$ignore': MiddlewareListener 57 | '*': ActionHandler 58 | '@@ion-router/ACTION': ActionHandler< 59 | actions.AllUrlActions 60 | > 61 | '@@ion-router/EDIT_ROUTE': ActionHandler< 62 | actions.EditRouteAction 63 | > 64 | '@@ion-router/BATCH_ROUTES': ActionHandler 65 | '@@ion-router/REMOVE_ROUTE': ActionHandler 66 | '@@ion-router/BATCH_REMOVE_ROUTES': ActionHandler< 67 | actions.BatchRemoveRoutesAction 68 | > 69 | '@@ion-router/ROUTE': ActionHandler 70 | } 71 | 72 | // every action handler accepts enhanced routes, state, and action 73 | // and returns enhanced routes and a list of actions to send 74 | // so all of them are pure 75 | export const actionHandlers: ActionHandlers = { 76 | [ignoreKey]: ignore, 77 | 78 | [types.ACTION]: pass, 79 | [types.EDIT_ROUTE]: helpers.makeRoute, 80 | [types.BATCH_ROUTES]: helpers.batchRoutesHelper, 81 | [types.REMOVE_ROUTE]: helpers.removeRouteHelper, 82 | [types.BATCH_REMOVE_ROUTES]: helpers.batchRemoveRoutesHelper, 83 | 84 | [types.ROUTE]: helpers.matchRoutesHelper, 85 | '*': helpers.urlFromState, 86 | } 87 | 88 | function invariantHelper( 89 | type: string, 90 | condition: any, 91 | message: string 92 | ): asserts condition { 93 | invariant( 94 | condition, 95 | `router middleware action handler for action type "${type}" does not ${message}` 96 | ) 97 | } 98 | 99 | export function processHandler( 100 | handler: ActionHandler, 101 | routes: EnhancedRoutes, 102 | state: FullStateWithRouter, 103 | action: any 104 | ) { 105 | const info = handler(routes, state, action) 106 | invariantHelper( 107 | action.type, 108 | info !== undefined, 109 | `return a map { newEnhancedRoutes, toDispatch }` 110 | ) 111 | invariantHelper( 112 | action.type, 113 | info.newEnhancedRoutes !== undefined && 114 | info.newEnhancedRoutes !== null && 115 | typeof info.newEnhancedRoutes === 'object' && 116 | !Array.isArray(info.newEnhancedRoutes), 117 | 'return a map for newEnhancedRoutes' 118 | ) 119 | invariantHelper( 120 | action.type, 121 | Array.isArray(info.toDispatch), 122 | 'return an array for toDispatch' 123 | ) 124 | invariantHelper( 125 | action.type, 126 | info.toDispatch.every(act => act.type), 127 | 'return a toDispatch array with all actions containing a "type" key' 128 | ) 129 | return info 130 | } 131 | 132 | export const createMiddleware = ( 133 | history = createBrowserHistory(), 134 | handlers = actionHandlers, 135 | debug = false 136 | ) => { 137 | let lastLocation = createPath(history.location) 138 | let activeListener: MiddlewareListener = listen 139 | const myHandlers = { 140 | ...handlers, 141 | } 142 | 143 | function isKnownAction( 144 | a: string 145 | ): a is Exclude { 146 | return Object.keys(myHandlers).includes(a) 147 | } 148 | 149 | function listen, T extends any>( 150 | store: Store & IonRouterOptions, 151 | next: (action: A) => T, 152 | action: A 153 | ): T { 154 | const opts = store.routerOptions 155 | const handler: ActionHandler = isKnownAction(action.type) 156 | ? myHandlers[action.type] 157 | : myHandlers['*'] 158 | const state = store.getState() 159 | activeListener = myHandlers[ignoreKey] || ignore 160 | try { 161 | if (handler !== myHandlers['*']) { 162 | const info = processHandler(handler, opts.enhancedRoutes, state, action) 163 | const ret = next(action) 164 | info.toDispatch.forEach(act => store.dispatch(act as A)) 165 | opts.enhancedRoutes = info.newEnhancedRoutes 166 | return ret 167 | } 168 | const ret = next(action) 169 | const info = processHandler( 170 | handler, 171 | opts.enhancedRoutes, 172 | store.getState(), 173 | action 174 | ) 175 | opts.enhancedRoutes = info.newEnhancedRoutes 176 | if (debug && info.toDispatch.length) { 177 | console.info(`ion-router PROCESSING: ${action.type}`) // eslint-disable-line 178 | console.info(`dispatching: `, info.toDispatch) // eslint-disable-line 179 | } 180 | info.toDispatch.forEach(act => store.dispatch(act as A)) 181 | return ret 182 | } finally { 183 | activeListener = listen 184 | } 185 | } 186 | const newStore = >( 187 | store: Store & IonRouterOptions 188 | ) => { 189 | assertEnhancedStore( 190 | store 191 | ) 192 | history.listen(location => { 193 | const a = createPath(location) 194 | if (a === lastLocation) return 195 | lastLocation = a 196 | store.dispatch(actions.route(location)) 197 | }) 198 | store.dispatch(actions.route(history.location)) 199 | return (next: (a: A | actions.IonRouterActions) => any) => ( 200 | action: actions.IonRouterActions 201 | ) => { 202 | const ret = activeListener< 203 | S & FullStateWithRouter, 204 | A & actions.IonRouterActions, 205 | any 206 | >(store as any, next, action as any) 207 | if (action.type === types.ACTION) { 208 | if ( 209 | !action.payload.route && 210 | action.payload.verb !== 'goBack' && 211 | action.payload.verb !== 'goForward' 212 | ) { 213 | throw new Error( 214 | `ion-router action ${action.payload.verb} must be a string or a location object` 215 | ) 216 | } 217 | if (!actions.isCallableVerb(action.payload.verb)) return 218 | switch (action.payload.verb) { 219 | case 'go': 220 | history.go(action.payload.distance!) 221 | break 222 | case 'goBack': 223 | history.goBack() 224 | break 225 | case 'goForward': 226 | history.goForward() 227 | break 228 | case 'push': 229 | history.push(action.payload.route!, action.payload.state) 230 | break 231 | case 'replace': 232 | history.replace(action.payload.route!, action.payload.state) 233 | } 234 | } 235 | return ret 236 | } 237 | } 238 | return newStore 239 | } 240 | export default createMiddleware 241 | -------------------------------------------------------------------------------- /test/Routes.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Provider } from 'react-redux' 3 | import ConnectedRoutes from '../src/Routes' 4 | import Route, { fakeRouteHelper as fake } from '../src/Route' 5 | import Context from '../src/Context' 6 | import * as enhancers from '../src/enhancers' 7 | import { renderComponent, sagaStore } from './test_helper' 8 | import * as actions from '../src/actions' 9 | import * as rtl from '@testing-library/react' 10 | import '@testing-library/jest-dom' 11 | import { FullStateWithRouter } from '../src' 12 | 13 | describe('Routes', () => { 14 | let component, store, log, CompClass // eslint-disable-line 15 | function make( 16 | props = {}, 17 | Comp: React.ElementType = ConnectedRoutes, 18 | state = {}, 19 | mount: false | HTMLElement = false, 20 | s = undefined 21 | ) { 22 | const things = sagaStore((state as unknown) as FullStateWithRouter) 23 | store = (s && s.store) || things.store 24 | log = s ? log : things.log 25 | const My = props => ( 26 | 27 | 28 | 29 | ) 30 | 31 | const info = renderComponent(My, props, state, true, s, mount) 32 | component = info[0] 33 | store = info[1] 34 | log = info[2] 35 | CompClass = info[3] 36 | } 37 | test('correct prop dispersion', () => { 38 | const mystore = sagaStore({ 39 | routing: { 40 | matchedRoutes: [], 41 | location: { 42 | pathname: '', 43 | hash: '', 44 | search: '', 45 | }, 46 | routes: { 47 | ids: ['hi'], 48 | routes: { 49 | hi: { 50 | name: 'hi', 51 | path: '/there', 52 | parent: '', 53 | state: {}, 54 | params: {}, 55 | }, 56 | }, 57 | }, 58 | }, 59 | }) 60 | mystore.store.routerOptions.enhancedRoutes = enhancers.save( 61 | { 62 | name: 'hi', 63 | path: '/there', 64 | }, 65 | {} 66 | ) 67 | const newstore = sagaStore({ 68 | routing: { 69 | matchedRoutes: [], 70 | location: { 71 | pathname: '', 72 | hash: '', 73 | search: '', 74 | }, 75 | routes: { 76 | ids: ['wow'], 77 | routes: { 78 | wow: { 79 | name: 'wow', 80 | path: '/wow', 81 | parent: '', 82 | state: {}, 83 | params: {}, 84 | }, 85 | }, 86 | }, 87 | }, 88 | }) 89 | newstore.store.routerOptions.enhancedRoutes = enhancers.save( 90 | { 91 | name: 'wow', 92 | path: '/wow', 93 | }, 94 | {} 95 | ) 96 | const Checker = props => ( 97 |
98 | 99 |
    100 | {Object.keys(props).map(prop => ( 101 |
  • 102 | {prop} 103 |
  • 104 | ))} 105 |
106 |
107 | ) 108 | const Thing = () => ( 109 | {info => } 110 | ) 111 | const R = ({ s }) => ( 112 | 113 | 114 | 115 | 116 | 117 | ) 118 | make({ s: mystore.store }, R, {}, false, mystore) 119 | expect(component.getAllByTestId('prop')).toHaveLength(4) 120 | expect(component.queryByText('dispatch')).not.toBe(null) 121 | expect(component.queryByText('routes')).not.toBe(null) 122 | expect(component.queryByText('addRoute')).not.toBe(null) 123 | expect(component.queryByText('store')).not.toBe(null) 124 | rtl.fireEvent.click(component.getByText('click')) 125 | expect(mystore.log[1]).toEqual({ type: 'hi' }) 126 | }) 127 | test('passes in routes from state', () => { 128 | const mystore = sagaStore({ 129 | routing: { 130 | matchedRoutes: [], 131 | location: { 132 | pathname: '', 133 | hash: '', 134 | search: '', 135 | }, 136 | routes: { 137 | ids: ['hi'], 138 | routes: { 139 | hi: { 140 | name: 'hi', 141 | path: '/there', 142 | parent: '', 143 | state: {}, 144 | params: {}, 145 | }, 146 | }, 147 | }, 148 | }, 149 | }) 150 | mystore.store.routerOptions.enhancedRoutes = enhancers.save( 151 | { 152 | name: 'hi', 153 | path: '/there', 154 | }, 155 | {} 156 | ) 157 | const Checker = props => ( 158 |
159 |
    160 | {Object.keys(props).map(prop => ( 161 |
  • 162 | {JSON.stringify(props[prop])} 163 |
  • 164 | ))} 165 |
166 |
167 | ) 168 | const Thing = () => ( 169 | {info => } 170 | ) 171 | const R = () => ( 172 | 173 | 174 | 175 | 176 | 177 | ) 178 | make({}, R, undefined, false, mystore) 179 | expect(component.getByTestId('routes')).toHaveTextContent( 180 | JSON.stringify(mystore.store.getState().routing.routes.routes) 181 | ) 182 | }) 183 | test('multiple Route children', () => { 184 | const Thing = () => ( 185 | 186 |
hi
187 |
there
188 |
189 | ) 190 | make({}, Thing) 191 | expect(component.queryByText('hi')).not.toBe(null) 192 | expect(component.queryByText('there')).not.toBe(null) 193 | }) 194 | test('unmount', () => { 195 | const unsubscribe = jest.fn() 196 | const fakeState = { 197 | routing: { 198 | matchedRoutes: [], 199 | location: { 200 | pathname: '', 201 | hash: '', 202 | search: '', 203 | }, 204 | routes: { 205 | ids: [], 206 | routes: {}, 207 | }, 208 | }, 209 | } 210 | const s = { 211 | routerOptions: { 212 | isServer: false, 213 | }, 214 | dispatch: jest.fn(), 215 | getState: jest.fn(() => fakeState), 216 | subscribe: () => unsubscribe, 217 | } 218 | const Thing = ({ s }) => ( 219 | 220 | 221 |
hi
222 |
there
223 |
224 |
225 | ) 226 | make({ s }, Thing) 227 | component.unmount() 228 | expect(s.dispatch).toHaveBeenCalledTimes(0) 229 | }) 230 | describe('server', () => { 231 | test('route setup', () => { 232 | const mystore = sagaStore({ 233 | routing: { 234 | matchedRoutes: [], 235 | location: { 236 | pathname: '', 237 | hash: '', 238 | search: '', 239 | }, 240 | routes: { 241 | ids: [], 242 | routes: {}, 243 | }, 244 | }, 245 | }) 246 | mystore.store.routerOptions.isServer = true 247 | const Thing = ({ s }) => ( 248 | 249 | 250 | 251 | 252 | 253 | ) 254 | mystore.store.dispatch = jest.fn(mystore.store.dispatch) 255 | make({ s: mystore.store }, Thing, {}, false, mystore) 256 | expect((mystore.store.dispatch as any).mock.calls).toEqual([ 257 | [ 258 | actions.addRoute({ 259 | name: 'test', 260 | path: 'hi/', 261 | paramsFromState: fake, 262 | stateFromParams: fake, 263 | updateState: {}, 264 | }), 265 | ], 266 | [ 267 | actions.batchRoutes([ 268 | { 269 | name: 'test', 270 | path: 'hi/', 271 | paramsFromState: fake, 272 | stateFromParams: fake, 273 | updateState: {}, 274 | }, 275 | ]), 276 | ], 277 | ]) 278 | }) 279 | }) 280 | }) 281 | -------------------------------------------------------------------------------- /src/actions.ts: -------------------------------------------------------------------------------- 1 | import * as types from './types' 2 | import { Location, History } from 'history' 3 | import { DeclareRoute } from './enhancers' 4 | import { FullStateWithRouter } from './selectors' 5 | 6 | export interface IonRouterRoute< 7 | State = { [key: string]: any }, 8 | Params = { [key: string]: string } 9 | > { 10 | name: string 11 | path: string 12 | parent: string | undefined 13 | params: Params 14 | state: State 15 | } 16 | 17 | export type ActionVerbs = 'push' | 'replace' | 'go' | 'goBack' | 'goForward' 18 | type HistoryKey = K extends keyof History ? K : never 19 | export type ActionHistoryKeys = HistoryKey 20 | 21 | export const isCallableVerb = (verb: string): verb is ActionHistoryKeys => { 22 | return ( 23 | verb === 'push' || 24 | verb === 'replace' || 25 | verb === 'go' || 26 | verb === 'goBack' || 27 | verb === 'goForward' 28 | ) 29 | } 30 | 31 | export interface RouteParams { 32 | [key: string]: any 33 | } 34 | 35 | export interface RouteState { 36 | [key: string]: any 37 | } 38 | 39 | export type AllUrlActions = T extends ActionVerbs 40 | ? UrlAction 41 | : never 42 | 43 | export type IonRouterActions = 44 | | AllUrlActions 45 | | MatchRoutesAction 46 | | RouteAction 47 | | EditRouteAction 48 | | RemoveRouteAction 49 | | ExitRoutesAction 50 | | SetParamsAndStateAction 51 | | ExitRoutesAction 52 | | EnterRoutesAction 53 | | PendingUpdatesAction 54 | | CommittedUpdatesAction 55 | | BatchAddRoutesAction 56 | | BatchRemoveRoutesAction 57 | 58 | export interface UrlAction { 59 | type: '@@ion-router/ACTION' 60 | payload: { 61 | verb: Verb 62 | route?: string 63 | distance?: number 64 | state?: any 65 | } 66 | } 67 | 68 | export function stateRouteShape< 69 | ReduxState extends FullStateWithRouter, 70 | Params extends { [key: string]: string }, 71 | ParamsState extends { [key: string]: any }, 72 | Action extends { type: string; [key: string]: any } 73 | >( 74 | params: DeclareRoute 75 | ): EditRouteAction< 76 | FullStateWithRouter, 77 | Params, 78 | ParamsState, 79 | Action 80 | >['payload'] { 81 | return { 82 | name: params.name, 83 | path: params.path, 84 | parent: params.parent, 85 | params: {} as Params, 86 | state: {} as ParamsState, 87 | } 88 | } 89 | 90 | function makeUrlAction(name: Verb) { 91 | return (details: string, state: any = {}): UrlAction => ({ 92 | type: types.ACTION, 93 | payload: { 94 | verb: name, 95 | route: details, 96 | state, 97 | }, 98 | }) 99 | } 100 | 101 | export const push = makeUrlAction('push') 102 | export const replace = makeUrlAction('replace') 103 | 104 | export function go(details: number): UrlAction<'go'> { 105 | return { 106 | type: types.ACTION, 107 | payload: { 108 | verb: 'go', 109 | distance: details, 110 | }, 111 | } 112 | } 113 | 114 | export function goBack(): UrlAction<'goBack'> { 115 | return { 116 | type: types.ACTION, 117 | payload: { 118 | verb: 'goBack', 119 | }, 120 | } 121 | } 122 | 123 | export function goForward(): UrlAction<'goForward'> { 124 | return { 125 | type: types.ACTION, 126 | payload: { 127 | verb: 'goForward', 128 | }, 129 | } 130 | } 131 | 132 | export interface MatchRoutesAction { 133 | type: '@@ion-router/MATCH_ROUTES' 134 | payload: string[] 135 | } 136 | 137 | export function matchRoutes(routes: string[]): MatchRoutesAction { 138 | return { 139 | type: types.MATCH_ROUTES, 140 | payload: routes, 141 | } 142 | } 143 | 144 | export type StateNotRequiredLocation = { 145 | [P in Exclude]: Location[P] 146 | } & { state?: any; key?: any } 147 | 148 | export interface RouteAction { 149 | type: '@@ion-router/ROUTE' 150 | payload: StateNotRequiredLocation 151 | } 152 | 153 | export function route(location: StateNotRequiredLocation): RouteAction { 154 | return { 155 | type: types.ROUTE, 156 | payload: location, 157 | } 158 | } 159 | 160 | export interface EditRouteAction< 161 | ReduxState extends FullStateWithRouter, 162 | Params extends { [key: string]: string }, 163 | ParamsState extends { [key: string]: any }, 164 | Action extends { type: string; [key: string]: any } 165 | > { 166 | type: '@@ion-router/EDIT_ROUTE' 167 | payload: DeclareRoute & { 168 | params: Params 169 | state: ParamsState 170 | } 171 | } 172 | 173 | export function addRoute< 174 | ReduxState extends FullStateWithRouter, 175 | Params extends { [key: string]: string }, 176 | ParamsState extends { [key: string]: any }, 177 | Action extends { type: string; [key: string]: any } 178 | >( 179 | params: DeclareRoute 180 | ): EditRouteAction { 181 | return { 182 | type: types.EDIT_ROUTE, 183 | payload: stateRouteShape(params), 184 | } 185 | } 186 | 187 | export interface BatchActionBase { 188 | type: '@@ion-router/BATCH_ROUTES' | '@@ion-router/BATCH_REMOVE_ROUTES' 189 | payload: { 190 | ids: string[] 191 | routes: { 192 | [name: string]: IonRouterRoute 193 | } 194 | } 195 | } 196 | 197 | export interface BatchAddRoutesAction extends BatchActionBase { 198 | type: '@@ion-router/BATCH_ROUTES' 199 | } 200 | 201 | export interface BatchRemoveRoutesAction extends BatchActionBase { 202 | type: '@@ion-router/BATCH_REMOVE_ROUTES' 203 | } 204 | 205 | function batch< 206 | StoreState extends FullStateWithRouter, 207 | A extends BatchActionBase['type'] 208 | >(batchRoutes: DeclareRoute[], type: A) { 209 | return { 210 | type, 211 | payload: { 212 | ids: batchRoutes.map(r => r.name), 213 | routes: batchRoutes.reduce<{ 214 | [name: string]: IonRouterRoute 215 | }>( 216 | (defs, r) => ({ 217 | ...defs, 218 | [r.name]: { 219 | parent: r.parent, 220 | ...r, 221 | params: {}, 222 | state: {}, 223 | }, 224 | }), 225 | {} 226 | ), 227 | }, 228 | } 229 | } 230 | 231 | export function batchRoutes< 232 | StoreState extends FullStateWithRouter, 233 | A extends '@@ion-router/BATCH_ROUTES' 234 | >(routes: DeclareRoute[]): BatchAddRoutesAction { 235 | return batch( 236 | routes, 237 | types.BATCH_ROUTES as A 238 | ) as BatchAddRoutesAction 239 | } 240 | 241 | export interface RemoveRouteAction { 242 | type: '@@ion-router/REMOVE_ROUTE' 243 | payload: string 244 | } 245 | 246 | export function removeRoute(name: string): RemoveRouteAction { 247 | return { 248 | type: types.REMOVE_ROUTE, 249 | payload: name, 250 | } 251 | } 252 | 253 | export function batchRemoveRoutes< 254 | StoreState extends FullStateWithRouter, 255 | A extends '@@ion-router/BATCH_REMOVE_ROUTES' 256 | >(routes: DeclareRoute[]): BatchRemoveRoutesAction { 257 | return batch( 258 | routes, 259 | types.BATCH_REMOVE_ROUTES as A 260 | ) as BatchRemoveRoutesAction 261 | } 262 | 263 | export interface SetParamsAndStateAction { 264 | type: '@@ion-router/SET_PARAMS' 265 | payload: { 266 | route: string 267 | params: any 268 | state: any 269 | } 270 | } 271 | 272 | export function setParamsAndState( 273 | route: string, 274 | params: RouteParams, 275 | state: RouteState 276 | ): SetParamsAndStateAction { 277 | return { 278 | type: types.SET_PARAMS, 279 | payload: { 280 | route, 281 | params, 282 | state, 283 | }, 284 | } 285 | } 286 | 287 | export interface ExitRoutesAction { 288 | type: '@@ion-router/EXIT_ROUTES' 289 | payload: string[] 290 | } 291 | 292 | export function exitRoutes(routes: string[]): ExitRoutesAction { 293 | return { 294 | type: types.EXIT_ROUTES, 295 | payload: routes, 296 | } 297 | } 298 | 299 | export interface EnterRoutesAction { 300 | type: '@@ion-router/ENTER_ROUTES' 301 | payload: string[] 302 | } 303 | 304 | export function enterRoutes(routes: string[]): EnterRoutesAction { 305 | return { 306 | type: types.ENTER_ROUTES, 307 | payload: routes, 308 | } 309 | } 310 | 311 | export interface PendingUpdatesAction { 312 | type: '@@ion-router/PENDING_UPDATES' 313 | payload: null 314 | } 315 | 316 | export function pending(): PendingUpdatesAction { 317 | return { 318 | type: types.PENDING_UPDATES, 319 | payload: null, 320 | } 321 | } 322 | 323 | export interface CommittedUpdatesAction { 324 | type: '@@ion-router/COMMITTED_UPDATES' 325 | payload: null 326 | } 327 | 328 | export function commit(): CommittedUpdatesAction { 329 | return { 330 | type: types.COMMITTED_UPDATES, 331 | payload: null, 332 | } 333 | } 334 | -------------------------------------------------------------------------------- /src/type-tests/actions.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectType, expectError, expectAssignable } from 'tsd' 2 | 3 | import { 4 | addRoute, 5 | FullStateWithRouter, 6 | EditRouteAction, 7 | batchRoutes, 8 | BatchAddRoutesAction, 9 | batchRemoveRoutes, 10 | BatchRemoveRoutesAction, 11 | } from '..' 12 | 13 | // simple route 14 | expectType< 15 | EditRouteAction< 16 | FullStateWithRouter, 17 | { [key: string]: string }, 18 | { [key: string]: any }, 19 | { [key: string]: any; type: string } 20 | > 21 | >( 22 | addRoute({ 23 | name: 'hi', 24 | path: '/hi/:there', 25 | parent: '', 26 | }) 27 | ) 28 | 29 | // route with stateFromParams 30 | expectType< 31 | EditRouteAction< 32 | FullStateWithRouter, 33 | { there: string; arr: string }, 34 | { thing: string[] }, 35 | { type: 'fronk'; thing: string[] } 36 | > 37 | >( 38 | addRoute({ 39 | name: 'hi', 40 | path: '/hi/:there/:arr', 41 | parent: '', 42 | stateFromParams: ({ there, arr }: { there: string; arr: string }) => { 43 | return { thing: [there, arr] } 44 | }, 45 | paramsFromState: state => { 46 | return { 47 | there: state.routing.location.pathname, 48 | arr: state.routing.location.hash, 49 | } 50 | }, 51 | updateState: { 52 | thing: (thing): { type: 'fronk'; thing: string[] } => ({ 53 | type: 'fronk', 54 | thing, 55 | }), 56 | }, 57 | }) 58 | ) 59 | 60 | // route with exitParams 1 61 | expectType< 62 | EditRouteAction< 63 | FullStateWithRouter, 64 | { there: string; arr: string }, 65 | { thing: string[] }, 66 | { type: 'fronk'; thing: string[] } 67 | > 68 | >( 69 | addRoute({ 70 | name: 'hi', 71 | path: '/hi/:there/:arr', 72 | parent: '', 73 | stateFromParams: ({ there, arr }: { there: string; arr: string }) => { 74 | return { thing: [there, arr] } 75 | }, 76 | paramsFromState: state => { 77 | return { 78 | there: state.routing.location.pathname, 79 | arr: state.routing.location.hash, 80 | } 81 | }, 82 | updateState: { 83 | thing: (thing): { type: 'fronk'; thing: string[] } => ({ 84 | type: 'fronk', 85 | thing, 86 | }), 87 | }, 88 | exitParams: { 89 | there: 'hoo', 90 | }, 91 | }) 92 | ) 93 | 94 | // route with exitParams 2 95 | expectType< 96 | EditRouteAction< 97 | FullStateWithRouter, 98 | { there: string; arr: string }, 99 | { thing: string[] }, 100 | { type: 'fronk'; thing: string[] } 101 | > 102 | >( 103 | addRoute({ 104 | name: 'hi', 105 | path: '/hi/:there/:arr', 106 | parent: '', 107 | stateFromParams: ({ there, arr }: { there: string; arr: string }) => { 108 | return { thing: [there, arr] } 109 | }, 110 | paramsFromState: state => { 111 | return { 112 | there: state.routing.location.pathname, 113 | arr: state.routing.location.hash, 114 | } 115 | }, 116 | updateState: { 117 | thing: (thing): { type: 'fronk'; thing: string[] } => ({ 118 | type: 'fronk', 119 | thing, 120 | }), 121 | }, 122 | exitParams: ({ there, arr }) => { 123 | return { there: 'hoo' } 124 | }, 125 | }) 126 | ) 127 | 128 | // batch routes 129 | expectType( 130 | batchRoutes([ 131 | { 132 | name: 'hi1', 133 | path: '/hi/:there', 134 | parent: '', 135 | }, 136 | { 137 | name: 'hi2', 138 | path: '/hi/:there/:arr', 139 | parent: '', 140 | stateFromParams: ({ there, arr }: { there: string; arr: string }) => { 141 | return { thing: [there, arr] } 142 | }, 143 | paramsFromState: state => { 144 | return { 145 | there: state.routing.location.pathname, 146 | arr: state.routing.location.hash, 147 | } 148 | }, 149 | updateState: { 150 | thing: (thing): { type: 'fronk'; thing: string[] } => ({ 151 | type: 'fronk', 152 | thing, 153 | }), 154 | }, 155 | exitParams: ({ there, arr }) => { 156 | return { there: 'hoo' } 157 | }, 158 | }, 159 | ]) 160 | ) 161 | 162 | // batch routes 163 | expectType( 164 | batchRemoveRoutes([ 165 | { 166 | name: 'hi1', 167 | path: '/hi/:there', 168 | parent: '', 169 | }, 170 | { 171 | name: 'hi2', 172 | path: '/hi/:there/:arr', 173 | parent: '', 174 | stateFromParams: ({ there, arr }: { there: string; arr: string }) => { 175 | return { thing: [there, arr] } 176 | }, 177 | paramsFromState: state => { 178 | return { 179 | there: state.routing.location.pathname, 180 | arr: state.routing.location.hash, 181 | } 182 | }, 183 | updateState: { 184 | thing: (thing): { type: 'fronk'; thing: string[] } => ({ 185 | type: 'fronk', 186 | thing, 187 | }), 188 | }, 189 | exitParams: ({ there, arr }) => { 190 | return { there: 'hoo' } 191 | }, 192 | }, 193 | ]) 194 | ) 195 | 196 | //**********************************ERRORS**************************************/ 197 | 198 | // wrong type for params (must be all string) 199 | expectError( 200 | addRoute({ 201 | name: 'hi', 202 | path: '/hi/:there/:arr', 203 | parent: '', 204 | stateFromParams: ({ there, arr }: { there: string; arr: number }) => { 205 | return { thing: [there, arr] } 206 | }, 207 | paramsFromState: state => { 208 | return { 209 | there: state.routing.location.pathname, 210 | arr: state.routing.location.hash, 211 | } 212 | }, 213 | updateState: { 214 | thing: thing => ({ 215 | type: 'fronk', 216 | thing, 217 | }), 218 | }, 219 | }) 220 | ) 221 | 222 | // wrong type for intermediate state (must be a keyed object, keyed by string) 223 | expectError( 224 | addRoute({ 225 | name: 'hi', 226 | path: '/hi/:there/:arr', 227 | parent: '', 228 | stateFromParams: ({ there, arr }: { there: string; arr: string }) => { 229 | return 1 230 | }, 231 | paramsFromState: state => { 232 | return { 233 | there: state.routing.location.pathname, 234 | arr: state.routing.location.hash, 235 | } 236 | }, 237 | updateState: { 238 | thing: (thing): { type: 'fronk'; thing: string[] } => ({ 239 | type: 'fronk', 240 | thing, 241 | }), 242 | }, 243 | }) 244 | ) 245 | 246 | // wrong type for redux state 247 | expectError( 248 | addRoute({ 249 | name: 'hi', 250 | path: '/hi/:there/:arr', 251 | parent: '', 252 | stateFromParams: ({ there, arr }: { there: string; arr: string }) => { 253 | return { thing: [there, arr] } 254 | }, 255 | paramsFromState: (state: 5) => { 256 | return { 257 | there: 'there', 258 | arr: 'hi', 259 | } 260 | }, 261 | updateState: { 262 | thing: (thing): { type: 'fronk'; thing: string[] } => ({ 263 | type: 'fronk', 264 | thing, 265 | }), 266 | }, 267 | }) 268 | ) 269 | 270 | // wrong keys for params 271 | expectError( 272 | addRoute({ 273 | name: 'hi', 274 | path: '/hi/:there/:arr', 275 | parent: '', 276 | stateFromParams: ({ there, arr }: { there: string; arr: string }) => { 277 | return { thing: [there, arr] } 278 | }, 279 | paramsFromState: state => { 280 | return { 281 | wrong: state.routing.location.pathname, 282 | arr: state.routing.location.hash, 283 | } 284 | }, 285 | updateState: { 286 | thing: (thing): { type: 'fronk'; thing: string[] } => ({ 287 | type: 'fronk', 288 | thing, 289 | }), 290 | }, 291 | }) 292 | ) 293 | 294 | // wrong keys for updateState 295 | expectError( 296 | addRoute({ 297 | name: 'hi', 298 | path: '/hi/:there/:arr', 299 | parent: '', 300 | stateFromParams: ({ there, arr }: { there: string; arr: string }) => { 301 | return { thing: [there, arr] } 302 | }, 303 | paramsFromState: state => { 304 | return { 305 | there: state.routing.location.pathname, 306 | arr: state.routing.location.hash, 307 | } 308 | }, 309 | updateState: { 310 | wrong: (thing: any): { type: 'fronk'; thing: string[] } => ({ 311 | type: 'fronk', 312 | thing, 313 | }), 314 | }, 315 | }) 316 | ) 317 | 318 | // doesn't return an action 319 | expectError( 320 | addRoute({ 321 | name: 'hi', 322 | path: '/hi/:there/:arr', 323 | parent: '', 324 | stateFromParams: ({ there, arr }: { there: string; arr: string }) => { 325 | return { thing: [there, arr] } 326 | }, 327 | paramsFromState: state => { 328 | return { 329 | there: state.routing.location.pathname, 330 | arr: state.routing.location.hash, 331 | } 332 | }, 333 | updateState: { 334 | there: (thing: any) => 1, 335 | }, 336 | }) 337 | ) 338 | -------------------------------------------------------------------------------- /test/helpers.test.ts: -------------------------------------------------------------------------------- 1 | import RouteParser from 'route-parser' 2 | import * as actions from '../src/actions' 3 | import * as enhancers from '../src/enhancers' 4 | import * as helpers from '../src/helpers' 5 | import * as index from '../src' 6 | import reducer from '../src/reducer' 7 | import { IonRouterOptions } from '../src' 8 | import { string } from 'prop-types' 9 | 10 | describe('helper functions', () => { 11 | test('filter', () => { 12 | expect( 13 | helpers.filter( 14 | { 15 | hi: enhancers.enhanceRoute({ 16 | name: 'hi', 17 | path: '/hi(/:there)', 18 | }), 19 | }, 20 | '/hi/boo' 21 | )('hi') 22 | ).toEqual({ there: 'boo' }) 23 | }) 24 | test('diff', () => { 25 | expect(helpers.diff([], [])).toEqual([]) 26 | expect(helpers.diff(['hi'], ['hi'])).toEqual([]) 27 | expect(helpers.diff(['hi'], [])).toEqual(['hi']) 28 | expect(helpers.diff([], ['hi'])).toEqual([]) 29 | }) 30 | test('template', () => { 31 | expect( 32 | helpers.template( 33 | { 34 | name: 'a' as const, 35 | path: 'a', 36 | params: { 37 | hi: '5', 38 | }, 39 | state: { 40 | hi: 5, 41 | }, 42 | parent: 'b', 43 | stateFromParams: params => params, 44 | updateState: { 45 | hi: hi => ({ type: 'hi', hi }), 46 | }, 47 | paramsFromState: s => s.routing.location, 48 | '@parser': new RouteParser(''), 49 | exitParams: {}, 50 | }, 51 | undefined 52 | ) 53 | ).toEqual({}) 54 | expect( 55 | helpers.template( 56 | { 57 | name: 'a' as const, 58 | path: 'a', 59 | params: { 60 | foo: '5', 61 | }, 62 | state: { 63 | hi: 5, 64 | }, 65 | parent: 'b', 66 | stateFromParams: params => params, 67 | updateState: { 68 | hi: hi => ({ type: 'hi', hi }), 69 | }, 70 | paramsFromState: s => ({ foo: s.routing.location.pathname }), 71 | '@parser': new RouteParser<{ foo: string }>(':foo'), 72 | exitParams: p => ({ ...p, hi: 'there' }), 73 | }, 74 | { foo: 'bar' } 75 | ) 76 | ).toEqual({ 77 | foo: 'bar', 78 | hi: 'there', 79 | }) 80 | }) 81 | test('changed', () => { 82 | expect( 83 | helpers.changed( 84 | { 85 | hi: 'there', 86 | }, 87 | { 88 | hi: 'there', 89 | } 90 | ) 91 | ).toEqual([]) 92 | expect( 93 | helpers.changed( 94 | { 95 | hi: 'f', 96 | boo: 'boo', 97 | }, 98 | { 99 | hi: 'there', 100 | } 101 | ) 102 | ).toEqual(['hi', 'boo']) 103 | expect( 104 | helpers.changed( 105 | { 106 | hi: 'f', 107 | }, 108 | { 109 | hi: 'there', 110 | boo: 'boo', 111 | } 112 | ) 113 | ).toEqual(['hi', 'boo']) 114 | expect( 115 | helpers.changed( 116 | { 117 | composer: undefined, 118 | piece: undefined, 119 | filter: '', 120 | }, 121 | { 122 | composer: undefined, 123 | piece: undefined, 124 | filter: '', 125 | } 126 | ) 127 | ).toEqual([]) 128 | }) 129 | describe('urlFromState', () => { 130 | const options: IonRouterOptions['routerOptions'] = { 131 | isServer: false, 132 | enhancedRoutes: {}, 133 | } 134 | const action = index.synchronousMakeRoutes( 135 | [ 136 | { 137 | name: 'hi', 138 | path: '*a/bar/:test', 139 | paramsFromState: state => ({ a: state.hia, test: state.test }), 140 | stateFromParams: params => params, 141 | }, 142 | { 143 | name: 'there', 144 | path: '/foo/*b', 145 | }, 146 | { 147 | name: 'three', 148 | path: '/foo/:bar/*a', 149 | paramsFromState: state => ({ bar: state.bar, a: state.threet }), 150 | stateFromParams: params => params, 151 | }, 152 | ], 153 | options 154 | ) 155 | const state = { 156 | routing: { 157 | ...reducer(undefined, action), 158 | matchedRoutes: ['hi', 'there', 'three'], 159 | location: { 160 | pathname: '/foo/bar/t', 161 | search: '', 162 | hash: '', 163 | }, 164 | }, 165 | hia: 'foo', 166 | test: 'tenth', 167 | bar: 'barb', 168 | threet: 't', 169 | } 170 | test('normal', () => { 171 | expect(helpers.urlFromState(options.enhancedRoutes, state)).toEqual({ 172 | newEnhancedRoutes: { 173 | ...options.enhancedRoutes, 174 | hi: { 175 | ...options.enhancedRoutes.hi, 176 | params: { a: 'foo', test: 'tenth' }, 177 | state: { a: 'foo', test: 'tenth' }, 178 | }, 179 | three: { 180 | ...options.enhancedRoutes.three, 181 | params: { bar: 'barb', a: 't' }, 182 | state: { bar: 'barb', a: 't' }, 183 | }, 184 | }, 185 | toDispatch: [ 186 | actions.setParamsAndState( 187 | 'hi', 188 | { a: 'foo', test: 'tenth' }, 189 | { a: 'foo', test: 'tenth' } 190 | ), 191 | actions.setParamsAndState( 192 | 'three', 193 | { bar: 'barb', a: 't' }, 194 | { bar: 'barb', a: 't' } 195 | ), 196 | actions.push('foo/bar/tenth'), 197 | actions.matchRoutes(['hi']), 198 | actions.exitRoutes(['there', 'three']), 199 | ], 200 | }) 201 | }) 202 | test('urlFromState, no change to url', () => { 203 | const newState = { 204 | ...state, 205 | routing: { 206 | ...[ 207 | actions.setParamsAndState( 208 | 'hi', 209 | { a: 'foo', test: 'tenth' }, 210 | { a: 'foo', test: 'tenth' } 211 | ), 212 | actions.setParamsAndState( 213 | 'three', 214 | { bar: 'barb', a: 't' }, 215 | { bar: 'barb', a: 't' } 216 | ), 217 | actions.push('foo/bar/tenth'), 218 | actions.matchRoutes(['hi']), 219 | actions.exitRoutes(['there', 'three']), 220 | ].reduce((a, b) => reducer(a, b), state.routing), 221 | location: { 222 | pathname: 'foo/bar/tenth', 223 | hash: '', 224 | search: '', 225 | }, 226 | }, 227 | } 228 | const opts = { 229 | ...options, 230 | enhancedRoutes: { 231 | ...options.enhancedRoutes, 232 | hi: { 233 | ...options.enhancedRoutes.hi, 234 | params: { a: 'foo', test: 'tenth' }, 235 | state: { a: 'foo', test: 'tenth' }, 236 | }, 237 | }, 238 | } 239 | expect(helpers.urlFromState(opts.enhancedRoutes, newState)).toEqual({ 240 | newEnhancedRoutes: opts.enhancedRoutes, 241 | toDispatch: [], 242 | }) 243 | }) 244 | }) 245 | test('getStateUpdates', () => { 246 | expect( 247 | helpers.getStateUpdates( 248 | { 249 | state: { 250 | a: 1, 251 | b: 2, 252 | c: 3, 253 | }, 254 | updateState: { 255 | a: (a, s) => ({ type: 'a', a, b: s.b }), 256 | b: b => ({ type: 'b', b }), 257 | }, 258 | name: 'a' as const, 259 | path: 'a', 260 | parent: undefined, 261 | params: { 262 | hi: '5', 263 | }, 264 | '@parser': new RouteParser(''), 265 | stateFromParams: ({ 266 | a, 267 | b, 268 | c, 269 | }: { 270 | a: string 271 | b: string 272 | c: string 273 | }) => ({ a: +a, b: +b, c: +c }), 274 | paramsFromState: s => s.routing.location, 275 | }, 276 | { 277 | a: 2, 278 | b: 2, 279 | c: 5, 280 | } 281 | ) 282 | ).toEqual([{ type: 'a', a: 2, b: 2 }]) 283 | }) 284 | test('exitRoute', () => { 285 | expect( 286 | helpers.exitRoute( 287 | { 288 | routing: reducer(undefined, undefined), 289 | }, 290 | { 291 | a: { 292 | name: 'a' as const, 293 | path: 'a', 294 | params: { 295 | hi: '5', 296 | }, 297 | state: { 298 | hi: 5, 299 | }, 300 | parent: 'b', 301 | exitParams: { 302 | hi: undefined, 303 | }, 304 | stateFromParams: params => params, 305 | updateState: { 306 | hi: hi => ({ type: 'hi', hi }), 307 | }, 308 | paramsFromState: s => s.routing.location, 309 | '@parser': new RouteParser(''), 310 | }, 311 | b: { 312 | name: 'b' as const, 313 | path: 'b', 314 | params: { 315 | there: '6', 316 | }, 317 | state: { 318 | there: 6, 319 | }, 320 | parent: 'c', 321 | exitParams: { 322 | there: undefined, 323 | }, 324 | stateFromParams: params => params, 325 | updateState: { 326 | there: there => ({ type: 'there', there }), 327 | }, 328 | paramsFromState: s => s.routing.location, 329 | '@parser': new RouteParser(''), 330 | }, 331 | c: { 332 | name: 'c' as const, 333 | path: 'c', 334 | parent: undefined, 335 | params: { 336 | booboo: '1', 337 | }, 338 | state: { 339 | booboo: 1, 340 | }, 341 | exitParams: { 342 | booboo: undefined, 343 | }, 344 | stateFromParams: params => params, 345 | updateState: { 346 | booboo: booboo => ({ type: 'booboo', booboo }), 347 | }, 348 | paramsFromState: s => s.routing.location, 349 | '@parser': new RouteParser(''), 350 | }, 351 | }, 352 | 'a' 353 | ) 354 | ) 355 | }) 356 | }) 357 | --------------------------------------------------------------------------------