├── 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 |
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 |
2 |
3 | # ion-router
4 | ###### Connecting your url and redux state
5 |
6 | [](https://codeclimate.com/github/cellog/ion-router) [](https://codeclimate.com/github/cellog/ion-router/coverage) [](https://travis-ci.org/cellog/ion-router) [](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)
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 |
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 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------