├── .gitignore
├── LICENSE
├── README.md
├── package.json
├── public
├── favicon.ico
├── index.html
└── manifest.json
├── src
├── components
│ ├── Layout
│ │ └── index.js
│ ├── Main
│ │ └── index.js
│ ├── Post
│ │ ├── CreateForm
│ │ │ └── index.js
│ │ └── List
│ │ │ └── index.js
│ ├── Tag
│ │ ├── List
│ │ │ └── index.js
│ │ └── Select
│ │ │ └── index.js
│ └── User
│ │ └── Name
│ │ └── index.js
├── index.js
├── logo.svg
├── react-redux
│ └── Entity
│ │ ├── Create
│ │ └── index.js
│ │ ├── Delete
│ │ └── index.js
│ │ ├── Read
│ │ ├── Entities
│ │ │ └── index.js
│ │ └── Entity
│ │ │ └── index.js
│ │ ├── Toggle
│ │ └── index.js
│ │ └── Update
│ │ └── index.js
├── redux
│ ├── __tests__
│ │ ├── addMulti.js
│ │ ├── addSingle.js
│ │ ├── createSingle.js
│ │ ├── deleteMulti.js
│ │ ├── deleteSingle.js
│ │ ├── readMulti.js
│ │ ├── readSingle.js
│ │ ├── removeMulti.js
│ │ ├── removeSingle.js
│ │ ├── updateMulti.js
│ │ └── updateSingle.js
│ ├── actions
│ │ ├── helpers.js
│ │ ├── index.js
│ │ └── index.spec.js
│ ├── index.js
│ ├── middlewares
│ │ ├── api.js
│ │ ├── index.js
│ │ └── normalize.js
│ ├── reducers
│ │ ├── byId.js
│ │ ├── create.js
│ │ ├── delete.js
│ │ ├── helpers.js
│ │ ├── index.js
│ │ ├── read.js
│ │ ├── toggle.js
│ │ └── update.js
│ ├── schema
│ │ └── index.js
│ ├── selectors
│ │ └── index.js
│ ├── services
│ │ └── api.js
│ └── utils
│ │ ├── index.js
│ │ └── schema.js
└── serviceWorker.js
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Onoufrios
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 | # State management with Redux
2 |
3 | This project serves as a guide to structure Redux for a real world React application. Once the setup is complete you can start making api calls in no time for any entity in your system.
4 |
5 | **The Problem:** Setting up Redux to work for a React app can be quite challenging and quickly result into a lot of boilerplate being repeated.
6 |
7 | **The Aim:** The aim of this project is to setup Redux in such way that it will reduce the boilerplate to a minimum when adding extra entities to the system and cover most (over 90%) of our api needs.
8 |
9 | > #### _Like this guide?_ **Show your support by giving a :star:**
10 |
11 | ---
12 |
13 | ## Docs
14 | - [Demonstrate simplicity](#example)
15 | - [Understanding the Guide](#understanding)
16 | - [Setup](#setup)
17 | - [Actions](#actions)
18 | - [Middlewares](#middlewares)
19 | - [Reducers](#reducers)
20 | - [Selectors](#selectors)
21 | - [react-redux](#react-redux)
22 | - [Production ready](#production-ready)
23 | - [Coming soon](#coming-soon)
24 | - [Help](#help)
25 |
26 | ---
27 |
28 | ## Demonstrate simplicity
29 |
30 | After the setup the only thing we need to do to introduce a new entity (e.g. user) is to:
31 | 1. Include the entity along with its nested relationships in `src/redux/index.js`
32 | 2. Call `getReducers` for this entity in `src/redux/reducers/index.js`
33 |
34 | With these **two** lines of code we can perform all the actions described in the [Actions](#actions) section for this entity
35 |
36 | Then, using the react-redux containers explained [later](#react-redux) in this guide, you can start making your api calls in React in no time!
37 |
38 | ## Understanding the Guide
39 |
40 | There is a Medium article explaining the core concepts of the setup, which you can find [here](https://medium.com/@onoufriosm/state-management-with-redux-50f3ec10c10a). See the end of the article for a video of my presentation at the React London meetup on these concept or follow the link [here](https://www.youtube.com/watch?time_continue=3231&v=yElOj4R4rdA).
41 |
42 | I advise you to read the article before diving into the code.
43 |
44 | You can also run `yarn start` to run a demo application using this code. This relies on some mock api calls found in `src/index.js`, therefore it will return predetermined data and it won't behave as a real world application. Nevertheless, it would be very useful to check the redux devtools to see how the store is structure and how it gets updated in response to different actions.
45 |
46 | Finally, you can check the tests under `src/redux/__tests__` to understand how the action->middleware->reducer + selector combination works.
47 |
48 |
49 | ## Setup
50 |
51 | Quick summary:
52 | 1. Dispatch a `REQUEST` action.
53 | 2. Make the api call in the api middleware.
54 | 3. Normalize response in the normalize middleware.
55 | 4. Store payload in `byId` reducer + update status of api call in one of the other reducers.
56 | + Access the actions and the stored payload using a Higher Order Component (connect react with redux).
57 |
58 | All action creators, reducers and selectors will receive an entityName argument which will be any of the entities type we have in our application (e.g. user, post, comment e.t.c). This means that all of our code is generic and that we only need to write it once and then it will work for any entity in the system without extra boilerplate.
59 |
60 | ## Actions
61 |
62 | All action creators live under `src/redux/actions`
63 |
64 | There are action creators for:
65 | 1. Reading a single entity (e.g. GET /user/1)
66 | 2. Reading multiple entities (e.g. GET /user)
67 | 3. Updating a single entity (e.g. PUT /user/1)
68 | 4. Updating multiple entities (e.g. PUT /user/1,2). This will probably be different in some projects so you can adjust accordingly.
69 | 5. Deleting a single entity (e.g. DELETE /user/1)
70 | 6. Deleting multiple entities (e.g. DELETE /user/1,2)
71 | 7. Create a single entity (e.g. POST /user)
72 | 8. Add an entity to another in a many to many relationship (e.g. POST /post/1/tag/1)
73 | 9. Add multiple entities to another in a many to many relationship (e.g. POST /post/1/tag/1,2)
74 | 10. Remove an entity from another in a many to many relationship (e.g. DELETE /post/1/tag/1)
75 | 11. Remove multiple entities from another in a many to many relationship (e.g. DELETE /post/1/tag/1,2)
76 |
77 | All actions return 4 fields:
78 | 1. `type`. The type of the action (e.g. `REQUEST_READ_USER`)
79 | 2. `params`. These are parameters that will be used by the api service to compute the api endpoint.
80 | 3. `meta`. Meta data to be used by the reducers and the normalizer middleware.
81 | 4. `options`. Extra options. Typically these can include `onSuccess` and `onFail` functions to be called when the api call is done.
82 |
83 | [⇧ back to top](#Docs)
84 |
85 | ## Middlewares
86 |
87 | All middlewares live under `src/redux/middlewares`.
88 |
89 | All actions will pass by the middlewares. There are two middlewares:
90 |
91 | 1. Api middleware. This is responsible for doing the api call (depending on the action type) and responding with success/fail action depending on the type of repsonse
92 | 2. Normalize middleware. This will normalize the payload using the [`normalizr`](https://github.com/paularmstrong/normalizr) library and the schema provided by us.
93 |
94 | [⇧ back to top](#Docs)
95 |
96 | ## Reducers
97 |
98 | All reducers live under `src/redux/reducers`. There are 6 subreducers for every entity.
99 |
100 | 1. `byId`. All the normalized data will be stored here.
101 | - On `SUCCESS_CREATE` the id of the created entity(ies) will be added to the parent entity.
102 | - On `SUCCESS_DELETE` the id of the deleted entity(ies) will be removed from the parent entity.
103 | - Same for `SUCCESS_REMOVE`, `SUCCESS_ADD`, `SUCCESS_SET` for many to many relationships.
104 | 2. `readIds`. Information about the status of all read calls will be stored here.
105 | - On `SUCCESS_CREATE` the id of the created entity(ies) will be added to the relevant readId.
106 | - On `SUCCESS_DELETE` the id of the deleted entity(ies) will be removed from the relevant readId.
107 | 3. `updateIds`. Information about the status of all update calls will be stored here.
108 | 4. `createIds`. Information about the status of all create calls will be stored here.
109 | 5. `deleteIds`. Information about the status of all delete calls will be stored here.
110 | 6. `toggleIds`. Information about the status of all toggle calls will be stored here. Toggle refers to remove/add one entity to another in a many to many relationship.
111 |
112 | Since the data is stored in a normalized structure it becomes very easy to update relational data. Consider the following example where the initial state:
113 | ```
114 | {
115 | entities: {
116 | user: {
117 | 1: {
118 | id: 1,
119 | posts: [1,2],
120 | }
121 | }
122 | }
123 | }
124 | ```
125 |
126 | If we create a post (it will receive the id 3) then in the `byId` reducer we can add the id to the `posts` array under the parent entity (in this case user). The new state will become:
127 |
128 | ```
129 | {
130 | entities: {
131 | user: {
132 | 1: {
133 | id: 1,
134 | posts: [1,2. 3],
135 | }
136 | }
137 | }
138 | }
139 | ```
140 |
141 | Note that there are two ways to retrieve the posts for a user. We could either load the user and return posts as nested data from our backend, which would lead to the initial state above. Or we might want to return the posts for a specific user_id (Usually the case when we paginate data). In this case the initial state would look like this:
142 |
143 | ```
144 | {
145 | entities: {
146 | post: {
147 | '{"user_id":1}': { items: [1,2] },
148 | }
149 | }
150 | }
151 | ```
152 |
153 | And the updated state:
154 | ```
155 | {
156 | entities: {
157 | post: {
158 | '{"user_id":1}': { items: [1,2, 3] },
159 | }
160 | }
161 | }
162 | ```
163 |
164 | All these are handle automatically and for all entities, so we don't have to worry about updating relationships anymore.
165 |
166 | [⇧ back to top](#Docs)
167 |
168 | ## Selectors
169 |
170 | All selectors live under `src/redux/selectors`. The selectors will select either the data from the `byId` reducer and denormalize it or the status of the operation from the `readIds`, `updateIds`, `createIds`, `deleteIds` and `toggleIds` reducers.
171 |
172 | [⇧ back to top](#Docs)
173 |
174 | ## react-redux
175 |
176 | All logic for connecting redux and react components live under `src/react-redux`. The mapDispatchToProps and mapStateToProps is moved in to higher order components so that we don't need to redeclare them in every component. You can see how these HOC are used in the example in `src/components`.
177 |
178 | Example to read a single entity:
179 | ```
180 |
181 | { props => }
182 |
183 | ```
184 |
185 | 1. Wrap your component around the HOC.
186 | 2. Pass the entityName and id props to the HOC.
187 | 3. You get access to the `read` action creator, the `entity` (user) that will be returned from the api call, and `status` (isFetching, error).
188 |
189 | See `src/components/Main/index.js` for the full example.
190 |
191 | [⇧ back to top](#Docs)
192 |
193 | ## Production ready
194 |
195 | This setup is the basis for the Redux setup at [Labstep](https://app.labstep.com/). It is used in production and has accelerated the development drastically.
196 |
197 | [⇧ back to top](#Docs)
198 |
199 | ## Coming soon
200 |
201 | TODO:
202 |
203 | 1. Add examples for cursor/page based read
204 | 2. Add example for caching / optimistic updates
205 | 3. Publish to npm (I plan to turn this into a package that everyone can use )
206 |
207 | [⇧ back to top](#Docs)
208 |
209 | ## Help
210 |
211 | Feel free to open an issue asking for help. I'll do my best to reply promptly.
212 |
213 | [⇧ back to top](#Docs)
214 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "redux-setup-guide",
3 | "version": "0.2.0",
4 | "description": "Guide on how to setup Redux for a react application. Once it's set up, adding new entities in the system and making api calls becomes a breeze.",
5 | "license": "MIT",
6 | "author": "Onoufrios Malikkides",
7 | "keywords": [
8 | "redux",
9 | "react",
10 | "state",
11 | "guide"
12 | ],
13 | "dependencies": {
14 | "antd": "^3.11.2",
15 | "axios": "^0.18.0",
16 | "axios-mock-adapter": "^1.15.0",
17 | "babel-plugin-module-resolver": "^3.1.3",
18 | "eslint-config-airbnb": "17.1.0",
19 | "eslint-plugin-import": "^2.14.0",
20 | "eslint-plugin-jsx-a11y": "^6.1.1",
21 | "eslint-plugin-react": "^7.11.0",
22 | "lodash": "^4.17.11",
23 | "normalizr": "^3.3.0",
24 | "react": "^16.6.3",
25 | "react-dom": "^16.6.3",
26 | "react-redux": "^6.0.0",
27 | "react-scripts": "2.1.1",
28 | "redux": "^4.0.1",
29 | "redux-devtools-extension": "^2.13.7",
30 | "redux-logger": "^3.0.6",
31 | "redux-mock-store": "^1.5.3",
32 | "redux-thunk": "^2.3.0",
33 | "uuid": "^3.3.2"
34 | },
35 | "scripts": {
36 | "start": "react-scripts start",
37 | "build": "react-scripts build",
38 | "test": "react-scripts test",
39 | "eject": "react-scripts eject"
40 | },
41 | "eslintConfig": {
42 | "env": {
43 | "jest": true
44 | },
45 | "extends": "airbnb",
46 | "rules": {
47 | "react/prop-types": false,
48 | "react/jsx-filename-extension": false,
49 | "import/no-cycle": false,
50 | "jsx-quotes": [
51 | 2,
52 | "prefer-single"
53 | ]
54 | }
55 | },
56 | "browserslist": [
57 | ">0.2%",
58 | "not dead",
59 | "not ie <= 11",
60 | "not op_mini all"
61 | ],
62 | "babel": {
63 | "plugins": [
64 | [
65 | "babel-plugin-module-resolver",
66 | {
67 | "root": [
68 | "./"
69 | ],
70 | "alias": {
71 | "src": "./src"
72 | }
73 | }
74 | ]
75 | ]
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoufriosm/redux-setup-guide/d9d56e4be2e71faead9891232a6c61b969aae0db/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
22 | React App
23 |
24 |
25 |
26 | You need to enable JavaScript to run this app.
27 |
28 |
29 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/Layout/index.js:
--------------------------------------------------------------------------------
1 | /* Dependencies */
2 | import React from 'react';
3 |
4 | import 'antd/dist/antd.css';
5 |
6 | import { Layout } from 'antd';
7 |
8 | import Main from '../Main';
9 |
10 | const {
11 | Header, Content,
12 | } = Layout;
13 |
14 | const AppLayout = () => (
15 |
16 |
17 | State management with Redux demo
18 |
19 |
20 |
21 |
22 |
23 | );
24 |
25 | export default AppLayout;
26 |
--------------------------------------------------------------------------------
/src/components/Main/index.js:
--------------------------------------------------------------------------------
1 | /* Dependencies */
2 | import React from 'react';
3 |
4 | /* Containers */
5 | import ReadSingleEntityContainer from '../../react-redux/Entity/Read/Entity';
6 |
7 | /* Components */
8 | import UserNameForm from '../User/Name';
9 | import PostCreateForm from '../Post/CreateForm';
10 | import PostList from '../Post/List';
11 |
12 |
13 | class ShowUser extends React.Component {
14 | componentDidMount() {
15 | const { read } = this.props;
16 | read();
17 | }
18 |
19 | render() {
20 | const { entity: user, status } = this.props;
21 |
22 | if (status && !status.isFetching) {
23 | return (
24 |
25 |
26 |
27 |
Posts
28 |
29 |
30 |
31 |
32 | );
33 | }
34 | return Loading user...
;
35 | }
36 | }
37 |
38 | const Main = () => (
39 |
40 | { props => }
41 |
42 | );
43 |
44 | export default Main;
45 |
--------------------------------------------------------------------------------
/src/components/Post/CreateForm/index.js:
--------------------------------------------------------------------------------
1 | /* Dependencies */
2 | import React from 'react';
3 |
4 | /* Containers */
5 | import { Form, Input, Button } from 'antd';
6 | import CreateContainer from '../../../react-redux/Entity/Create';
7 |
8 | /* Components */
9 |
10 | class PostCreateForm extends React.Component {
11 | constructor() {
12 | super();
13 | this.state = { text: '' };
14 | this.handleSubmit = this.handleSubmit.bind(this);
15 | this.handleChange = this.handleChange.bind(this);
16 | }
17 |
18 | handleChange(evt) {
19 | this.setState({ text: evt.target.value });
20 | }
21 |
22 | handleSubmit(evt) {
23 | evt.preventDefault();
24 | const { create } = this.props;
25 | const { text } = this.state;
26 | create({ text });
27 | this.setState({ text: '' });
28 | }
29 |
30 | render() {
31 | const { text } = this.state;
32 | const { status } = this.props;
33 |
34 | return (
35 |
37 |
38 |
39 |
40 |
45 | Create
46 |
47 |
48 |
49 | );
50 | }
51 | }
52 |
53 | const PostCreateFormCreate = ({ userId }) => (
54 |
55 | { props => }
56 |
57 | );
58 |
59 | export default PostCreateFormCreate;
60 |
--------------------------------------------------------------------------------
/src/components/Post/List/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import {
4 | Card, Icon, List, Button,
5 | } from 'antd';
6 | import Tags from '../../Tag/List';
7 | import TagSelect from '../../Tag/Select';
8 |
9 | import DeleteSingleEntityContainer from '../../../react-redux/Entity/Delete';
10 |
11 | const { Meta } = Card;
12 |
13 | const Post = ({ post }) => (
14 | }
17 | actions={[
18 |
19 | { props => (
20 |
24 |
25 |
26 | )}
27 | ,
28 | (
30 |
31 |
32 |
33 | )}
34 | selectedTags={post.tags}
35 | parentId={post.id}
36 | />,
37 | ]}
38 | >
39 |
42 | {post.text}
43 |
44 |
45 | )}
46 | />
47 |
48 | );
49 |
50 | const PostList = ({ posts }) => (
51 | }
55 | />
56 | );
57 |
58 | export default PostList;
59 |
--------------------------------------------------------------------------------
/src/components/Tag/List/index.js:
--------------------------------------------------------------------------------
1 | /* Dependencies */
2 | import React from 'react';
3 |
4 | /* Components */
5 | import { Tag } from 'antd';
6 |
7 | const Tags = ({ tags }) => (
8 |
9 | { tags.map(tag => (
10 |
11 | { tag.name }
12 |
13 | )) }
14 |
15 | );
16 |
17 | export default Tags;
18 |
--------------------------------------------------------------------------------
/src/components/Tag/Select/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import {
4 | Modal, Spin, Tag,
5 | } from 'antd';
6 | import ReadMultiEntitiesContainer from '../../../react-redux/Entity/Read/Entities';
7 | import ToggleEntityContainer from '../../../react-redux/Entity/Toggle';
8 |
9 | const isChecked = (selectedTags, tag) => selectedTags.map(t => t.id).indexOf(tag.id) > -1;
10 |
11 | class TagSelect extends React.Component {
12 | constructor() {
13 | super();
14 | this.state = { visible: false, hasRead: false };
15 | this.toggleModal = this.toggleModal.bind(this);
16 | }
17 |
18 | static getDerivedStateFromProps(props, state) {
19 | if (state.visible) {
20 | return {
21 | ...state,
22 | hasRead: true,
23 | };
24 | }
25 | return state;
26 | }
27 |
28 | componentDidUpdate(prevProps, prevState) {
29 | const { read } = this.props;
30 | const { hasRead } = this.state;
31 | if (!prevState.hasRead && hasRead) {
32 | read();
33 | }
34 | }
35 |
36 | toggleModal() {
37 | const { visible } = this.state;
38 | this.setState({ visible: !visible });
39 | }
40 |
41 | render() {
42 | const {
43 | viewComponent, status, tags, selectedTags, parentId,
44 | } = this.props;
45 | const { visible } = this.state;
46 |
47 | return (
48 |
49 | {viewComponent({ toggleModal: this.toggleModal })}
50 |
57 | { status && status.isFetching ? (
58 |
59 | ) : (
60 |
61 | { tags.map(tag => (
62 |
69 | {({ toggle }) => (
70 | {
73 | if (status && status.isFetching) {
74 | return;
75 | }
76 | toggle(isChecked(selectedTags, tag) ? 'remove' : 'add');
77 | }}
78 | >
79 | { tag.name }
80 |
81 | )}
82 |
83 | )) }
84 |
85 | ) }
86 |
87 |
88 | );
89 | }
90 | }
91 |
92 |
93 | const TagSelectContainer = ({ selectedTags, viewComponent, parentId }) => (
94 |
95 | {({ entities: tags, status, read }) => (
96 |
104 | )}
105 |
106 | );
107 |
108 | export default TagSelectContainer;
109 |
--------------------------------------------------------------------------------
/src/components/User/Name/index.js:
--------------------------------------------------------------------------------
1 | /* Dependencies */
2 | import React from 'react';
3 |
4 | /* Containers */
5 | import {
6 | Button,
7 | Icon,
8 | } from 'antd';
9 | import UpdateSingleEntityContainer from '../../../react-redux/Entity/Update';
10 |
11 | class UserNameForm extends React.Component {
12 | constructor(props) {
13 | super(props);
14 | this.toggle = this.toggle.bind(this);
15 | this.handleSubmit = this.handleSubmit.bind(this);
16 | this.handleChange = this.handleChange.bind(this);
17 | this.state = { toggled: false, name: props.user.name };
18 | }
19 |
20 | toggle() {
21 | const { user: { name } } = this.props;
22 | this.setState({ toggled: true, name });
23 | }
24 |
25 | handleSubmit(evt) {
26 | evt.preventDefault();
27 | const { update } = this.props;
28 | const { name } = this.state;
29 | update({ name }, { onSuccess: () => this.setState({ toggled: false, name: '' }) });
30 | }
31 |
32 | handleChange(evt) {
33 | this.setState({ name: evt.target.value });
34 | }
35 |
36 | render() {
37 | const { user, status } = this.props;
38 | const { toggled, name } = this.state;
39 | return (
40 |
41 | { !toggled ? (
42 |
43 |
{ user.name }
44 |
50 |
51 | ) : (
52 |
56 | )}
57 |
58 | );
59 | }
60 | }
61 |
62 | const UserNameFormContainer = ({ user }) => (
63 |
64 | { ({ update, status }) => }
65 |
66 | );
67 |
68 | export default UserNameFormContainer;
69 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { Provider } from 'react-redux';
4 | import AppLayout from './components/Layout';
5 | import * as serviceWorker from './serviceWorker';
6 | import setupStore from './redux';
7 | import axios from 'axios';
8 | import MockAdapter from 'axios-mock-adapter';
9 |
10 | // Mock api calls
11 | const axiosMock = new MockAdapter(axios, { delayResponse: 500 });
12 | axiosMock.onGet('http://localhost:8000/user/1').reply(200, {
13 | id: 1,
14 | name: 'John',
15 | posts: [{ id: 1, tags: [{ id: 1, name: 'important' }] }],
16 | });
17 | axiosMock.onGet('http://localhost:8000/tag').reply(200, [
18 | { id: 1, name: 'important' },
19 | { id: 2, name: 'serious' },
20 | { id: 3, name: 'ready' },
21 | ]);
22 | axiosMock.onPut('http://localhost:8000/user/1').reply(200, {
23 | id: 1,
24 | name: 'James',
25 | });
26 | axiosMock.onPost('http://localhost:8000/post').reply(200, {
27 | id: 20,
28 | text: 'My newly created post',
29 | tags: [],
30 | });
31 | axiosMock.onDelete('http://localhost:8000/post/1').reply(200,{});
32 |
33 |
34 | const store = setupStore({}, { debug: true });
35 |
36 | /* eslint-disable-next-line */
37 | ReactDOM.render( , document.getElementById('root'));
38 |
39 | // If you want your app to work offline and load faster, you can change
40 | // unregister() to register() below. Note this comes with some pitfalls.
41 | // Learn more about service workers: http://bit.ly/CRA-PWA
42 | serviceWorker.unregister();
43 |
--------------------------------------------------------------------------------
/src/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/src/react-redux/Entity/Create/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Higher order component for creating an entity
3 | */
4 |
5 | /* Dependencies */
6 | import React from 'react';
7 | import { connect } from 'react-redux';
8 | import { v4 } from 'uuid';
9 |
10 | /* Actions */
11 | import { createEntity } from '../../../redux/actions';
12 |
13 | /* Selectors */
14 | import { selectCreatedEntity, selectCreateEntityStatus } from '../../../redux/selectors';
15 |
16 | const Container = ({ children, ...rest }) => children(rest);
17 |
18 | const mapStateToProps = (state, ownProps) => ({
19 | createdEntity: selectCreatedEntity(state, ownProps.entityName, ownProps.uuid),
20 | status: selectCreateEntityStatus(state, ownProps.entityName, ownProps.uuid),
21 | });
22 |
23 | const mapDispatchToProps = (dispatch, ownProps) => ({
24 | create(body, options = {}) {
25 | const enhancedOptions = {
26 | ...options,
27 | onSuccess: () => {
28 | if (options.onSuccess) {
29 | options.onSuccess();
30 | }
31 | // We need to get a new uuid on success
32 | ownProps.refreshUuid();
33 | },
34 | };
35 | dispatch(
36 | createEntity(
37 | ownProps.entityName,
38 | ownProps.parentName,
39 | ownProps.parentId,
40 | ownProps.uuid,
41 | body,
42 | enhancedOptions,
43 | ),
44 | );
45 | },
46 | });
47 |
48 |
49 | // Here we are passing a unique id to the children to keep track of the operation as there
50 | // is not id that we could use before the entity get created.
51 | const ConnectedChildren = connect(
52 | mapStateToProps,
53 | mapDispatchToProps,
54 | )(Container);
55 |
56 | export class UuidContainer extends React.Component {
57 | constructor(props) {
58 | super(props);
59 | this.state = {
60 | uuid: v4(),
61 | };
62 | this.refreshUuid = this.refreshUuid.bind(this);
63 | }
64 |
65 | refreshUuid() {
66 | this.setState({ uuid: v4() });
67 | }
68 |
69 | render() {
70 | const { uuid } = this.state;
71 |
72 | return (
73 |
78 | );
79 | }
80 | }
81 |
82 | export default connect(mapStateToProps, mapDispatchToProps)(UuidContainer);
83 |
--------------------------------------------------------------------------------
/src/react-redux/Entity/Delete/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Higher order component for deleting an entity
3 | */
4 |
5 | /* Dependencies */
6 | import { connect } from 'react-redux';
7 |
8 | /* Actions */
9 | import { deleteEntity } from '../../../redux/actions';
10 |
11 | /* Selectors */
12 | import { selectDeleteEntityStatus } from '../../../redux/selectors';
13 |
14 | const Container = ({ children, ...rest }) => children(rest);
15 |
16 | const mapStateToProps = (state, ownProps) => ({
17 | status: selectDeleteEntityStatus(state, ownProps.entityName, ownProps.id),
18 | });
19 |
20 | const mapDispatchToProps = (dispatch, ownProps) => ({
21 | delete(body, options) {
22 | dispatch(
23 | deleteEntity(ownProps.entityName, ownProps.id, options),
24 | );
25 | },
26 | });
27 |
28 | export default connect(mapStateToProps, mapDispatchToProps)(Container);
29 |
--------------------------------------------------------------------------------
/src/react-redux/Entity/Read/Entities/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Higher order component for reading multiple entities
3 | */
4 |
5 | /* Dependencies */
6 | import { connect } from 'react-redux';
7 |
8 | /* Actions */
9 | import { readEntities } from '../../../../redux/actions';
10 |
11 | /* Selectors */
12 | import { selectReadEntities, selectReadEntitiesStatus } from '../../../../redux/selectors';
13 |
14 | const Container = ({ children, ...rest }) => children(rest);
15 |
16 | const mapStateToProps = (state, ownProps) => ({
17 | entities: selectReadEntities(state, ownProps.entityName, ownProps.params),
18 | status: selectReadEntitiesStatus(state, ownProps.entityName, ownProps.params),
19 | });
20 |
21 | const mapDispatchToProps = (dispatch, ownProps) => ({
22 | read(options) {
23 | dispatch(
24 | readEntities(ownProps.entityName, ownProps.params, options),
25 | );
26 | },
27 | });
28 |
29 | export default connect(mapStateToProps, mapDispatchToProps)(Container);
30 |
--------------------------------------------------------------------------------
/src/react-redux/Entity/Read/Entity/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Higher order component for reading a single entity
3 | */
4 |
5 | /* Dependencies */
6 | import { connect } from 'react-redux';
7 |
8 | /* Actions */
9 | import { readEntity } from '../../../../redux/actions';
10 |
11 | /* Selectors */
12 | import { selectEntity, selectReadEntityStatus } from '../../../../redux/selectors';
13 |
14 | const Container = ({ children, ...rest }) => children(rest);
15 |
16 | const mapStateToProps = (state, ownProps) => ({
17 | entity: selectEntity(state, ownProps.entityName, ownProps.id),
18 | status: selectReadEntityStatus(state, ownProps.entityName, ownProps.id),
19 | });
20 |
21 | const mapDispatchToProps = (dispatch, ownProps) => ({
22 | read(options) {
23 | dispatch(
24 | readEntity(ownProps.entityName, ownProps.id, options),
25 | );
26 | },
27 | });
28 |
29 | export default connect(mapStateToProps, mapDispatchToProps)(Container);
30 |
--------------------------------------------------------------------------------
/src/react-redux/Entity/Toggle/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Higher order component for toggling entity(ies)
3 | */
4 |
5 | /* Dependencies */
6 | import { connect } from 'react-redux';
7 |
8 | /* ACTIONS */
9 | import { addEntity, removeEntity } from '../../../redux/actions';
10 |
11 | /* Selectors */
12 | import { selectToggleEntityStatus } from '../../../redux/selectors';
13 |
14 | /* Container */
15 | const Container = ({ children, ...rest }) => children(rest);
16 |
17 | const mapStateToProps = (state, ownProps) => ({
18 | status: selectToggleEntityStatus(
19 | state,
20 | ownProps.entityName,
21 | ownProps.entityIds,
22 | ownProps.parentName,
23 | ownProps.parentId,
24 | ),
25 | });
26 |
27 | const mapDispatchToProps = (dispatch, ownProps) => ({
28 | /**
29 | * Add or Remove a child Entity to/from parentEntity
30 | * @param {string} action - 'add'/'remove'
31 | * @param {array|number} ids - EntityIds
32 | * @param {object} options - Additional options
33 | */
34 | toggle(action, ids, options = {}) {
35 | // Ownprops get priority
36 | const actionType = ownProps.action || action;
37 | const entityIds = ownProps.entityIds || ids;
38 |
39 | if (actionType === 'add') {
40 | dispatch(
41 | addEntity(
42 | ownProps.entityName,
43 | entityIds,
44 | ownProps.parentName,
45 | ownProps.parentId,
46 | options,
47 | ),
48 | );
49 | } else if (actionType === 'remove') {
50 | dispatch(
51 | removeEntity(
52 | ownProps.entityName,
53 | entityIds,
54 | ownProps.parentName,
55 | ownProps.parentId,
56 | options,
57 | ),
58 | );
59 | }
60 | },
61 | });
62 |
63 | export default connect(
64 | mapStateToProps,
65 | mapDispatchToProps,
66 | )(Container);
67 |
--------------------------------------------------------------------------------
/src/react-redux/Entity/Update/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Higher order component for updating an entity
3 | */
4 |
5 | /* Dependencies */
6 | import { connect } from 'react-redux';
7 |
8 | /* Actions */
9 | import { updateEntity } from '../../../redux/actions';
10 |
11 | /* Selectors */
12 | import { selectUpdateEntityStatus } from '../../../redux/selectors';
13 |
14 | const Container = ({ children, ...rest }) => children(rest);
15 |
16 | const mapStateToProps = (state, ownProps) => ({
17 | status: selectUpdateEntityStatus(state, ownProps.entityName, ownProps.id),
18 | });
19 |
20 | const mapDispatchToProps = (dispatch, ownProps) => ({
21 | update(body, options) {
22 | dispatch(
23 | updateEntity(ownProps.entityName, ownProps.id, body, options),
24 | );
25 | },
26 | });
27 |
28 | export default connect(mapStateToProps, mapDispatchToProps)(Container);
29 |
--------------------------------------------------------------------------------
/src/redux/__tests__/addMulti.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import MockAdapter from 'axios-mock-adapter';
3 | import * as selectors from '../selectors';
4 | import * as entityActions from '../actions';
5 |
6 | import getStore from '..';
7 |
8 | const initialValue = { id: 1, text: 'My first post', tags: [1] };
9 |
10 | const store = getStore({
11 | entities: {
12 | post: {
13 | byId: {
14 | 1: initialValue,
15 | },
16 | },
17 | tag: {
18 | byId: {
19 | 1: { id: 1, name: 'tag1' },
20 | 5: { id: 5, name: 'tag5' },
21 | 8: { id: 8, name: 'tag8' },
22 | 19: { id: 19, name: 'tag19' },
23 | },
24 | },
25 | },
26 | });
27 |
28 | const axiosMock = new MockAdapter(axios);
29 |
30 | const response = { id: 1 };
31 |
32 | axiosMock.onAny().reply(200, response);
33 |
34 | describe('Entity - Read Entity', () => {
35 | const entityName = 'tag';
36 | const entityId = [5, 8];
37 | const parentName = 'post';
38 | const parentId = 1;
39 |
40 | it('valid', (done) => {
41 | const action = entityActions.toggleEntity('add', entityName, entityId, parentName, parentId, {});
42 | store.dispatch(action);
43 |
44 | expect(
45 | selectors.selectToggleEntityStatus(
46 | store.getState(),
47 | entityName,
48 | entityId,
49 | parentName,
50 | parentId,
51 | ),
52 | ).toEqual({
53 | isFetching: true,
54 | error: null,
55 | });
56 |
57 | setTimeout(() => {
58 | expect(
59 | selectors.selectToggleEntityStatus(
60 | store.getState(),
61 | entityName,
62 | entityId,
63 | parentName,
64 | parentId,
65 | ),
66 | ).toEqual({
67 | isFetching: false,
68 | error: null,
69 | });
70 | expect(
71 | store.getState().entities.post.byId[1].tags,
72 | ).toEqual([1, 5, 8]);
73 |
74 | done();
75 | }, 0);
76 | });
77 | });
78 |
--------------------------------------------------------------------------------
/src/redux/__tests__/addSingle.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import MockAdapter from 'axios-mock-adapter';
3 | import * as selectors from '../selectors';
4 | import * as entityActions from '../actions';
5 |
6 | import getStore from '..';
7 |
8 | const initialValue = { id: 1, text: 'My first post', tags: [1, 5, 8] };
9 |
10 | const store = getStore({
11 | entities: {
12 | post: {
13 | byId: {
14 | 1: initialValue,
15 | },
16 | },
17 | tag: {
18 | byId: {
19 | 1: { id: 1, name: 'tag1' },
20 | 5: { id: 5, name: 'tag5' },
21 | 8: { id: 8, name: 'tag8' },
22 | 19: { id: 19, name: 'tag19' },
23 | },
24 | },
25 | },
26 | });
27 |
28 | const axiosMock = new MockAdapter(axios);
29 |
30 | const response = { id: 1 };
31 |
32 | axiosMock.onAny().reply(200, response);
33 |
34 | describe('Entity - Read Entity', () => {
35 | const entityName = 'tag';
36 | const entityId = 19;
37 | const parentName = 'post';
38 | const parentId = 1;
39 |
40 | it('valid', (done) => {
41 | const action = entityActions.toggleEntity('add', entityName, entityId, parentName, parentId, {});
42 | store.dispatch(action);
43 |
44 | expect(
45 | selectors.selectToggleEntityStatus(
46 | store.getState(),
47 | entityName,
48 | entityId,
49 | parentName,
50 | parentId,
51 | ),
52 | ).toEqual({
53 | isFetching: true,
54 | error: null,
55 | });
56 |
57 | setTimeout(() => {
58 | expect(
59 | selectors.selectToggleEntityStatus(
60 | store.getState(),
61 | entityName,
62 | entityId,
63 | parentName,
64 | parentId,
65 | ),
66 | ).toEqual({
67 | isFetching: false,
68 | error: null,
69 | });
70 | expect(
71 | store.getState().entities.post.byId[1].tags,
72 | ).toEqual([1, 5, 8, 19]);
73 |
74 | done();
75 | }, 0);
76 | });
77 | });
78 |
--------------------------------------------------------------------------------
/src/redux/__tests__/createSingle.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import MockAdapter from 'axios-mock-adapter';
3 | import * as selectors from '../selectors';
4 | import * as entityActions from '../actions';
5 |
6 | import getStore from '..';
7 |
8 | const initialValue = { id: 1, name: 'Jeff' };
9 |
10 | const store = getStore({
11 | entities: {
12 | user: {
13 | byId: { 1: initialValue },
14 | },
15 | post: {
16 | readIds: {
17 | '{"user_id":1}': { items: [1] },
18 | },
19 | },
20 | },
21 | });
22 |
23 | const axiosMock = new MockAdapter(axios);
24 |
25 | const response = { id: 15, text: 'Jeff' };
26 |
27 | axiosMock.onAny().reply(200, response);
28 |
29 | describe('Entity - Read Entity', () => {
30 | const entityName = 'post';
31 | const parentName = 'user';
32 | const parentId = 1;
33 | const uuid = 'uuid';
34 |
35 | it('valid', (done) => {
36 | const action = entityActions.createEntity(entityName, parentName, parentId, uuid, { text: 'Jeff' });
37 | store.dispatch(action);
38 |
39 | expect(
40 | selectors.selectCreateEntityStatus(
41 | store.getState(),
42 | entityName,
43 | uuid,
44 | ),
45 | ).toEqual({
46 | isFetching: true,
47 | error: null,
48 | });
49 |
50 | setTimeout(() => {
51 | expect(
52 | selectors.selectCreateEntityStatus(
53 | store.getState(),
54 | entityName,
55 | uuid,
56 | ),
57 | ).toEqual({
58 | isFetching: false,
59 | error: null,
60 | });
61 | expect(
62 | selectors.selectEntity(store.getState(), entityName, response.id),
63 | ).toEqual(response);
64 |
65 | expect(store.getState().entities.user.byId[1].posts).toEqual([15]);
66 | expect(store.getState().entities.post.readIds['{"user_id":1}'].items).toEqual([1, 15]);
67 |
68 | done();
69 | }, 0);
70 | });
71 | });
72 |
--------------------------------------------------------------------------------
/src/redux/__tests__/deleteMulti.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import MockAdapter from 'axios-mock-adapter';
3 | import * as selectors from '../selectors';
4 | import * as entityActions from '../actions';
5 |
6 | import getStore from '..';
7 |
8 | const initialValue = { id: 1, name: 'Jeff', posts: [1, 5, 8] };
9 |
10 | const store = getStore({
11 | entities: {
12 | user: {
13 | byId: { 1: initialValue },
14 | },
15 | post: {
16 | readIds: {
17 | '{"user_id":1}': { items: [1, 5, 8] },
18 | },
19 | },
20 | },
21 | });
22 |
23 |
24 | const axiosMock = new MockAdapter(axios);
25 |
26 | const response = {};
27 |
28 | axiosMock.onAny().reply(200, response);
29 |
30 | describe('Entity - Delete Entity', () => {
31 | const entityName = 'post';
32 | const identifier = [5, 8];
33 |
34 | it('valid', (done) => {
35 | const action = entityActions.deleteEntity(entityName, identifier, {});
36 | store.dispatch(action);
37 |
38 | expect(
39 | selectors.selectDeleteEntityStatus(
40 | store.getState(),
41 | entityName,
42 | identifier,
43 | ),
44 | ).toEqual({
45 | isFetching: true,
46 | error: null,
47 | });
48 |
49 | setTimeout(() => {
50 | expect(
51 | selectors.selectDeleteEntityStatus(
52 | store.getState(),
53 | entityName,
54 | identifier,
55 | ),
56 | ).toEqual({
57 | isFetching: false,
58 | error: null,
59 | });
60 |
61 |
62 | expect(store.getState().entities.user.byId[1].posts).toEqual([1]);
63 | expect(store.getState().entities.post.readIds['{"user_id":1}'].items).toEqual([1]);
64 |
65 | done();
66 | }, 0);
67 | });
68 | });
69 |
--------------------------------------------------------------------------------
/src/redux/__tests__/deleteSingle.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import MockAdapter from 'axios-mock-adapter';
3 | import * as selectors from '../selectors';
4 | import * as entityActions from '../actions';
5 |
6 | import getStore from '..';
7 |
8 | const initialValue = { id: 1, name: 'Jeff', posts: [1, 5] };
9 |
10 | const store = getStore({
11 | entities: {
12 | user: {
13 | byId: { 1: initialValue },
14 | },
15 | post: {
16 | readIds: {
17 | '{"user_id":1}': { items: [1, 5] },
18 | },
19 | },
20 | },
21 | });
22 |
23 | const axiosMock = new MockAdapter(axios);
24 |
25 | const response = {};
26 |
27 | axiosMock.onAny().reply(200, response);
28 |
29 | describe('Entity - Delete Entity', () => {
30 | const entityName = 'post';
31 | const identifier = 5;
32 |
33 | it('valid', (done) => {
34 | const action = entityActions.deleteEntity(entityName, identifier, {});
35 | store.dispatch(action);
36 |
37 | expect(
38 | selectors.selectDeleteEntityStatus(
39 | store.getState(),
40 | entityName,
41 | identifier,
42 | ),
43 | ).toEqual({
44 | isFetching: true,
45 | error: null,
46 | });
47 |
48 | setTimeout(() => {
49 | expect(
50 | selectors.selectDeleteEntityStatus(
51 | store.getState(),
52 | entityName,
53 | identifier,
54 | ),
55 | ).toEqual({
56 | isFetching: false,
57 | error: null,
58 | });
59 |
60 |
61 | expect(store.getState().entities.user.byId[1].posts).toEqual([1]);
62 | expect(store.getState().entities.post.readIds['{"user_id":1}'].items).toEqual([1]);
63 |
64 | done();
65 | }, 0);
66 | });
67 | });
68 |
--------------------------------------------------------------------------------
/src/redux/__tests__/readMulti.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import MockAdapter from 'axios-mock-adapter';
3 | import * as selectors from '../selectors';
4 | import * as entityActions from '../actions';
5 |
6 | import getStore from '..';
7 |
8 | const store = getStore({});
9 |
10 | const axiosMock = new MockAdapter(axios);
11 |
12 | const response = [{ id: 1, name: 'John' }, { id: 2, name: 'Peter' }];
13 |
14 | axiosMock.onAny().reply(200, response);
15 |
16 | describe('Entity - Read Entity', () => {
17 | const entityName = 'user';
18 | const params = {};
19 |
20 | it('valid', (done) => {
21 | const action = entityActions.readEntities(entityName, params, {});
22 | store.dispatch(action);
23 |
24 | expect(
25 | selectors.selectReadEntitiesStatus(
26 | store.getState(),
27 | entityName,
28 | params,
29 | ),
30 | ).toEqual({
31 | isFetching: true,
32 | error: null,
33 | });
34 |
35 | setTimeout(() => {
36 | expect(
37 | selectors.selectReadEntitiesStatus(
38 | store.getState(),
39 | entityName,
40 | params,
41 | ),
42 | ).toEqual({
43 | isFetching: false,
44 | error: null,
45 | });
46 | expect(
47 | selectors.selectReadEntities(store.getState(), entityName, params),
48 | ).toEqual(response);
49 |
50 | done();
51 | }, 0);
52 | });
53 | });
54 |
--------------------------------------------------------------------------------
/src/redux/__tests__/readSingle.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import MockAdapter from 'axios-mock-adapter';
3 | import * as selectors from '../selectors';
4 | import * as entityActions from '../actions';
5 |
6 | import getStore from '..';
7 |
8 | const store = getStore({})
9 |
10 | const axiosMock = new MockAdapter(axios);
11 |
12 | const response = { id: 1, name: 'Jeff' };
13 |
14 | axiosMock.onAny().reply(200, response);
15 |
16 | describe('Entity - Read Entity', () => {
17 | const entityName = 'user';
18 | const identifier = 1;
19 |
20 | it('valid', (done) => {
21 | const action = entityActions.readEntity(entityName, identifier);
22 | store.dispatch(action);
23 |
24 | expect(
25 | selectors.selectReadEntityStatus(
26 | store.getState(),
27 | entityName,
28 | identifier,
29 | ),
30 | ).toEqual({
31 | isFetching: true,
32 | error: null,
33 | });
34 |
35 | setTimeout(() => {
36 | expect(
37 | selectors.selectReadEntityStatus(
38 | store.getState(),
39 | entityName,
40 | identifier,
41 | ),
42 | ).toEqual({
43 | isFetching: false,
44 | error: null,
45 | });
46 | expect(
47 | selectors.selectEntity(store.getState(), entityName, identifier),
48 | ).toEqual(response);
49 |
50 | done();
51 | }, 0);
52 | });
53 | });
54 |
--------------------------------------------------------------------------------
/src/redux/__tests__/removeMulti.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import MockAdapter from 'axios-mock-adapter';
3 | import * as selectors from '../selectors';
4 | import * as entityActions from '../actions';
5 |
6 | import getStore from '..';
7 |
8 | const initialValue = { id: 1, text: 'My first post', tags: [1, 5, 8] };
9 |
10 | const store = getStore({
11 | entities: {
12 | post: {
13 | byId: {
14 | 1: initialValue,
15 | },
16 | },
17 | tag: {
18 | byId: {
19 | 1: { id: 1, name: 'tag1' },
20 | 5: { id: 5, name: 'tag5' },
21 | 8: { id: 8, name: 'tag8' },
22 | 19: { id: 19, name: 'tag19' },
23 | },
24 | },
25 | },
26 | });
27 |
28 | const axiosMock = new MockAdapter(axios);
29 |
30 | const response = { id: 1 };
31 |
32 | axiosMock.onAny().reply(200, response);
33 |
34 | describe('Entity - Read Entity', () => {
35 | const entityName = 'tag';
36 | const entityId = [1, 8];
37 | const parentName = 'post';
38 | const parentId = 1;
39 |
40 | it('valid', (done) => {
41 | const action = entityActions.removeEntity(entityName, entityId, parentName, parentId, {});
42 | store.dispatch(action);
43 |
44 | expect(
45 | selectors.selectToggleEntityStatus(
46 | store.getState(),
47 | entityName,
48 | entityId,
49 | parentName,
50 | parentId,
51 | ),
52 | ).toEqual({
53 | isFetching: true,
54 | error: null,
55 | });
56 |
57 | setTimeout(() => {
58 | expect(
59 | selectors.selectToggleEntityStatus(
60 | store.getState(),
61 | entityName,
62 | entityId,
63 | parentName,
64 | parentId,
65 | ),
66 | ).toEqual({
67 | isFetching: false,
68 | error: null,
69 | });
70 | expect(
71 | store.getState().entities.post.byId[1].tags,
72 | ).toEqual([5]);
73 |
74 | done();
75 | }, 0);
76 | });
77 | });
78 |
--------------------------------------------------------------------------------
/src/redux/__tests__/removeSingle.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import MockAdapter from 'axios-mock-adapter';
3 | import * as selectors from '../selectors';
4 | import * as entityActions from '../actions';
5 |
6 | import getStore from '..';
7 |
8 | const initialValue = { id: 1, text: 'My first post', tags: [1, 5, 8] };
9 |
10 | const store = getStore({
11 | entities: {
12 | post: {
13 | byId: {
14 | 1: initialValue,
15 | },
16 | },
17 | tag: {
18 | byId: {
19 | 1: { id: 1, name: 'tag1' },
20 | 5: { id: 5, name: 'tag5' },
21 | 8: { id: 8, name: 'tag8' },
22 | 19: { id: 19, name: 'tag19' },
23 | },
24 | },
25 | },
26 | });
27 |
28 | const axiosMock = new MockAdapter(axios);
29 |
30 | const response = { id: 1 };
31 |
32 | axiosMock.onAny().reply(200, response);
33 |
34 | describe('Entity - Read Entity', () => {
35 | const entityName = 'tag';
36 | const entityId = 8;
37 | const parentName = 'post';
38 | const parentId = 1;
39 |
40 | it('valid', (done) => {
41 | const action = entityActions.removeEntity(entityName, entityId, parentName, parentId, {});
42 | store.dispatch(action);
43 |
44 | expect(
45 | selectors.selectToggleEntityStatus(
46 | store.getState(),
47 | entityName,
48 | entityId,
49 | parentName,
50 | parentId,
51 | ),
52 | ).toEqual({
53 | isFetching: true,
54 | error: null,
55 | });
56 |
57 | setTimeout(() => {
58 | expect(
59 | selectors.selectToggleEntityStatus(
60 | store.getState(),
61 | entityName,
62 | entityId,
63 | parentName,
64 | parentId,
65 | ),
66 | ).toEqual({
67 | isFetching: false,
68 | error: null,
69 | });
70 | expect(
71 | store.getState().entities.post.byId[1].tags,
72 | ).toEqual([1, 5]);
73 |
74 | done();
75 | }, 0);
76 | });
77 | });
78 |
--------------------------------------------------------------------------------
/src/redux/__tests__/updateMulti.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import MockAdapter from 'axios-mock-adapter';
3 | import * as selectors from '../selectors';
4 | import * as entityActions from '../actions';
5 |
6 | import getStore from '..';
7 |
8 | const store = getStore({});
9 |
10 | const axiosMock = new MockAdapter(axios);
11 |
12 | const response = [{ id: 1, name: 'John' }, { id: 2, name: 'Peter' }];
13 |
14 | axiosMock.onAny().reply(200, response);
15 |
16 | describe('Entity - Update Entities', () => {
17 | const entityName = 'user';
18 | const id = [1, 2];
19 |
20 | it('valid', (done) => {
21 | const action = entityActions.updateEntities(entityName, id);
22 | store.dispatch(action);
23 |
24 | expect(
25 | selectors.selectUpdateEntityStatus(
26 | store.getState(),
27 | entityName,
28 | id,
29 | ),
30 | ).toEqual({
31 | isFetching: true,
32 | error: null,
33 | });
34 |
35 | setTimeout(() => {
36 | expect(
37 | selectors.selectUpdateEntityStatus(
38 | store.getState(),
39 | entityName,
40 | id,
41 | ),
42 | ).toEqual({
43 | isFetching: false,
44 | error: null,
45 | });
46 | expect(
47 | selectors.selectEntitiesByArray(store.getState(), entityName, id),
48 | ).toEqual(response);
49 |
50 | done();
51 | }, 0);
52 | });
53 | });
54 |
--------------------------------------------------------------------------------
/src/redux/__tests__/updateSingle.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import MockAdapter from 'axios-mock-adapter';
3 | import * as selectors from '../selectors';
4 | import * as entityActions from '../actions';
5 |
6 | import getStore from '..';
7 |
8 | const initialValue = { id: 1, name: 'Jeff' };
9 |
10 | const store = getStore({ entities: { user: { byId: { 1: initialValue } } } });
11 |
12 | const axiosMock = new MockAdapter(axios);
13 |
14 | const response = { id: 1, name: 'Jeff' };
15 |
16 | axiosMock.onAny().reply(200, response);
17 |
18 | describe('Entity - Read Entity', () => {
19 | const entityName = 'user';
20 | const identifier = 1;
21 |
22 | it('valid', (done) => {
23 | const action = entityActions.updateEntity(entityName, identifier);
24 | store.dispatch(action);
25 |
26 | expect(
27 | selectors.selectUpdateEntityStatus(
28 | store.getState(),
29 | entityName,
30 | identifier,
31 | ),
32 | ).toEqual({
33 | isFetching: true,
34 | error: null,
35 | });
36 |
37 | expect(
38 | selectors.selectEntity(store.getState(), entityName, identifier),
39 | ).toEqual(initialValue);
40 |
41 | setTimeout(() => {
42 | expect(
43 | selectors.selectUpdateEntityStatus(
44 | store.getState(),
45 | entityName,
46 | identifier,
47 | ),
48 | ).toEqual({
49 | isFetching: false,
50 | error: null,
51 | });
52 | expect(
53 | selectors.selectEntity(store.getState(), entityName, identifier),
54 | ).toEqual(response);
55 |
56 | done();
57 | }, 0);
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/src/redux/actions/helpers.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Computes the redux type for toggling entities
3 | */
4 | export const getToggleType = (action, entityName, parentName) => {
5 | const actionUpperCase = action.toUpperCase();
6 | const entityNameUpperCase = entityName.toUpperCase();
7 | const verb = action === 'remove' ? 'FROM' : 'TO';
8 | const parentNameUpperCase = parentName.toUpperCase();
9 | return `REQUEST_${actionUpperCase}_${entityNameUpperCase}_${verb}_${parentNameUpperCase}`;
10 | };
11 |
12 | /**
13 | * Computes the identifier key for toggling an entity
14 | */
15 | export const getToggleKey = (entityName, entityIds, parentName, parentId) => (
16 | `${parentName}_id=${parentId}_toggle_${entityName}_id=${entityIds}`
17 | );
18 |
--------------------------------------------------------------------------------
/src/redux/actions/index.js:
--------------------------------------------------------------------------------
1 | import { getReduxType } from '../utils';
2 | import { getToggleType, getToggleKey } from './helpers';
3 |
4 | /**
5 | * Computes action creator
6 | * @param {string} entityName - Any entity type in the system e.g. 'user', 'group' e.t.c
7 | * @param {integer} entityId - The id of the entity to be read
8 | * @param {Object} options - onSuccess and onFail function could be passed here
9 | * @param {String} actionType - 'read' | 'update' | 'create' | 'delete'
10 | * @param {Boolean} single - Acting on single entity (false if acting on multiple entities)
11 | * @param {Object} body - Optional param
12 | */
13 | export const commonActionCreator = (
14 | entityName,
15 | entityId,
16 | options = {},
17 | actionType,
18 | single = true,
19 | body,
20 | ) => ({
21 | type: getReduxType('request', actionType, entityName, false),
22 | params: { id: entityId },
23 | body,
24 | meta: {
25 | identifier: entityId,
26 | entityName,
27 | type: single ? 'single' : 'multi',
28 | body,
29 | },
30 | options,
31 | });
32 |
33 | /**
34 | * Action creator to read a single entity
35 | */
36 | export const readEntity = (entityName, entityId, options) => (
37 | commonActionCreator(entityName, entityId, options, 'read')
38 | );
39 |
40 | /**
41 | * Action creator to read a multiple entities entity
42 | */
43 | export const readEntities = (entityName, params, options = {}) => ({
44 | type: getReduxType('request', 'read', entityName, true),
45 | params,
46 | meta: {
47 | identifier: JSON.stringify(params),
48 | entityName,
49 | type: 'multi',
50 | },
51 | options,
52 | });
53 |
54 | /**
55 | * Action creator to update a single entity
56 | */
57 | export const updateEntity = (entityName, entityId, body, options) => (
58 | commonActionCreator(entityName, entityId, options, 'update', true, body)
59 | );
60 |
61 | /**
62 | * Action creator to update multiple entities
63 | */
64 | export const updateEntities = (entityName, entityId, body, options) => (
65 | commonActionCreator(entityName, entityId, options, 'update', false, body)
66 | );
67 |
68 | /**
69 | * Action creator to delete a single entity
70 | */
71 | export const deleteEntity = (entityName, entityId, options) => (
72 | commonActionCreator(entityName, entityId, options, 'delete')
73 | );
74 |
75 | /**
76 | * Action creator to create a single entity
77 | */
78 | export const createEntity = (
79 | entityName,
80 | parentName,
81 | parentId,
82 | uuid,
83 | body,
84 | options = {},
85 | ) => ({
86 | type: getReduxType('request', 'create', entityName),
87 | params: { parentName, parentId, entityName },
88 | body,
89 | meta: {
90 | identifier: uuid,
91 | entityName,
92 | parentName,
93 | parentId,
94 | uuid,
95 | type: 'single',
96 | },
97 | options,
98 | });
99 |
100 | /**
101 | * Action creator to toggle (attach/detach in many to many relationship) single/multi entity(ies)
102 | */
103 | export const toggleEntity = (action, entityName, entityIds, parentName, parentId, options) => {
104 | const metaType = Array.isArray(entityIds) ? 'multi' : 'single';
105 | const type = getToggleType(action, entityName, parentName);
106 | return {
107 | type,
108 | params: {
109 | entityName, parentName, parentId, entityIds,
110 | },
111 | meta: {
112 | identifier: getToggleKey(entityName, entityIds, parentName, parentId),
113 | entityName,
114 | parentName,
115 | parentId,
116 | entityIds,
117 | type: metaType,
118 | },
119 | options,
120 | };
121 | };
122 |
123 | /**
124 | * Action creator to add entitiy(ies)
125 | */
126 | export const addEntity = (...args) => toggleEntity('add', ...args);
127 |
128 | /**
129 | * Action creator to remove entitiy(ies)
130 | */
131 | export const removeEntity = (...args) => toggleEntity('remove', ...args);
132 |
--------------------------------------------------------------------------------
/src/redux/actions/index.spec.js:
--------------------------------------------------------------------------------
1 | import {
2 | readEntity,
3 | readEntities,
4 | updateEntity,
5 | updateEntities,
6 | createEntity,
7 | deleteEntity,
8 | toggleEntity,
9 | } from '.';
10 |
11 | describe('readEntity', () => {
12 | test('Action', () => {
13 | expect(readEntity('user', 1, {})).toEqual({
14 | type: 'REQUEST_READ_USER',
15 | params: { id: 1 },
16 | body: undefined,
17 | meta: {
18 | identifier: 1,
19 | entityName: 'user',
20 | type: 'single',
21 | body: undefined,
22 | },
23 | options: {},
24 | });
25 | });
26 | });
27 |
28 | describe('readEntities', () => {
29 | test('Action', () => {
30 | expect(readEntities('user', { group_id: 5 }, {})).toEqual({
31 | type: 'REQUEST_READ_USER',
32 | params: { group_id: 5 },
33 | body: undefined,
34 | meta: {
35 | identifier: '{"group_id":5}',
36 | entityName: 'user',
37 | type: 'multi',
38 | body: undefined,
39 | },
40 | options: {},
41 | });
42 | });
43 | });
44 |
45 | describe('updateEntity', () => {
46 | test('Action', () => {
47 | expect(updateEntity('user', 1, { name: 'James' }, {})).toEqual({
48 | type: 'REQUEST_UPDATE_USER',
49 | params: { id: 1 },
50 | body: { name: 'James' },
51 | meta: {
52 | identifier: 1,
53 | entityName: 'user',
54 | type: 'single',
55 | body: { name: 'James' },
56 | },
57 | options: {},
58 | });
59 | });
60 | });
61 |
62 | describe('updateEntities', () => {
63 | test('Action', () => {
64 | expect(updateEntities('user', [1, 4, 5], { name: 'James' }, {})).toEqual({
65 | type: 'REQUEST_UPDATE_USER',
66 | params: { id: [1, 4, 5] },
67 | body: { name: 'James' },
68 | meta: {
69 | identifier: [1, 4, 5],
70 | entityName: 'user',
71 | type: 'multi',
72 | body: { name: 'James' },
73 | },
74 | options: {},
75 | });
76 | });
77 | });
78 |
79 | describe('createEntity', () => {
80 | test('Action', () => {
81 | expect(createEntity('comment', 'thread', 1, 'uuid', { text: 'My first comment' }, {})).toEqual({
82 | type: 'REQUEST_CREATE_COMMENT',
83 | params: { parentName: 'thread', parentId: 1, entityName: 'comment' },
84 | body: { text: 'My first comment' },
85 | meta: {
86 | identifier: 'uuid',
87 | parentName: 'thread',
88 | parentId: 1,
89 | entityName: 'comment',
90 | uuid: 'uuid',
91 | type: 'single',
92 | },
93 | options: {},
94 | });
95 | });
96 | });
97 |
98 | describe('deleteEntity', () => {
99 | test('Action', () => {
100 | expect(deleteEntity('user', 1, {})).toEqual({
101 | type: 'REQUEST_DELETE_USER',
102 | params: { id: 1 },
103 | body: undefined,
104 | meta: {
105 | identifier: 1,
106 | entityName: 'user',
107 | type: 'single',
108 | body: undefined,
109 | },
110 | options: {},
111 | });
112 | });
113 | });
114 |
--------------------------------------------------------------------------------
/src/redux/index.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware } from 'redux';
2 | import { composeWithDevTools } from 'redux-devtools-extension';
3 | import { createLogger } from 'redux-logger';
4 | import reducers from './reducers';
5 | import middlewares from './middlewares';
6 | import { computeSchema } from './utils';
7 |
8 | // Add your entities here. Under define key you need to define
9 | // all the nested relationships. Pass a string if the name of the field
10 | // is the same as the entity name or an object with the key being the field name
11 | // and the value being the entity name
12 | export const schema = {
13 | user: {
14 | define: ['posts'],
15 | },
16 | post: {
17 | define: [{ author: 'user' }, 'tags'],
18 | },
19 | tag: {
20 | define: [],
21 | },
22 | };
23 |
24 | export const entitiesSchema = computeSchema(schema);
25 |
26 | const getStore = (initialState, options = {}) => {
27 | if (options.debug) {
28 | const reduxLogger = createLogger({
29 | collapsed: true,
30 | });
31 | middlewares.push(reduxLogger);
32 | }
33 | return createStore(reducers, initialState, composeWithDevTools(applyMiddleware(...middlewares)));
34 | };
35 |
36 |
37 | export default getStore;
38 |
--------------------------------------------------------------------------------
/src/redux/middlewares/api.js:
--------------------------------------------------------------------------------
1 | import api from '../services/api';
2 |
3 | /**
4 | * Middleware to do the api call and send success or fail action
5 | * depending on the response
6 | */
7 | const apiMiddleware = store => next => (action) => {
8 | let method = null;
9 | const { type } = action;
10 | if (type.startsWith('REQUEST_READ_')) {
11 | method = 'GET';
12 | }
13 | if (type.startsWith('REQUEST_CREATE_')) {
14 | method = 'POST';
15 | }
16 | if (type.startsWith('REQUEST_UPDATE_') || type.startsWith('REQUEST_ADD_')) {
17 | method = 'PUT';
18 | }
19 | if (type.startsWith('REQUEST_DELETE_') || type.startsWith('REQUEST_REMOVE_')) {
20 | method = 'DELETE';
21 | }
22 |
23 | if (method) {
24 | api(method, action)
25 | .then((response) => {
26 | store.dispatch({
27 | type: action.type.replace('REQUEST', 'SUCCESS'),
28 | payload: response,
29 | meta: action.meta,
30 | });
31 | return response;
32 | })
33 | .then((response) => {
34 | if (action.options.onSuccess) {
35 | action.options.onSuccess(response);
36 | }
37 | })
38 | .catch((error) => {
39 | store.dispatch({
40 | type: action.type.replace('REQUEST', 'FAIL'),
41 | error,
42 | meta: action.meta,
43 | });
44 | if (action.options.onFail) {
45 | action.options.onFail(error);
46 | }
47 | });
48 | }
49 |
50 | next(action);
51 | };
52 |
53 | export default apiMiddleware;
54 |
--------------------------------------------------------------------------------
/src/redux/middlewares/index.js:
--------------------------------------------------------------------------------
1 | import api from './api';
2 | import normalize from './normalize';
3 |
4 | export default [api, normalize];
5 |
--------------------------------------------------------------------------------
/src/redux/middlewares/normalize.js:
--------------------------------------------------------------------------------
1 | import { normalize } from 'normalizr';
2 | import { entitiesSchema } from '..';
3 |
4 | /**
5 | * Middleware to normalize the response
6 | */
7 | const normalizeMiddlewre = () => next => (action) => {
8 | if (action.type.startsWith('SUCCESS_') && !action.type.startsWith('SUCCESS_DELETE')) {
9 | const schemaToNormalize = action.meta.type === 'single'
10 | ? entitiesSchema[action.meta.entityName]
11 | : entitiesSchema[`${action.meta.entityName}s`];
12 |
13 | next({
14 | ...action,
15 | payload: normalize(action.payload, schemaToNormalize),
16 | });
17 | } else {
18 | next(action);
19 | }
20 | };
21 |
22 | export default normalizeMiddlewre;
23 |
--------------------------------------------------------------------------------
/src/redux/reducers/byId.js:
--------------------------------------------------------------------------------
1 | import {
2 | mergeByIds,
3 | removeDeletedChildFromParent,
4 | addCreatedChildEntityToParent,
5 | addChildToParent,
6 | removeChildFromParent,
7 | } from './helpers';
8 | import { parentOf } from '../utils/schema';
9 |
10 | const byId = entityName => (state = {}, action) => {
11 | if (!action.type.startsWith('SUCCESS_')) {
12 | return state;
13 | }
14 |
15 | const { type, meta: { parentName }, payload: { result } } = action;
16 |
17 | if (parentName === entityName && type.includes('SUCCESS_CREATE')) {
18 | return addCreatedChildEntityToParent(state, action, entityName);
19 | }
20 |
21 | if (
22 | action.type.startsWith('SUCCESS_DELETE_')
23 | && parentOf(entityName).includes(action.meta.entityName)
24 | ) {
25 | return removeDeletedChildFromParent(state, action, entityName);
26 | }
27 |
28 | if (type.startsWith('SUCCESS_ADD') && parentName === entityName) {
29 | return addChildToParent(state, action, entityName);
30 | }
31 |
32 | if (type.startsWith('SUCCESS_REMOVE') && parentName === entityName) {
33 | return removeChildFromParent(state, action, entityName);
34 | }
35 |
36 | if (result === null || result === undefined) {
37 | return state;
38 | }
39 |
40 |
41 | return mergeByIds(state, action.payload.entities[entityName]);
42 | };
43 |
44 | export default byId;
45 |
--------------------------------------------------------------------------------
/src/redux/reducers/create.js:
--------------------------------------------------------------------------------
1 | import {
2 | requestStatus,
3 | receiveStatus,
4 | failStatus,
5 | isValidAction,
6 | } from './helpers';
7 |
8 | /**
9 | * Reducer to keep track of the status of create api calls
10 | */
11 | const createIds = entityName => (state = {}, action) => {
12 | const entityNameUppercase = entityName.toUpperCase();
13 | if (!isValidAction(action, 'CREATE')) {
14 | return state;
15 | }
16 | switch (action.type) {
17 | case `REQUEST_CREATE_${entityNameUppercase}`: {
18 | return requestStatus(state, action);
19 | }
20 | case `SUCCESS_CREATE_${entityNameUppercase}`: {
21 | return receiveStatus(state, action);
22 | }
23 | case `FAIL_CREATE_${entityNameUppercase}`: {
24 | return failStatus(state, action);
25 | }
26 | default: {
27 | return state;
28 | }
29 | }
30 | };
31 |
32 | export default createIds;
33 |
--------------------------------------------------------------------------------
/src/redux/reducers/delete.js:
--------------------------------------------------------------------------------
1 | import {
2 | requestStatus,
3 | receiveStatus,
4 | failStatus,
5 | isValidAction,
6 | } from './helpers';
7 |
8 | /**
9 | * Reducer to keep track of the status of delete api calls
10 | */
11 | const deleteIds = entityName => (state = {}, action) => {
12 | const entityNameUppercase = entityName.toUpperCase();
13 | if (!isValidAction(action, 'DELETE')) {
14 | return state;
15 | }
16 | switch (action.type) {
17 | case `REQUEST_DELETE_${entityNameUppercase}`: {
18 | return requestStatus(state, action);
19 | }
20 | case `SUCCESS_DELETE_${entityNameUppercase}`: {
21 | return receiveStatus(state, action);
22 | }
23 | case `FAIL_DELETE_${entityNameUppercase}`: {
24 | return failStatus(state, action);
25 | }
26 | default: {
27 | return state;
28 | }
29 | }
30 | };
31 |
32 | export default deleteIds;
33 |
--------------------------------------------------------------------------------
/src/redux/reducers/helpers.js:
--------------------------------------------------------------------------------
1 | import {
2 | isEmpty, clone, union,
3 | } from 'lodash';
4 | import { entitiesSchema as schema } from '..';
5 |
6 | export const arraify = n => (Array.isArray(n) ? n : [n]);
7 |
8 | export const mergeWithArray = (state, result) => union(
9 | state,
10 | arraify(result),
11 | );
12 |
13 | export const requestStatus = (state, action) => ({
14 | ...state,
15 | [action.meta.identifier]: {
16 | status: {
17 | isFetching: true,
18 | error: null,
19 | },
20 | },
21 | });
22 |
23 | export const receiveStatus = (state, action, args = {}) => ({
24 | ...state,
25 | [action.meta.identifier]: {
26 | ...args,
27 | items: action.payload.result,
28 | status: {
29 | isFetching: false,
30 | error: null,
31 | },
32 | },
33 | });
34 |
35 | export const failStatus = (state, action, args = {}) => ({
36 | ...state,
37 | [action.meta.identifier]: {
38 | ...state[action.meta.identifier],
39 | ...args,
40 | status: {
41 | isFetching: false,
42 | error: action.error,
43 | },
44 | },
45 | });
46 |
47 | export const mergeByIds = (state, entities) => {
48 | // When nested entity is empty in normalizr it does not appear under entities therefore
49 | // we need to check for undefined. Also if entities is null or empty just return state
50 | if (
51 | typeof entities === 'undefined'
52 | || entities == null
53 | || isEmpty(entities)
54 | ) {
55 | return state;
56 | }
57 |
58 | // Merge entities and state common keys as they might have different fields
59 | const mergedEntities = Object.keys(entities).reduce(
60 | (result, current) => {
61 | const item = clone(result);
62 | item[current] = current in state
63 | ? { ...state[current], ...entities[current] }
64 | : entities[current];
65 | return item;
66 | },
67 | {},
68 | );
69 |
70 | return { ...state, ...mergedEntities };
71 | };
72 |
73 | export const isValidAction = (action, reducer) => {
74 | const { type } = action;
75 | if (
76 | !type.startsWith(`REQUEST_${reducer}`)
77 | || !type.startsWith(`SUCCESS_${reducer}`)
78 | || !type.startsWith(`FAIL_${reducer}`)
79 | ) {
80 | return true;
81 | }
82 | return false;
83 | };
84 |
85 | export const nameOfChildKey = (
86 | parentEntityName,
87 | childEntityName,
88 | ) => {
89 | const parentSchema = schema[parentEntityName].schema;
90 | return Object.keys(parentSchema).find(
91 | /* eslint-disable-next-line */
92 | key => parentSchema[key].schema._key === childEntityName,
93 | );
94 | };
95 |
96 | export const removeDeletedChildFromParent = (
97 | state,
98 | action,
99 | entityName,
100 | ) => Object.keys(state).reduce((result, key) => {
101 | const childKey = nameOfChildKey(entityName, action.meta.entityName);
102 |
103 | if (!state[key][childKey]) {
104 | return { ...result, [key]: state[key] };
105 | }
106 |
107 | // TODO: Write test. This is to reduce the number of count when deleting an entity.
108 |
109 | return {
110 | ...result,
111 | [key]: {
112 | ...state[key],
113 | [childKey]: state[key][childKey].filter(
114 | (id) => {
115 | const { identifier } = action.meta;
116 | if (Array.isArray(identifier)) {
117 | return identifier.indexOf(id) === -1;
118 | }
119 | return id !== identifier;
120 | },
121 | ),
122 | },
123 | };
124 | }, {});
125 |
126 |
127 | export const addCreatedChildEntityToParent = (
128 | state,
129 | action,
130 | entityName,
131 | ) => {
132 | const { parentName, parentId } = action.meta;
133 | if (entityName === parentName) {
134 | // Sometimes parentId does not exist in state (e.g. when visiting experiment_complete directly)
135 | if (!state[parentId]) {
136 | return state;
137 | }
138 |
139 | let stateWithUpdatedChildren = { ...state };
140 | const childKey = nameOfChildKey(entityName, action.meta.entityName);
141 |
142 | // OneToMany associations
143 | const childrenArray = state[parentId][childKey] || [];
144 | const updatedParentState = {
145 | ...state[action.meta.parentId],
146 | [childKey]: mergeWithArray(childrenArray, action.payload.result),
147 | };
148 |
149 | stateWithUpdatedChildren = {
150 | ...state,
151 | [action.meta.parentId]: updatedParentState,
152 | };
153 |
154 | return mergeByIds(
155 | stateWithUpdatedChildren,
156 | action.payload.entities[entityName],
157 | );
158 | }
159 |
160 | return state;
161 | };
162 |
163 | export const addChildToParent = (state, action, entityName) => {
164 | const childKey = nameOfChildKey(entityName, action.meta.entityName);
165 | const childrenArray = state[action.meta.parentId][childKey] || [];
166 |
167 | return {
168 | ...state,
169 | [action.meta.parentId]: {
170 | ...state[action.meta.parentId],
171 | [childKey]: mergeWithArray(childrenArray, action.meta.entityIds),
172 | },
173 | };
174 | };
175 |
176 | export const removeChildFromParent = (
177 | state,
178 | action,
179 | entityName,
180 | ) => {
181 | const childKey = nameOfChildKey(entityName, action.meta.entityName);
182 |
183 | let newState = { ...state };
184 |
185 | const parent = state[action.meta.parentId];
186 | const children = Boolean(parent) && state[action.meta.parentId][childKey];
187 |
188 | if (parent && children) {
189 | const filteredChildren = children.filter(
190 | (id) => {
191 | const { entityIds } = action.meta;
192 | if (Array.isArray(entityIds)) {
193 | return entityIds.indexOf(id) === -1;
194 | }
195 | return id !== entityIds;
196 | },
197 | );
198 |
199 | newState = {
200 | ...state,
201 | [action.meta.parentId]: {
202 | ...state[action.meta.parentId],
203 | [childKey]: filteredChildren,
204 | },
205 | };
206 | }
207 |
208 | return newState;
209 | };
210 |
--------------------------------------------------------------------------------
/src/redux/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import getByIdReducer from './byId';
3 | import getReadIdsReducer from './read';
4 | import getUpdateIdsReducer from './update';
5 | import getDeleteIdsReducer from './delete';
6 | import getToggleIdsReducer from './toggle';
7 | import getCreateIdsReducer from './create';
8 |
9 | const getReducers = reducerName => (
10 | combineReducers({
11 | byId: getByIdReducer(reducerName),
12 | createIds: getCreateIdsReducer(reducerName),
13 | readIds: getReadIdsReducer(reducerName),
14 | updateIds: getUpdateIdsReducer(reducerName),
15 | deleteIds: getDeleteIdsReducer(reducerName),
16 | toggleIds: getToggleIdsReducer(reducerName),
17 | })
18 | );
19 |
20 | const entities = combineReducers({
21 | user: getReducers('user'),
22 | post: getReducers('post'),
23 | tag: getReducers('tag'),
24 | });
25 |
26 | const reducers = combineReducers({
27 | entities,
28 | });
29 |
30 | export default reducers;
31 |
--------------------------------------------------------------------------------
/src/redux/reducers/read.js:
--------------------------------------------------------------------------------
1 | import { difference } from 'lodash';
2 | import {
3 | requestStatus,
4 | receiveStatus,
5 | failStatus,
6 | isValidAction,
7 | mergeWithArray,
8 | arraify,
9 | } from './helpers';
10 |
11 | /**
12 | * Reducer to keep track of the status of read api calls
13 | */
14 | const readIds = entityName => (state = {}, action) => {
15 | const entityNameUppercase = entityName.toUpperCase();
16 | if (!isValidAction(action)) {
17 | return state;
18 | }
19 |
20 | const { type } = action;
21 | if (type === `REQUEST_READ_${entityNameUppercase}`) {
22 | return requestStatus(state, action);
23 | }
24 | if (type === `SUCCESS_READ_${entityNameUppercase}`) {
25 | return receiveStatus(state, action);
26 | }
27 | if (type === `FAIL_READ_${entityNameUppercase}`) {
28 | return failStatus(state, action);
29 | }
30 |
31 | if (type.startsWith('SUCCESS_CREATE_')) {
32 | if (action.meta.entityName === entityName) {
33 | return Object.keys(state).reduce((result, key) => {
34 | const { parentName, parentId } = action.meta;
35 | const params = JSON.parse(key);
36 | const keyState = state[key];
37 | if (typeof params === 'object' && params[`${parentName}_id`] === parentId) {
38 | return {
39 | ...result,
40 | [key]: {
41 | ...keyState,
42 | items: mergeWithArray(keyState.items, action.payload.result),
43 | },
44 | };
45 | }
46 | return { ...result, [key]: keyState };
47 | }, {});
48 | }
49 | }
50 |
51 | if (type.startsWith('SUCCESS_DELETE_')) {
52 | if (action.meta.entityName === entityName) {
53 | return Object.keys(state).reduce((result, key) => {
54 | const params = JSON.parse(key);
55 | const keyState = state[key];
56 | const deletedEntitiesArray = arraify(action.meta.identifier);
57 | if (typeof params === 'object') {
58 | return {
59 | ...result,
60 | [key]: {
61 | ...keyState,
62 | items: difference(keyState.items, deletedEntitiesArray),
63 | },
64 | };
65 | }
66 | return { ...result, [key]: keyState };
67 | }, {});
68 | }
69 | }
70 |
71 | return state;
72 | };
73 |
74 | export default readIds;
75 |
--------------------------------------------------------------------------------
/src/redux/reducers/toggle.js:
--------------------------------------------------------------------------------
1 | import {
2 | requestStatus,
3 | receiveStatus,
4 | failStatus,
5 | } from './helpers';
6 |
7 | /**
8 | * Reducer to keep track of the status of toggle api calls
9 | */
10 | const toggleIds = entityName => (state = {}, action) => {
11 | if (!(action.type && (action.type.includes('ADD') || action.type.includes('REMOVE')) && action.meta.entityName === entityName)) {
12 | return state;
13 | }
14 |
15 | if (action.type.startsWith('REQUEST')) {
16 | return requestStatus(state, action);
17 | } if (action.type.startsWith('SUCCESS')) {
18 | return receiveStatus(state, action);
19 | } if (action.type.startsWith('FAIL')) {
20 | return failStatus(state, action);
21 | }
22 | return state;
23 | };
24 |
25 | export default toggleIds;
26 |
--------------------------------------------------------------------------------
/src/redux/reducers/update.js:
--------------------------------------------------------------------------------
1 | import {
2 | requestStatus,
3 | receiveStatus,
4 | failStatus,
5 | isValidAction,
6 | } from './helpers';
7 |
8 | /**
9 | * Reducer to keep track of the status of update api calls
10 | */
11 | const updateIds = entityName => (state = {}, action) => {
12 | const entityNameUppercase = entityName.toUpperCase();
13 | if (!isValidAction(action, 'UPDATE')) {
14 | return state;
15 | }
16 | switch (action.type) {
17 | case `REQUEST_UPDATE_${entityNameUppercase}`: {
18 | return requestStatus(state, action);
19 | }
20 | case `SUCCESS_UPDATE_${entityNameUppercase}`: {
21 | return receiveStatus(state, action);
22 | }
23 | case `FAIL_UPDATE_${entityNameUppercase}`: {
24 | return failStatus(state, action);
25 | }
26 | default: {
27 | return state;
28 | }
29 | }
30 | };
31 |
32 | export default updateIds;
33 |
--------------------------------------------------------------------------------
/src/redux/schema/index.js:
--------------------------------------------------------------------------------
1 | import { schema } from 'normalizr';
2 |
3 | const user = new schema.Entity('user');
4 | const users = new schema.Array(user);
5 |
6 | const group = new schema.Entity('group');
7 | const groups = new schema.Array(group);
8 |
9 | const tag = new schema.Entity('tag');
10 | const tags = new schema.Array(tag);
11 |
12 | user.define({
13 | groups,
14 | tags,
15 | });
16 |
17 | group.define({
18 | author: user,
19 | users,
20 | });
21 |
22 | export default {
23 | user,
24 | users,
25 | group,
26 | groups,
27 | tag,
28 | tags,
29 | };
30 |
--------------------------------------------------------------------------------
/src/redux/selectors/index.js:
--------------------------------------------------------------------------------
1 | import { denormalize } from 'normalizr';
2 | import { entitiesSchema as schema } from '..';
3 | import { getToggleKey } from '../actions/helpers';
4 |
5 | const hasKey = (obj, key) => key in obj;
6 |
7 | export const selectDenormalizedEntity = (
8 | state,
9 | entityName,
10 | id,
11 | ) => state.entities[entityName].byId[id];
12 |
13 | const getEntitiesForDenormalization = state => (
14 | Object.keys(state.entities).reduce(
15 | (result, key) => ({ ...result, [key]: state.entities[key].byId }),
16 | {},
17 | )
18 | );
19 |
20 | export const selectEntity = (state, entityName, id) => (
21 | denormalize(
22 | state.entities[entityName].byId[id],
23 | schema[entityName],
24 | getEntitiesForDenormalization(state),
25 | )
26 | );
27 |
28 | export const selectEntitiesByArray = (state, entityName, array) => (
29 | denormalize(
30 | array,
31 | schema[`${entityName}s`],
32 | getEntitiesForDenormalization(state),
33 | )
34 | );
35 |
36 | export const selectReadEntities = (state, entityName, params) => {
37 | const readId = state
38 | .entities[entityName].readIds[JSON.stringify(params)];
39 | if (!readId) {
40 | return [];
41 | }
42 |
43 | return selectEntitiesByArray(state, entityName, readId.items);
44 | };
45 |
46 | export const selectStatus = (state, entityName, id, key) => {
47 | const statuses = state.entities[entityName][key];
48 | return hasKey(statuses, id) ? statuses[id].status : undefined;
49 | };
50 |
51 | export const selectReadEntityStatus = (state, entityName, id) => (
52 | selectStatus(state, entityName, id, 'readIds')
53 | );
54 |
55 | export const selectReadEntitiesStatus = (state, entityName, params) => (
56 | selectStatus(state, entityName, JSON.stringify(params), 'readIds')
57 | );
58 |
59 | export const selectUpdateEntityStatus = (state, entityName, id) => (
60 | selectStatus(state, entityName, id, 'updateIds')
61 | );
62 |
63 | export const selectDeleteEntityStatus = (state, entityName, id) => (
64 | selectStatus(state, entityName, id, 'deleteIds')
65 | );
66 |
67 | export const selectCreateEntityStatus = (state, entityName, uuid) => {
68 | if (!uuid) {
69 | return undefined;
70 | }
71 | const { createIds } = state.entities[entityName];
72 | const createIdKey = Object.keys(createIds).find(key => key.includes(uuid));
73 | if (!createIdKey) {
74 | return undefined;
75 | }
76 | return createIds[createIdKey].status;
77 | };
78 |
79 | export const selectCreatedEntity = (state, entityName, uuid) => {
80 | if (!uuid) {
81 | return undefined;
82 | }
83 | const { createIds } = state.entities[entityName];
84 | const createIdKey = Object.keys(createIds).find(key => key.includes(uuid));
85 | if (!createIdKey) {
86 | return undefined;
87 | }
88 | const { id } = createIds[createIdKey];
89 | if (id) {
90 | return selectEntity(state, entityName, id);
91 | }
92 | return undefined;
93 | };
94 |
95 | export const selectToggleEntityStatus = (
96 | state,
97 | entityName,
98 | entityIds,
99 | parentName,
100 | parentId,
101 | ) => {
102 | const identifier = getToggleKey(
103 | entityName,
104 | entityIds,
105 | parentName,
106 | parentId,
107 | );
108 | const { toggleIds } = state.entities[entityName];
109 |
110 | return hasKey(toggleIds, identifier)
111 | ? toggleIds[identifier].status
112 | : false;
113 | };
114 |
--------------------------------------------------------------------------------
/src/redux/services/api.js:
--------------------------------------------------------------------------------
1 | /**
2 | * This is a service to do all the api calls (get, post, put, delete)
3 | */
4 |
5 | import axios from 'axios';
6 | import { computeUrl } from '../utils';
7 |
8 | const api = (method, action) => {
9 | const url = `http://localhost:8000/${computeUrl(method, action)}`;
10 |
11 | const headers = method === 'GET'
12 | ? {}
13 | : {
14 | 'Content-Type': 'application/json',
15 | };
16 |
17 | const config = { method, url, headers };
18 |
19 | if (method !== 'GET') {
20 | config.data = action.body;
21 | }
22 |
23 | return axios
24 | .request(config)
25 | .then(response => response.data);
26 | };
27 |
28 | export default api;
29 |
--------------------------------------------------------------------------------
/src/redux/utils/index.js:
--------------------------------------------------------------------------------
1 | import { schema } from 'normalizr';
2 |
3 | /**
4 | * Returns the redux type
5 | */
6 | export const getReduxType = (status, method, entityName) => (
7 | `${status}_${method}_${entityName}`.toUpperCase()
8 | );
9 |
10 | /**
11 | * Computes the url to be called depending on the action
12 | * (This will be different for every project)
13 | */
14 | export const computeUrl = (method, action) => {
15 | if (action.type.includes('ADD') || action.type.includes('REMOVE')) {
16 | const {
17 | parentName, entityName, parentId, entityIds,
18 | } = action.meta;
19 | return `${parentName}/${parentId}/${entityName}/${entityIds}`;
20 | }
21 | if (method === 'GET' && action.meta.type === 'multi') {
22 | return `${action.meta.entityName}`;
23 | }
24 | if (method === 'GET' || method === 'PUT') {
25 | return `${action.meta.entityName}/${action.meta.identifier}`;
26 | }
27 | if (method === 'POST') {
28 | return `${action.meta.entityName}`;
29 | }
30 | if (method === 'DELETE') {
31 | return `${action.meta.entityName}/${action.params.id}`;
32 | }
33 | return '';
34 | };
35 |
36 | /**
37 | * Returns the plural of an entity name
38 | */
39 | const pluralizeEntityName = (plural, key) => (
40 | plural || `${key}s`
41 | );
42 |
43 | /**
44 | * Computes entity.define({ ... }) for normalizr library
45 | */
46 | const getDefinition = (definitions, entities) => (
47 | definitions.reduce((result, definition) => {
48 | if (typeof definition === 'object') {
49 | const key = Object.keys(definition)[0];
50 | const value = definition[key];
51 | return { ...result, [key]: entities[value] };
52 | }
53 | return { ...result, [definition]: entities[definition] };
54 | }, {})
55 | );
56 |
57 | /**
58 | * Computes schema using normalizr Entity and Array
59 | */
60 | export const computeSchema = (userSchema) => {
61 | const entities = Object.keys(userSchema).reduce((result, key) => {
62 | const keySchema = userSchema[key];
63 | const entitySchema = new schema.Entity(key);
64 | const entitiesSchema = new schema.Array(entitySchema);
65 | const pluralKey = pluralizeEntityName(keySchema.plural, key);
66 | return { ...result, [key]: entitySchema, [pluralKey]: entitiesSchema };
67 | }, {});
68 |
69 | Object.keys(userSchema).forEach((key) => {
70 | const keySchema = userSchema[key];
71 | const definition = getDefinition(keySchema.define, entities);
72 | entities[key].define(definition);
73 | });
74 | return entities;
75 | };
76 |
--------------------------------------------------------------------------------
/src/redux/utils/schema.js:
--------------------------------------------------------------------------------
1 | import { entitiesSchema as schema } from '..';
2 |
3 | /* eslint-disable */
4 |
5 | const isEntityType = schemaEntity => '_key' in schemaEntity;
6 |
7 | const getEntityChildren = schemaEntity => Object.keys(schemaEntity.schema).map((childKey) => {
8 | const child = schemaEntity.schema[childKey];
9 |
10 | if (child._key) {
11 | return child._key;
12 | }
13 |
14 | return child.schema._key;
15 | });
16 |
17 | export const nameOfChildKey = (
18 | parentEntityName,
19 | childEntityName,
20 | ) => {
21 | const parentSchema = schema[parentEntityName].schema;
22 | return Object.keys(parentSchema).find(
23 | key => parentSchema[key].schema._key === childEntityName,
24 | );
25 | };
26 |
27 | export const parentOf = (entityName) => {
28 | const parent = [];
29 |
30 | // FIXME entity variable name is misleading
31 | Object.keys(schema).forEach((entity) => {
32 | const schemaEntity = schema[entity];
33 |
34 | if (isEntityType(schemaEntity)) {
35 | const children = getEntityChildren(schemaEntity);
36 |
37 | if (
38 | children.includes(entityName)
39 | || children.includes(`${entityName}s`)
40 | ) {
41 | parent.push(entity);
42 | }
43 | }
44 | });
45 |
46 | return parent;
47 | };
48 |
49 | export const getIdAttribute = entityName => schema[entityName]._idAttribute;
50 |
--------------------------------------------------------------------------------
/src/serviceWorker.js:
--------------------------------------------------------------------------------
1 | // This optional code is used to register a service worker.
2 | // register() is not called by default.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on subsequent visits to a page, after all the
7 | // existing tabs open on the page have been closed, since previously cached
8 | // resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model and instructions on how to
11 | // opt-in, read http://bit.ly/CRA-PWA
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === 'localhost' ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === '[::1]' ||
17 | // 127.0.0.1/8 is considered localhost for IPv4.
18 | window.location.hostname.match(
19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
20 | )
21 | );
22 |
23 | export function register(config) {
24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
25 | // The URL constructor is available in all browsers that support SW.
26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
27 | if (publicUrl.origin !== window.location.origin) {
28 | // Our service worker won't work if PUBLIC_URL is on a different origin
29 | // from what our page is served on. This might happen if a CDN is used to
30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
31 | return;
32 | }
33 |
34 | window.addEventListener('load', () => {
35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
36 |
37 | if (isLocalhost) {
38 | // This is running on localhost. Let's check if a service worker still exists or not.
39 | checkValidServiceWorker(swUrl, config);
40 |
41 | // Add some additional logging to localhost, pointing developers to the
42 | // service worker/PWA documentation.
43 | navigator.serviceWorker.ready.then(() => {
44 | console.log(
45 | 'This web app is being served cache-first by a service ' +
46 | 'worker. To learn more, visit http://bit.ly/CRA-PWA'
47 | );
48 | });
49 | } else {
50 | // Is not localhost. Just register service worker
51 | registerValidSW(swUrl, config);
52 | }
53 | });
54 | }
55 | }
56 |
57 | function registerValidSW(swUrl, config) {
58 | navigator.serviceWorker
59 | .register(swUrl)
60 | .then(registration => {
61 | registration.onupdatefound = () => {
62 | const installingWorker = registration.installing;
63 | if (installingWorker == null) {
64 | return;
65 | }
66 | installingWorker.onstatechange = () => {
67 | if (installingWorker.state === 'installed') {
68 | if (navigator.serviceWorker.controller) {
69 | // At this point, the updated precached content has been fetched,
70 | // but the previous service worker will still serve the older
71 | // content until all client tabs are closed.
72 | console.log(
73 | 'New content is available and will be used when all ' +
74 | 'tabs for this page are closed. See http://bit.ly/CRA-PWA.'
75 | );
76 |
77 | // Execute callback
78 | if (config && config.onUpdate) {
79 | config.onUpdate(registration);
80 | }
81 | } else {
82 | // At this point, everything has been precached.
83 | // It's the perfect time to display a
84 | // "Content is cached for offline use." message.
85 | console.log('Content is cached for offline use.');
86 |
87 | // Execute callback
88 | if (config && config.onSuccess) {
89 | config.onSuccess(registration);
90 | }
91 | }
92 | }
93 | };
94 | };
95 | })
96 | .catch(error => {
97 | console.error('Error during service worker registration:', error);
98 | });
99 | }
100 |
101 | function checkValidServiceWorker(swUrl, config) {
102 | // Check if the service worker can be found. If it can't reload the page.
103 | fetch(swUrl)
104 | .then(response => {
105 | // Ensure service worker exists, and that we really are getting a JS file.
106 | const contentType = response.headers.get('content-type');
107 | if (
108 | response.status === 404 ||
109 | (contentType != null && contentType.indexOf('javascript') === -1)
110 | ) {
111 | // No service worker found. Probably a different app. Reload the page.
112 | navigator.serviceWorker.ready.then(registration => {
113 | registration.unregister().then(() => {
114 | window.location.reload();
115 | });
116 | });
117 | } else {
118 | // Service worker found. Proceed as normal.
119 | registerValidSW(swUrl, config);
120 | }
121 | })
122 | .catch(() => {
123 | console.log(
124 | 'No internet connection found. App is running in offline mode.'
125 | );
126 | });
127 | }
128 |
129 | export function unregister() {
130 | if ('serviceWorker' in navigator) {
131 | navigator.serviceWorker.ready.then(registration => {
132 | registration.unregister();
133 | });
134 | }
135 | }
136 |
--------------------------------------------------------------------------------