├── .babelrc
├── .gitignore
├── .npmignore
├── LICENSE
├── README.md
├── examples
├── public
│ ├── 404.html
│ └── index.html
└── src
│ ├── app
│ ├── App.js
│ ├── Home.js
│ ├── Locations.js
│ ├── NotFound.js
│ └── index.js
│ └── items
│ ├── Item.js
│ ├── ItemList.js
│ └── ItemMocks.js
├── package-lock.json
├── package.json
├── src
├── Location.js
└── parseUtils.js
├── tests
├── ctor.js
├── integration.js
├── parseLocationParams.js
├── toRoute.js
└── toUrl.js
└── webpack.config.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["@babel/env", "@babel/react"],
3 | "plugins": ["@babel/plugin-proposal-class-properties"]
4 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .vs
3 | dist
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | src
2 | tests
3 | examples
4 | .babelrc
5 | .gitignore
6 | webpack.config.js
7 | .vs
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 - Present Xovation Corporation
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-app-location
2 | Declarative locations for React apps. Avoids repetition with Routes and Links, and reduces boilerplate with parsing and casting parameters from URLs.
3 |
4 |
5 |
6 |
7 |
8 | This package depends on React Router 4. If you are not using React Router 4, take a look at [app-location](https://github.com/bradstiff/app-location), which is router-agnostic.
9 |
10 | ## Install
11 | `npm install react-app-location --save`
12 |
13 | ## Usage
14 | A `Location` is an endpoint that your app supports. It specifies a path, and can optionally specify path and query string parameters.
15 |
16 | A `Location` keeps your code DRY as the `Location` is defined in one place and used throughout your code to generate `Routes`, `Links` and URLs.
17 |
18 | When generating a `Link` or URL, you can provide a literal object of values, and the values will be mapped to path and query string parameters and inserted into the resulting URL.
19 |
20 | Path and query string parameters are specified as Yup schemas. A `Route` that is generated from a `Location` automatically parses the URL and extracts
21 | the path and query string parameters. These are validated according to the schema, cast to the appropriate data types, and passed as props to your
22 | component. If a required parameter is missing or a parameter fails validation, the `Route` will render the specified ` ` component.
23 | This eliminates a boatload of boilerplate.
24 |
25 | ```javascript
26 | import React from "react";
27 | import { Link, BrowserRouter, Switch, Route } from 'react-router-dom';
28 | import * as Yup from 'yup';
29 | import Location from "react-app-location";
30 |
31 | const HomeLocation = new Location('/');
32 | const ArticleLocation = new Location('/articles/:id', { id: Yup.number().integer().positive().required() });
33 |
34 | const App = () => (
35 |
36 |
37 | {/* Regular Route */}
38 |
39 | {/* Route with params automatically passed as props to your component */}
40 | {ArticleLocation.toRoute({ component: Article, invalid: NotFound }, true)}
41 |
42 |
43 |
44 | );
45 |
46 | const Home = () => (
47 |
48 |
49 |
50 | {/* Article 1 */}
51 | {ArticleLocation.toLink('Article 1', {id: 1})}
52 | {/* Article 2 */}
53 | {ArticleLocation.toLink('Article 2', {id: 2})}
54 | {/* Also works */}
55 | Article 3
56 | {/* Clicking results in */}
57 | Article 4
58 | {/* Also results in */}
59 | Article 5
60 |
61 |
62 | );
63 |
64 | //id has been parsed from the URL, cast to int, and merged into props
65 | const Article = ({id}) => ;
66 |
67 | const NotFound = () => (
68 |
69 |
70 |
Looks like you have followed a broken link or entered a URL that does not exist on this site.
71 |
72 | );
73 | ```
74 |
75 | ## API
76 | **`Location.ctor(path: string, pathParamDefs: ?schema, queryStringParamDefs: ?schema): Location`**
77 |
78 | Defines a `Location`. pathParamDefs and queryStringParamDefs are optional and specified as Yup schemas.
79 |
80 | **`Location.toUrl(params: ?object): string`**
81 |
82 | Builds a URL with param values plugged in.
83 |
84 | **`Location.toLink(children: func || node, params: ?object, props: ?object): element`**
85 |
86 | Generates a React Router 4 `Link` passing the generated URL as the `to` prop.
87 |
88 | Location.toRoute(
89 | renderOptions: {
90 | component: ?func,
91 | render: ?func,
92 | children: ?func || ?node,
93 | invalid: func
94 | },
95 | exact: bool = false,
96 | strict: bool = false,
97 | sensitive: bool = false
98 | ): element
99 |
100 | Generates a React Router 4 `Route` which parses params and passes them as props to your component.
101 |
102 | **`Location.path: string`**
103 |
104 | Returns the path property which you can use when building a Route by hand, e.g., without params passed as props.
105 |
106 | **`Location.parseLocationParams(location: object = window.location, ?match: object) : object`**
107 |
108 | Returns a literal object containing the parameters parsed from the React Router 4 `location` (or window.location) and `match` (optional) props. Each parameter is validated and cast to the data type indicated in the schema. If validation fails, returns null.
109 |
110 | You can manually call `parseLocationParams` from within your component to get the location parameters if you prefer to not use the automatic param parsing and prop injection provided by `Location.toRoute`.
111 |
112 | **`Location.isValidParams(params: ?object): boolean`**
113 |
114 | Returns a boolean indicating if the parameters are valid.
115 |
116 | ## Try it out
117 | ### Online
118 | [Demo](https://bradstiff.github.io/react-app-location/)
119 |
120 | ### Local
121 | 1. `git clone https://github.com/bradstiff/react-app-location.git`
122 | 2. `cd react-app-location`
123 | 3. `npm install`
124 | 4. `npm start`
125 | 5. Browse to http://localhost:3001
126 |
127 | ## Contribute
128 | You're welcome to contribute to react-app-location.
129 |
130 | To set up the project:
131 |
132 | 1. Fork and clone the repository
133 | 2. `npm install`
134 |
135 | The project supports three workflows, described below.
136 |
137 | ### Developing, testing and locally demoing the component
138 | Source and tests are located in the /src and /test folders.
139 |
140 | To test: `npm run test`.
141 |
142 | To run the demo, `npm start`. The demo can be seen at http://localhost:3001 in watch mode.
143 |
144 | ### Publishing the module to npm
145 | `npm publish`
146 |
147 | This will use babel to transpile the component source, and publish the component and readme to npm.
148 |
149 | ### Publishing the demo to github-pages
150 | `npm run publish-demo`
151 |
152 | This will build a production version of the demo and publish it to your github-pages site, in the react-app-location directory.
153 |
154 | Note that webpack.config is set up to handle the fact the demo lives in a directory.
155 |
156 | Also note that github-pages does not support routers that use HTML5 pushState history API. There are special scripts added to index.html and 404.html to redirect server requests for nested routes to the home page.
--------------------------------------------------------------------------------
/examples/public/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Single Page Apps for GitHub Pages
6 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/examples/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | React App Location Demo
4 |
5 |
6 |
7 |
36 |
37 |
38 |
39 |
40 | You need to enable JavaScript to run this app.
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/examples/src/app/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link, BrowserRouter, Switch, Route } from 'react-router-dom';
3 |
4 | import Locations from './Locations';
5 | import Home from './Home';
6 | import NotFound from './NotFound';
7 |
8 | import ItemList from '../items/ItemList';
9 | import Item from '../items/Item';
10 |
11 | const styles = {
12 | container: {
13 | display: 'flex',
14 | width: 500,
15 | margin: 'auto',
16 | minHeight: 300,
17 | },
18 | navContainer: {
19 | width: 60,
20 | flex: 'none',
21 | display: 'flex',
22 | flexDirection: 'column',
23 | marginRight: 20,
24 | backgroundColor: 'silver',
25 | },
26 | nav: {
27 | listStyle: 'none',
28 | paddingLeft: 5,
29 | },
30 | link: {
31 | },
32 | };
33 |
34 | export default () => (
35 |
36 |
37 |
38 |
39 | Home
40 | Items
41 |
42 |
43 |
44 | {Locations.Home.toRoute({ component: Home, invalid: NotFound }, true)}
45 | {Locations.ItemList.toRoute({ component: ItemList, invalid: NotFound }, true)}
46 | {Locations.Item.toRoute({ component: Item, invalid: NotFound }, true)}
47 |
48 |
49 |
50 |
51 | );
52 |
--------------------------------------------------------------------------------
/examples/src/app/Home.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default () => (
4 |
7 | );
--------------------------------------------------------------------------------
/examples/src/app/Locations.js:
--------------------------------------------------------------------------------
1 | import * as Yup from 'yup';
2 | import Location from '../../../src/Location';
3 |
4 | const integer = Yup.number().integer();
5 | const wholeNbr = integer.positive();
6 |
7 | export const HomeLocation = new Location('/');
8 |
9 | export const ItemListLocation = new Location('/items', null, {
10 | isActive: Yup.boolean(),
11 | categoryID: wholeNbr.nullable(),
12 | });
13 |
14 | export const ItemLocation = new Location('/items/:id', { id: wholeNbr.required() });
15 |
16 | export default {
17 | Home: HomeLocation,
18 | ItemList: ItemListLocation,
19 | Item: ItemLocation,
20 | };
--------------------------------------------------------------------------------
/examples/src/app/NotFound.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 | import PropTypes from 'prop-types';
4 | import Locations from './Locations';
5 |
6 | const NotFound = () => (
7 |
8 |
9 |
Page Not Found
10 | Looks like you've followed a broken link or entered a URL that doesn't exist on this site.
11 |
12 | < Back to Home
13 |
14 |
15 |
16 | );
17 |
18 | NotFound.propTypes = {
19 | message: PropTypes.string,
20 | }
21 |
22 | export default NotFound;
--------------------------------------------------------------------------------
/examples/src/app/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from './App';
4 |
5 | ReactDOM.render( , document.getElementById('root'));
6 |
--------------------------------------------------------------------------------
/examples/src/items/Item.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Items, itemCategory, itemStatus, Categories } from './ItemMocks';
3 |
4 | export default ({ id }) => {
5 | const item = Items.find(item => item.id === id);
6 |
7 | if (!item) {
8 | return (
9 |
10 | Item does not exist
11 |
12 | );
13 | }
14 | return
15 |
18 |
19 |
20 |
21 | Category:
22 | {itemCategory(item)}
23 |
24 |
25 | Status:
26 | {itemStatus(item)}
27 |
28 |
29 |
30 |
;
31 | };
32 |
--------------------------------------------------------------------------------
/examples/src/items/ItemList.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 | import { ItemListLocation, ItemLocation } from '../app/Locations';
4 | import { Items, itemCategory, itemStatus, Categories } from './ItemMocks';
5 |
6 | const styles = {
7 | toolbar: {
8 | display: 'flex',
9 | alignItems: 'center',
10 | },
11 | filter: {
12 | paddingLeft: 20,
13 | }
14 | }
15 |
16 | class ItemList extends React.Component {
17 | replaceLocation(token) {
18 | const { isActive, categoryID } = this.props;
19 | const tokens = {
20 | isActive,
21 | categoryID
22 | };
23 | const nextLocation = ItemListLocation.toUrl({
24 | ...tokens,
25 | ...token
26 | });
27 | this.props.history.replace(nextLocation);
28 | }
29 |
30 | handleSelectStatus = event => {
31 | const isActive = event.target.value;
32 | this.replaceLocation({ isActive });
33 | }
34 |
35 | handleSelectCategory = event => {
36 | const categoryID = event.target.value;
37 | this.replaceLocation({ categoryID });
38 | }
39 |
40 | render() {
41 | const { isActive, categoryID } = this.props;
42 | return (
43 |
44 |
60 |
61 |
62 |
63 | Name
64 | Category
65 | Status
66 |
67 |
68 |
69 | {Items
70 | .filter(item => (isActive === undefined || item.isActive === isActive) && (categoryID === undefined || item.categoryID === categoryID))
71 | .map(item => (
72 |
73 | {ItemLocation.toLink(item.name, { id: item.id })}
74 | {itemCategory(item)}
75 | {itemStatus(item)}
76 |
77 | ))
78 | }
79 |
80 |
81 |
82 | );
83 | }
84 | };
85 |
86 | export default ItemList;
--------------------------------------------------------------------------------
/examples/src/items/ItemMocks.js:
--------------------------------------------------------------------------------
1 | export const Categories = [
2 | { id: 1, name: 'Regular Items' },
3 | { id: 2, name: 'Fancy Items' },
4 | ];
5 |
6 | export const itemCategory = item => Categories.find(category => category.id === item.categoryID).name;
7 | export const itemStatus = item => item.isActive ? 'Active' : 'Inactive';
8 |
9 | export const Items = [
10 | { id: 1, name: 'Item 1', isActive: true, categoryID: 1, },
11 | { id: 2, name: 'Item 2', isActive: true, categoryID: 2, },
12 | { id: 3, name: 'Item 3', isActive: false, categoryID: 1, },
13 | { id: 4, name: 'Item 4', isActive: false, categoryID: 2, },
14 | ];
15 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-app-location",
3 | "version": "1.2.1",
4 | "description": "A package to avoid repetition with Routes, Links and URLs, and reduce boilerplate with location param parsing in React Apps",
5 | "main": "./dist/Location.js",
6 | "repository": "https://github.com/bradstiff/react-app-location.git",
7 | "scripts": {
8 | "start": "webpack-dev-server --mode development",
9 | "test": "jest",
10 | "transpile": "babel src -d dist --copy-files",
11 | "prepublishOnly": "npm run transpile",
12 | "build": "webpack --mode production && cp examples/public/404.html examples/dist/404.html",
13 | "deploy": "gh-pages -d examples/dist",
14 | "publish-demo": "npm run build && npm run deploy"
15 | },
16 | "keywords": [
17 | "react",
18 | "router",
19 | "route",
20 | "location"
21 | ],
22 | "author": "Brad Stiff",
23 | "license": "MIT",
24 | "devDependencies": {
25 | "@babel/cli": "^7.0.0",
26 | "@babel/core": "^7.0.0",
27 | "@babel/plugin-proposal-class-properties": "^7.0.0",
28 | "@babel/preset-env": "^7.0.0",
29 | "@babel/preset-react": "^7.0.0",
30 | "babel-core": "^7.0.0-bridge.0",
31 | "babel-jest": "^23.4.2",
32 | "babel-loader": "^8.0.1",
33 | "css-loader": "^0.28.11",
34 | "gh-pages": "^1.2.0",
35 | "html-webpack-plugin": "^3.2.0",
36 | "jest": "^23.5.0",
37 | "react": "^16.4.2",
38 | "react-dom": "^16.4.2",
39 | "react-router-dom": "^4.3.1",
40 | "react-testing-library": "^5.0.0",
41 | "regenerator-runtime": "^0.12.1",
42 | "style-loader": "^0.23.0",
43 | "webpack": "^4.17.1",
44 | "webpack-cli": "^3.1.0",
45 | "webpack-dev-server": "^3.1.7",
46 | "yup": "^0.26.3"
47 | },
48 | "peerDependencies": {
49 | "react": ">=16.4.2",
50 | "react-dom": ">=16.4.2",
51 | "react-router-dom": ">=4.3.1",
52 | "yup": ">=0.26.3"
53 | },
54 | "dependencies": {
55 | "app-location": "^1.1.1",
56 | "querystringify": "^2.0.0",
57 | "warning": "^4.0.2"
58 | },
59 | "jest": {
60 | "roots": [
61 | "tests"
62 | ],
63 | "testRegex": "\\.js"
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/Location.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Route, matchPath } from 'react-router';
3 | import { Link } from 'react-router-dom';
4 | import warning from 'warning';
5 |
6 | import LocationCore from 'app-location';
7 | import { parseQueryString } from './parseUtils';
8 |
9 | function isEmptyChildren(children) {
10 | return React.Children.count(children) === 0;
11 | };
12 |
13 | export default class Location extends LocationCore {
14 | constructor(path, pathParamDefs = {}, queryStringParamDefs = {}) {
15 | super(path, pathParamDefs, queryStringParamDefs);
16 | }
17 |
18 | toLink(children, params, props = {}) {
19 | warning(!props.to, 'toLink props should not include a to prop; it will be overwritten');
20 | const linkProps = {
21 | ...props,
22 | to: this.toUrl(params),
23 | };
24 | return {children};
25 | }
26 |
27 | toRoute(renderOptions, exact = false, strict = false, sensitive = false) {
28 | const { component, render, children, invalid } = renderOptions;
29 | warning(component || render || children, 'Location.toRoute requires renderOptions argument, which must include either component, render or children property');
30 | warning(invalid, 'Location.toRoute requires renderOptions argument, which must include an invalid property, indicating the component to render when the a matched location contains an invalid parameter');
31 |
32 | const routeProps = {
33 | path: this.path,
34 | exact,
35 | strict,
36 | sensitive,
37 | };
38 |
39 | const getPropsWithParams = props => {
40 | const { location, match } = props;
41 | const tokens = this.parseLocationParams(location, match);
42 | if (tokens === null) {
43 | return null;
44 | }
45 | //todo: warn about collisions between route params and qs params
46 | return {
47 | ...props,
48 | ...tokens,
49 | };
50 | }
51 |
52 | if (component) {
53 | return {
54 | const propsWithParams = getPropsWithParams(props)
55 | if (propsWithParams === null) {
56 | //schema validation error ocurred, render Invalid component
57 | return React.createElement(invalid);
58 | }
59 | return React.createElement(component, propsWithParams);
60 | }} />
61 | } else if (render) {
62 | return {
63 | const propsWithParams = getPropsWithParams(props)
64 | if (propsWithParams === null) {
65 | //schema validation error ocurred, render Invalid component
66 | return React.createElement(invalid);
67 | }
68 | return render(propsWithParams);
69 | }} />
70 | } else if (typeof children === "function") {
71 | return {
72 | const { match } = props;
73 | if (match) {
74 | const propsWithParams = getPropsWithParams(props)
75 | if (propsWithParams === null) {
76 | //schema validation error ocurred, render Invalid component
77 | return React.createElement(invalid);
78 | }
79 | return children(propsWithParams);
80 | } else {
81 | return children(props);
82 | }
83 | }} />
84 | } else if (children && !isEmptyChildren(children)) {
85 | warning(false, 'Location params are not passed as props to children arrays. Use a children function prop if needed.');
86 | return
87 | }
88 | }
89 |
90 | parseLocationParams(
91 | location = (window && window.location),
92 | match = matchPath(location.pathname, { path: this.path })
93 | ) {
94 | warning(location, 'location must be explicitly provided when window object is not available.');
95 | warning(location.pathname != undefined && location.search != undefined, 'location object must include pathname and search properties.');
96 | warning(location.pathname, 'location.pathname is required.');
97 |
98 | if (!match) {
99 | warning(false, 'location.pathname does not match Location.path.');
100 | return null;
101 | }
102 |
103 | try {
104 | if (!this._paramSchema) {
105 | return {};
106 | }
107 |
108 | const rawParams = {
109 | ...match.params,
110 | ...parseQueryString(location.search),
111 | };
112 | return this._paramSchema.validateSync(rawParams);
113 | } catch (err) {
114 | const { name, errors } = err;
115 | if (name === 'ValidationError') {
116 | warning(false, `Location.parseLocationParams: ${errors[0]}`);
117 | } else {
118 | throw err;
119 | }
120 | return null;
121 | }
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/src/parseUtils.js:
--------------------------------------------------------------------------------
1 | import qs from 'querystringify';
2 |
3 | export function parseQueryString(queryString) {
4 | const queryStringParams = qs.parse(queryString);
5 | for (const key in queryStringParams) {
6 | if (queryStringParams[key] === 'null') {
7 | queryStringParams[key] = null;
8 | } else if (queryStringParams[key] === 'undefined') {
9 | queryStringParams[key] = undefined;
10 | }
11 | }
12 | return queryStringParams;
13 | }
14 |
15 |
--------------------------------------------------------------------------------
/tests/ctor.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, fireEvent, cleanup } from 'react-testing-library';
3 | import * as Yup from 'yup';
4 |
5 | import Location from '../src/Location';
6 |
7 | afterEach(cleanup);
8 |
9 | const isNullableDate = Yup.string().test('is-date', '${path}:${value} is not a valid date', date => !date || !isNaN(Date.parse(date)));
10 | const integer = Yup.number().integer();
11 | const naturalNbr = integer.moreThan(-1);
12 | const wholeNbr = integer.positive();
13 |
14 | test('constructs with no params', () => {
15 | const HomeLocation = new Location('/');
16 | expect(HomeLocation).toBeDefined();
17 | expect(HomeLocation.path).toMatch('/');
18 | })
19 |
20 | test('constructs with path params', () => {
21 | const ResourceLocation = new Location('/resources/:id', { id: wholeNbr.required() });
22 | expect(ResourceLocation).toBeDefined();
23 | expect(ResourceLocation.path).toMatch('/resources/:id');
24 | })
25 |
26 | test('constructs with query string params', () => {
27 | const ResourceListLocation = new Location('/resources', null, {
28 | typeID: wholeNbr.required(),
29 | page: naturalNbr.default(0),
30 | rowsPerPage: Yup.number().oneOf([25, 50, 75, 100]).default(25),
31 | order: Yup.string().oneOf(['asc', 'desc']).default('asc'),
32 | isActive: Yup.boolean(),
33 | categoryID: wholeNbr.nullable(),
34 | });
35 | expect(ResourceListLocation).toBeDefined();
36 | expect(ResourceListLocation.path).toMatch('/resources');
37 | })
38 |
39 |
40 |
--------------------------------------------------------------------------------
/tests/integration.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { withRouter } from 'react-router';
3 | import { Link, Route, Router, Switch } from 'react-router-dom';
4 | import { createMemoryHistory } from 'history';
5 | import { render, fireEvent, cleanup } from 'react-testing-library';
6 | import * as Yup from 'yup';
7 |
8 | import Location from '../src/Location';
9 |
10 | const isNullableDate = Yup.string().test('is-date', '${path}:${value} is not a valid date', date => !date || !isNaN(Date.parse(date)));
11 | const string = Yup.string();
12 | const integer = Yup.number().integer();
13 | const naturalNbr = integer.moreThan(-1);
14 | const wholeNbr = integer.positive();
15 |
16 | const HomeLocation = new Location('/');
17 | const AboutLocation = new Location('/about');
18 | const AboutStrictLocation = new Location('/about/');
19 | const ResourceListLocation = new Location('/resources', null, {
20 | typeID: wholeNbr.required(),
21 | page: naturalNbr.default(0),
22 | rowsPerPage: Yup.number().oneOf([25, 50, 75, 100]).default(25),
23 | order: Yup.string().oneOf(['asc', 'desc']).default('asc'),
24 | isActive: Yup.boolean(),
25 | categoryID: wholeNbr.nullable(),
26 | });
27 | const ResourceLocation = new Location('/resources/:id', { id: wholeNbr.required() }, { date: isNullableDate });
28 | const ProtectedResourceLocation = new Location('/protectedResources/:id', { id: wholeNbr.required() }, { date: isNullableDate });
29 |
30 | //placeholder for parsed, type-cast props received by matching location's component
31 | //strictly used for test verification
32 | let receivedProps = {};
33 |
34 | //allow access to protected routes
35 | let isAuthorized = false;
36 |
37 | const Home = () => Home
;
38 | const About = () => About
;
39 |
40 | const ResourceList = ({ typeID, page, rowsPerPage, order, orderBy, isActive, categoryID, name, location }) => {
41 | //save the received props for subsequent test verification
42 | receivedProps = {
43 | typeID,
44 | page,
45 | rowsPerPage,
46 | order,
47 | isActive,
48 | };
49 | return Resource List
;
50 | };
51 |
52 | const Resource = ({ id, date }) => {
53 | //save the received props for subsequent test verification
54 | receivedProps = {
55 | id,
56 | date
57 | };
58 | return Resource
;
59 | };
60 |
61 | const NotFound = () => No match
;
62 | const NotAuthorized = () => Not authorized
;
63 |
64 | function ifAuthorized(test, protectedComponent, notAuthorizedComponent) {
65 | return test
66 | ? protectedComponent
67 | : notAuthorizedComponent;
68 | }
69 |
70 | function LocationTest() {
71 | return (
72 |
73 |
Home
74 |
About
75 |
76 | {HomeLocation.toRoute({ component: Home, invalid: NotFound }, true)}
77 | {AboutLocation.toRoute({ render: () => , invalid: NotFound }, true)}
78 | {ResourceListLocation.toRoute({ component: ResourceList, invalid: NotFound }, true)}
79 | {ResourceLocation.toRoute({ component: Resource, invalid: NotFound }, true)}
80 | {ProtectedResourceLocation.toRoute({ component: ifAuthorized(isAuthorized, Resource, NotAuthorized), invalid: NotFound }, true)}
81 |
82 |
83 |
84 | )
85 | }
86 |
87 | afterEach(() => {
88 | cleanup();
89 | receivedProps = {};
90 | isAuthorized = false;
91 | });
92 |
93 | function renderWithRouter(ui, url = '/') {
94 | const history = createMemoryHistory({ initialEntries: [url] });
95 | return {
96 | ...render({ui} ),
97 | history,
98 | }
99 | }
100 |
101 | test('navigates from one matched location to another, first uses component prop, second uses render prop', () => {
102 | const { container, getByText } = renderWithRouter( );
103 | expect(container.innerHTML).toMatch('Home');
104 | const leftClick = { button: 0 };
105 | fireEvent.click(getByText(/about/i), leftClick);
106 | expect(container.innerHTML).toMatch('About');
107 | })
108 |
109 | test('renders on navigating to non-matching URL', () => {
110 | const { container } = renderWithRouter( , '/should-not-match');
111 | expect(container.innerHTML).toMatch('No match');
112 | })
113 |
114 | test('builds and parses URL with int path param and supplied optional qs param', () => {
115 | const locationParams = { id: 1, date: '2018-08-20' };
116 | const serializedUrl = ResourceLocation.toUrl(locationParams);
117 | expect(serializedUrl).toBe('/resources/1?date=2018-08-20');
118 |
119 | const { container } = renderWithRouter( , serializedUrl);
120 | expect(container.innerHTML).toMatch('Resource');
121 | expect(receivedProps.id).toBe(1);
122 | expect(receivedProps.date).toBe('2018-08-20');
123 | })
124 |
125 | test('builds and parses URL with int path param and omitted optional qs param', () => {
126 | const locationParams = { id: 1 };
127 | const serializedUrl = ResourceLocation.toUrl(locationParams);
128 | expect(serializedUrl).toBe('/resources/1');
129 |
130 | const { container } = renderWithRouter( , serializedUrl);
131 | expect(container.innerHTML).toMatch('Resource');
132 | expect(receivedProps.id).toBe(1);
133 | expect(receivedProps.date).toBe(undefined);
134 | })
135 |
136 | test('renders on URL with invalid int path param', () => {
137 | jest.spyOn(global.console, "error").mockImplementation(() => { }) //suppress console output
138 | const { container } = renderWithRouter( , '/resources/a');
139 | expect(container.innerHTML).toMatch('No match');
140 | })
141 |
142 | test('renders on URL with invalid date qs param', () => {
143 | jest.spyOn(global.console, "error").mockImplementation(() => { }) //suppress console output
144 | const { container } = renderWithRouter( , '/resources/1?date=2018-123-123');
145 | expect(container.innerHTML).toMatch('No match');
146 | })
147 |
148 | test('builds and parses URL with omitted-with-default qs params', () => {
149 | const serializedUrl = ResourceListLocation.toUrl({ typeID: 2 });
150 | const { container } = renderWithRouter( , serializedUrl);
151 | //but are provided as component props
152 | expect(container.innerHTML).toMatch('Resource List');
153 | expect(receivedProps.typeID).toBe(2);
154 | expect(receivedProps.page).toBe(0);
155 | expect(receivedProps.rowsPerPage).toBe(25);
156 | expect(receivedProps.order).toBe('asc');
157 | expect(receivedProps.isActive).toBe(undefined);
158 | })
159 |
160 | test('builds and parses URL with supplied qs params', () => {
161 | const locationParams = {
162 | typeID: 2,
163 | page: 1,
164 | rowsPerPage: 50,
165 | order: 'desc',
166 | isActive: true,
167 | };
168 | const serializedUrl = ResourceListLocation.toUrl(locationParams);
169 | const { container } = renderWithRouter( , serializedUrl);
170 | expect(container.innerHTML).toMatch('Resource List');
171 | expect(receivedProps.typeID).toBe(2);
172 | expect(receivedProps.page).toBe(1);
173 | expect(receivedProps.rowsPerPage).toBe(50);
174 | expect(receivedProps.order).toBe('desc');
175 | expect(receivedProps.isActive).toBe(true);
176 | })
177 |
178 | test('renders on parsing URL with missing required qs params', () => {
179 | jest.spyOn(global.console, "error").mockImplementation(() => { }) //suppress console output
180 | const { container } = renderWithRouter( , '/resources?categoryID=2');
181 | expect(container.innerHTML).toMatch('No match');
182 | })
183 |
184 | test('renders on parsing URL with invalid qs params', () => {
185 | jest.spyOn(global.console, "error").mockImplementation(() => { }) //suppress console output
186 | const { container } = renderWithRouter( , '/resources?rowsPerPage=10');
187 | expect(container.innerHTML).toMatch('No match');
188 | })
189 |
190 | test('strict, insensitive match', () => {
191 | const StrictRoute = () => (
192 |
193 | {AboutStrictLocation.toRoute({ component: About, invalid: NotFound }, false, true)}
194 |
195 |
196 | );
197 | const { container } = renderWithRouter( , '/About/');
198 | expect(container.innerHTML).toMatch('About');
199 | })
200 |
201 | test('strict no match', () => {
202 | const StrictRoute = () => (
203 |
204 | {AboutStrictLocation.toRoute({ component: About, invalid: NotFound }, false, true)}
205 |
206 |
207 | );
208 | const { container } = renderWithRouter( , '/about');
209 | expect(container.innerHTML).toMatch('No match');
210 | })
211 |
212 | test('sensitive match', () => {
213 | const SensitiveRoute = () => (
214 |
215 | {AboutLocation.toRoute({ component: About, invalid: NotFound }, false, false, true)}
216 |
217 |
218 | );
219 | const { container } = renderWithRouter( , '/about/');
220 | expect(container.innerHTML).toMatch('About');
221 | })
222 |
223 | test('sensitive no match', () => {
224 | const SensitiveRoute = () => (
225 |
226 | {AboutLocation.toRoute({ component: About, invalid: NotFound }, false, false, true)}
227 |
228 |
229 | );
230 | const { container } = renderWithRouter( , '/About');
231 | expect(container.innerHTML).toMatch('No match');
232 | })
233 |
234 | test('children match', () => {
235 | const ChildrenRoute = () => (
236 |
237 | {ResourceLocation.toRoute({
238 | children: (props) => ,
239 | invalid: NotFound
240 | }, true)}
241 |
242 |
243 | );
244 | const { container, debug } = renderWithRouter( , '/resources/1');
245 | expect(container.innerHTML).toMatch('Resource');
246 | expect(receivedProps.id).toBe(1);
247 | })
248 |
249 | test('children no match, renders anyway without param parsing', () => {
250 | const ChildrenRoute = () => (
251 | ResourceLocation.toRoute({
252 | children: (props) => ,
253 | invalid: NotFound
254 | }, true)
255 | );
256 | const { container, debug } = renderWithRouter( , '/does-not-match');
257 | expect(container.innerHTML).toMatch('Resource');
258 | })
259 |
260 | test('bypass Location.toRoute', () => {
261 | const locationParams = { id: 1, date: '2018-08-20' };
262 | const serializedUrl = ResourceLocation.toUrl(locationParams);
263 | const ResourceWithManualParams = ({ location, match }) => {
264 | const { id, date } = ResourceLocation.parseLocationParams(location, match);
265 | //save the received props for subsequent test verification
266 | receivedProps = {
267 | id,
268 | date
269 | };
270 | return Resource
;
271 | };
272 | const ResourceRoute = () => ;
273 | const { container } = renderWithRouter( , serializedUrl);
274 | expect(container.innerHTML).toMatch('Resource');
275 | expect(receivedProps.id).toBe(1);
276 | expect(receivedProps.date).toMatch('2018-08-20');
277 | })
278 |
279 | test('renders protected component when authorized', () => {
280 | isAuthorized = true;
281 | const { container } = renderWithRouter( , '/protectedResources/1');
282 | expect(container.innerHTML).toMatch('Resource');
283 | })
284 |
285 | test('renders not authorized component when not authorized', () => {
286 | isAuthorized = false;
287 | const { container } = renderWithRouter( , '/protectedResources/1');
288 | expect(container.innerHTML).toMatch('Not authorized');
289 | })
290 |
--------------------------------------------------------------------------------
/tests/parseLocationParams.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, fireEvent, cleanup } from 'react-testing-library';
3 | import * as Yup from 'yup';
4 |
5 | import Location from '../src/Location';
6 |
7 | const isNullableDate = Yup.string().test('is-date', '${path}:${value} is not a valid date', date => !date || !isNaN(Date.parse(date)));
8 | const integer = Yup.number().integer();
9 | const naturalNbr = integer.moreThan(-1);
10 | const wholeNbr = integer.positive();
11 |
12 | const ResourceListLocation = new Location('/resources', null, {
13 | typeID: wholeNbr.required(),
14 | page: naturalNbr.default(0),
15 | rowsPerPage: Yup.number().oneOf([25, 50, 75, 100]).default(25),
16 | order: Yup.string().oneOf(['asc', 'desc']).default('asc'),
17 | isActive: Yup.boolean(),
18 | categoryID: wholeNbr.nullable(),
19 | });
20 | const ResourceLocation = new Location('/resources/:id', { id: wholeNbr.required() }, { date: isNullableDate });
21 |
22 | afterEach(cleanup);
23 |
24 | test('parses URL with path param', () => {
25 | const location = {
26 | pathname: '/resources/1',
27 | search: '',
28 | }
29 | const match = {
30 | params: {
31 | id: '1'
32 | }
33 | };
34 |
35 | const params = ResourceLocation.parseLocationParams(location, match);
36 | expect(params).toMatchObject({ id: 1 });
37 | })
38 |
39 | test('parses URL with path param, match is omitted', () => {
40 | const location = {
41 | pathname: '/resources/1',
42 | search: '',
43 | }
44 | const match = {
45 | params: {
46 | id: '1'
47 | }
48 | };
49 |
50 | const params = ResourceLocation.parseLocationParams(location, match);
51 | expect(params).toMatchObject({ id: 1 });
52 | })
53 |
54 | test('errors on parsing a URL with missing required path params', () => {
55 | jest.spyOn(global.console, "error").mockImplementation(() => { })
56 | const location = {
57 | pathname: '/resources/a',
58 | search: '',
59 | }
60 | const match = {
61 | params: {
62 | id: 'a'
63 | }
64 | };
65 |
66 | const params = ResourceLocation.parseLocationParams(location, match);
67 | expect(params).toBeNull();
68 | expect(console.error).toBeCalled();
69 | })
70 |
71 | test('parses URL with no path params and omitted-with-default qs params', () => {
72 | const location = {
73 | pathname: '/resources',
74 | search: 'typeID=2',
75 | }
76 | const match = {
77 | params: null,
78 | };
79 |
80 | const params = ResourceListLocation.parseLocationParams(location, match);
81 | expect(params).toMatchObject({ typeID: 2, page: 0, rowsPerPage: 25, order: 'asc' });
82 | })
83 |
84 | test('parses URL with all qs params supplied', () => {
85 | const location = {
86 | pathname: '/resources',
87 | search: 'typeID=2&page=1&rowsPerPage=50&order=desc&isActive=true',
88 | }
89 | const match = {
90 | params: null,
91 | };
92 |
93 | const params = ResourceListLocation.parseLocationParams(location, match);
94 | expect(params).toMatchObject({
95 | typeID: 2,
96 | page: 1,
97 | rowsPerPage: 50,
98 | order: 'desc',
99 | isActive: true,
100 | });
101 | })
102 |
103 | test('errors on parsing a URL with missing required qs params', () => {
104 | jest.spyOn(global.console, "error").mockImplementation(() => { })
105 | const serializedUrl = ResourceListLocation.toUrl({ categoryID: 1 }); //should be typeID:1
106 | const location = {
107 | pathname: '/resources',
108 | search: 'categoryID=1',
109 | }
110 | const match = {
111 | params: null
112 | };
113 |
114 | const params = ResourceListLocation.parseLocationParams(location, match);
115 | expect(params).toBeNull();
116 | expect(console.error).toBeCalled();
117 | })
--------------------------------------------------------------------------------
/tests/toRoute.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, fireEvent, cleanup } from 'react-testing-library';
3 | import * as Yup from 'yup';
4 |
5 | import Location from '../src/Location';
6 |
7 | const HomeLocation = new Location('/');
8 |
9 | const Home = () => Home
;
10 | const NotFound = () => No match
;
11 |
12 | afterEach(cleanup);
13 |
14 | test('errors when neither component, render nor children properties are provided', () => {
15 | jest.spyOn(global.console, "error").mockImplementation(() => { })
16 | HomeLocation.toRoute({ invalid: NotFound });
17 | expect(console.error).toBeCalled();
18 | })
19 |
20 | test('errors when invalid property is not provided', () => {
21 | jest.spyOn(global.console, "error").mockImplementation(() => { })
22 | HomeLocation.toRoute({ component: Home });
23 | expect(console.error).toBeCalled();
24 | })
25 |
26 | test('warning when children node is provided', () => {
27 | jest.spyOn(global.console, "error").mockImplementation(() => { })
28 | HomeLocation.toRoute({ children: , invalid: NotFound });
29 | expect(console.error).toBeCalled();
30 | })
31 |
--------------------------------------------------------------------------------
/tests/toUrl.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, fireEvent, cleanup } from 'react-testing-library';
3 | import * as Yup from 'yup';
4 |
5 | import Location from '../src/Location';
6 |
7 | const isNullableDate = Yup.string().test('is-date', '${path}:${value} is not a valid date', date => !date || !isNaN(Date.parse(date)));
8 | const integer = Yup.number().integer();
9 | const naturalNbr = integer.moreThan(-1);
10 | const wholeNbr = integer.positive();
11 |
12 | const ResourceListLocation = new Location('/resources', null, {
13 | typeID: wholeNbr.required(),
14 | page: naturalNbr.default(0),
15 | rowsPerPage: Yup.number().oneOf([25, 50, 75, 100]).default(25),
16 | order: Yup.string().oneOf(['asc', 'desc']).default('asc'),
17 | isActive: Yup.boolean(),
18 | categoryID: wholeNbr.nullable(),
19 | });
20 | const ResourceLocation = new Location('/resources/:id', { id: wholeNbr.required() }, { date: isNullableDate });
21 |
22 | afterEach(() => {
23 | cleanup();
24 | jest.clearAllMocks();
25 | });
26 |
27 | test('builds URL with path param', () => {
28 | const serializedUrl = ResourceLocation.toUrl({ id: 1 }); //should be id:1
29 | expect(serializedUrl).toBe('/resources/1');
30 | })
31 |
32 | test('errors on building a URL with missing required path params', () => {
33 | jest.spyOn(global.console, "error").mockImplementation(() => { })
34 | const serializedUrl = ResourceLocation.toUrl({ resourceID: 1 }); //should be id:1
35 | expect(console.error).toBeCalled();
36 | })
37 |
38 | test('builds URL with no path params and omitted-with-default qs params', () => {
39 | const serializedUrl = ResourceListLocation.toUrl({ typeID: 2 });
40 | expect(serializedUrl).toBe('/resources?typeID=2'); //to avoid clutter, omitted-with-default qs params are not written to the url
41 | })
42 |
43 | test('builds URL with all qs params supplied', () => {
44 | const locationParams = {
45 | typeID: 2,
46 | page: 1,
47 | rowsPerPage: 50,
48 | order: 'desc',
49 | isActive: true,
50 | };
51 | const serializedUrl = ResourceListLocation.toUrl(locationParams);
52 | expect(serializedUrl).toBe('/resources?isActive=true&order=desc&rowsPerPage=50&page=1&typeID=2');
53 | })
54 |
55 | test('errors on building a URL with missing required qs params', () => {
56 | jest.spyOn(global.console, "error").mockImplementation(() => { })
57 | const serializedUrl = ResourceListLocation.toUrl({ categoryID: 1 }); //should be typeID:1
58 | expect(console.error).toBeCalled();
59 | })
60 |
61 | test('returns null with no errors instead of building a URL with missing required qs params', () => {
62 | jest.spyOn(global.console, "error").mockImplementation(() => { })
63 | const params = { categoryID: 1 }; //invalid, should be typeID:1
64 | const serializedUrl = ResourceListLocation.isValidParams(params)
65 | ? ResourceListLocation.toUrl(params)
66 | : null;
67 | expect(serializedUrl).toBeNull();
68 | expect(console.error).toBeCalledTimes(0);
69 | })
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const webpack = require('webpack');
3 | const HtmlWebpackPlugin = require("html-webpack-plugin");
4 |
5 | const htmlWebpackPlugin = new HtmlWebpackPlugin({
6 | template: path.resolve(__dirname, "examples/public/index.html"),
7 | filename: "index.html"
8 | });
9 |
10 | module.exports = (env, argv) => {
11 | const PUBLIC_URL = argv.mode === 'development'
12 | ? '/'
13 | : '/react-app-location'; //because of gh-pages
14 |
15 | return {
16 | entry: "./examples/src/app/index.js",
17 | output: {
18 | filename: "bundle.js",
19 | path: path.resolve(__dirname, "examples/dist"),
20 | publicPath: PUBLIC_URL
21 | },
22 | devServer: {
23 | historyApiFallback: true,
24 | port: 3001,
25 | },
26 | module: {
27 | rules: [
28 | {
29 | test: /\.(js|jsx)$/,
30 | use: "babel-loader",
31 | exclude: /node_modules/
32 | },
33 | {
34 | test: /\.css$/,
35 | use: ["style-loader", "css-loader"]
36 | }
37 | ]
38 | },
39 | plugins: [
40 | htmlWebpackPlugin,
41 | new webpack.DefinePlugin({
42 | 'process.env.PUBLIC_URL': JSON.stringify(PUBLIC_URL)
43 | })
44 | ],
45 | resolve: {
46 | extensions: [".js", ".jsx"]
47 | }
48 | }
49 | };
--------------------------------------------------------------------------------