├── .babelrc
├── .editorconfig
├── .eslintrc
├── .gitignore
├── .npmignore
├── .prettierrc
├── LICENSE.md
├── README.md
├── demo
├── Demo.js
├── index.html
├── index.js
└── makeStore.js
├── package.json
├── src
├── actionTypes.js
├── actions.js
├── index.js
├── reducer.js
├── reduxAutoloader.js
├── reduxAutoloader.spec.js
├── sagas.js
├── sagas.spec.js
├── selectors.js
└── utils.js
├── webpack.config.js
├── webpack.demo.config.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "@babel/preset-env",
5 | {
6 | "targets": "> 0.25%, not dead"
7 | }
8 | ],
9 | "@babel/preset-react"
10 | ],
11 | "plugins": [
12 | "@babel/plugin-proposal-export-default-from",
13 | "@babel/plugin-proposal-class-properties"
14 | ],
15 | "env": {
16 | "test": {
17 | "plugins": ["@babel/plugin-transform-runtime"]
18 | }
19 | },
20 | }
21 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | end_of_line = lf
5 | insert_final_newline = true
6 | charset = utf-8
7 | indent_style = space
8 | indent_size = 2
9 | trim_trailing_whitespace = true
10 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["airbnb", "prettier", "prettier/react", "plugin:jest/recommended"],
3 | "env": {
4 | "node": true,
5 | "browser": true,
6 | "es6": true,
7 | "jest": true
8 | },
9 | "parser": "babel-eslint",
10 | "rules": {
11 | "react/jsx-filename-extension": 0,
12 | "react/no-multi-comp": 0,
13 | "import/no-extraneous-dependencies": 0,
14 | "react/require-default-props": 0,
15 | "react/destructuring-assignment": 0,
16 | "react/jsx-one-expression-per-line": 0,
17 | "react/jsx-props-no-spreading": 0,
18 | "max-classes-per-file": 0
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | lib
3 | *.log
4 | .DS_Store
5 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | demo
2 | docs
3 | .babelrc
4 | .eslint*
5 | .editorconfig
6 | .npmignore
7 | webpack.*
8 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "trailingComma": "es5"
4 | }
5 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2017 Wolt Enterprises
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # redux-autoloader
2 |
3 | [](https://badge.fury.io/js/redux-autoloader)
4 | [](https://npmjs.org/package/redux-autoloader)
5 |
6 | > A higher order component for declarative data loading in React and Redux.
7 |
8 | ## Install
9 |
10 | 1. Install via NPM
11 |
12 | ```
13 | npm install --save redux-autoloader
14 | ```
15 |
16 | 2. Register reducer in your root reducer
17 |
18 | The reducer must be mounted at `reduxAutoloader`.
19 |
20 | ```js
21 | import { reducer as reduxAutoloaderReducer } from 'redux-autoloader';
22 |
23 | const rootReducer = combineReducers({
24 | ...
25 | reduxAutoloader: reduxAutoloaderReducer,
26 | ...
27 | });
28 | ```
29 |
30 | 3. Register saga
31 |
32 | ```js
33 | import { saga as reduxAutoloaderSaga } from 'redux-autoloader';
34 |
35 | ...
36 | sagaMiddleware.run(reduxAutoloaderSaga);
37 | ...
38 | ```
39 |
40 | ## Peer dependencies
41 |
42 | - react
43 | - redux
44 | - react-redux
45 | - redux-saga
46 |
47 | ## Try demo locally
48 |
49 | ```
50 | git clone https://github.com/woltapp/redux-autoloader.git
51 | cd redux-autoloader
52 | npm install
53 | npm start
54 | ```
55 |
56 | ## What problem does the library solve?
57 |
58 | Attaching an API end-point to a view is such a common task that we decided
59 | to create a module to remove unnecessary boilerplate from most of the views
60 | and to greatly speed up the development.
61 | With `redux-autoloader` you can decorate _any_ component and automatically
62 | load data from an API. The higher order component will provide you the props for
63 | handling the state; whether it returned data, is currently loading or returned
64 | an error. Moreover, the data can be automatically reloaded both periodically
65 | or manually. The library removes the tedious work of writing the logic of
66 | handling common request/success/failure state, registering refreshing
67 | and cache invalidation.
68 |
69 | ## Examples
70 |
71 | #### Super simple data loader
72 |
73 | ```jsx
74 | import { reduxAutoloader } from 'redux-autoloader';
75 | ...
76 |
77 | const ExampleComponent = ({
78 | data, // provided by reduxAutoloader
79 | error, // provided by reduxAutoloader
80 | isLoading, // provided by reduxAutoloader
81 | }) = (
82 |
83 | {isLoading && 'Loading data'}
84 |
85 | {error ? JSON.stringify(error) : (
86 |
87 | Your data: {JSON.stringify(data)}
88 |
89 | )}
90 |
91 | );
92 |
93 | const ConnectedComponent = reduxAutoloader({
94 | name: 'example-loader-1', // A unique name for the loader
95 | apiCall: yourDataFech, // A function that returns a promise
96 | })(ExampleComponent);
97 | ```
98 |
99 | #### Set cache expiration and prevent excessive page loads
100 |
101 | ```jsx
102 | const ConnectedComponent = reduxAutoloader({
103 | name: 'example-loader-2',
104 | apiCall: yourDataFech,
105 | reloadOnMount: false, // Prevent triggering reload on mount, default: true
106 | cacheExpiresIn: 60000, // Set cache expiration time: data will be
107 | // loaded on mount after 1 minute even if reloadOnMount=false
108 | })(ExampleComponent);
109 | ```
110 |
111 | #### Set auto-refresh
112 |
113 | ```jsx
114 | const ConnectedComponent = reduxAutoloader({
115 | name: 'example-loader-3',
116 | apiCall: yourDataFech,
117 | autoRefreshInterval: 5000, // Set loader to automatically refetch data every 5 seconds.
118 | // Can be stopped by calling props.stopRefresh().
119 | })(ExampleComponent);
120 | ```
121 |
122 | #### Reload (refresh) when prop changes
123 |
124 | ```jsx
125 | const ConnectedComponent = reduxAutoloader({
126 | name: 'example-loader-3',
127 | apiCall: yourDataFech,
128 | reload: (props, nextProps) => props.myProp !== nextProps.myProp, // Watch when `myProp`
129 | // changes and reload
130 | })(ExampleComponent);
131 | ```
132 |
133 | ## API Documentation
134 |
135 | `reduxAutoloader(options, mapStateToProps)` takes `options` (Object) as
136 | first _(required)_ argument and `mapStateToProps` (Function) as second _(optional)_ argument.
137 |
138 | ### Options
139 |
140 | - **`name`** _(String|Function -> String)_: A unique name for the loader (string) or a function that
141 | returns a name. If you make multiple loaders with the same name, they will share the same
142 | data (state). - always required - example: `name: 'all-users'` - example: `` name: props => `user-loader-${props.userId}` ``
143 |
144 | - **`apiCall`** _(Function -> Promise)_: A function that returns a promise, which is usually
145 | an API call. If you want to provide arguments for the function, simply wrap it in a function
146 | that gets `props` as an argument. If left undefined, `reduxAutoloader` can be used
147 | simply as a connector to the data state. - example: `apiCall: props => fetchUser(props.userId)` - default: `undefined`
148 |
149 | - **`startOnMount`** _(Bool)_: Control the behavior of the loader on mount. Set to `false`
150 | if you do not want load on mount and you don't want to start autorefreshing automatically
151 | (if `autoRefreshInterval` is set). - default: `true` (enable refresh on mount and start auto refreshing)
152 |
153 | - **`autoRefreshInterval`** _(Number|Function -> Number)_: Provide an integer in milliseconds to define
154 | the interval of automatic refreshing. You can define also a function to return interval dynamically based on
155 | props. If set to `0` or `undefined`, automatic refresh won't be started. - default: `0` (no auto refreshing) - example: `autoRefreshInterval: props => props.interval`
156 |
157 | - **`loadOnInitialize`** _(Bool)_: Control whether to load the data immediately after initialization
158 | (component mounted). - default: `true`
159 |
160 | - **`cacheExpiresIn`** _(Number)_: Set the data expiration time, leavy empty for no expiration.
161 | If set, cache expiration will be checked on `componentWillMount`. Use with `reloadOnMount: false` to
162 | e.g. prevent excessive page loads. - default: `0` (no expiration)
163 |
164 | - **`reloadOnMount`** _(Bool)_: Control whether reload is done always on component re-mount.
165 |
166 | - default: `true`
167 |
168 | - **`resetOnUnmount`** _(Bool)_: Control whether to completely reset data-loader state on unmount.
169 |
170 | - default: `true`
171 |
172 | - **`reload`** _(Function -> Bool)_: This function is run when the decorated component
173 | receives new props. The function takes `props` (current props) as first argument
174 | and `nextProps` as second. When the function returns `true`, it performs a refresh on the
175 | data loader. Compared to `reinitialize` (below), this won't reset the loader state. - example: `reload: (props, nextProps) => props.userId !== nextProps.userId` - default: `() => false` - **! NOTE !** setting `reload: () => true` or any other function that returns
176 | always true will cause an infinite loop (do not do this!)
177 |
178 | - **`reinitialize`** _(Function -> Bool)_: This function is run when the decorated component
179 | receives new props. The function takes `props` (current props) as first argument
180 | and `nextProps` as second. When the function returns `true`, it resets the data loader; effectively
181 | re-mounting the component with a clean loader state. - example: `reinitialize: (props, nextProps) => props.userId !== nextProps.userId` - default: `() => false` - **! NOTE !** setting `reinitialize: () => true` or any other function that returns
182 | always true will cause an infinite loop (do not do this!)
183 |
184 | - **`pureConnect`** _(Bool)_: This library uses `connect()` from `react-redux` under hood. Set `pureConnect: false` if you wish to prevent `connect()` from controlling component updates based on props.
185 |
186 | - default: `true`
187 |
188 | - **`renderUninitialized`** _(Bool)_: Render wrapped component when the loader state has not yet been initialized.
189 | - default: `false`
190 |
191 | ### mapStateToProps
192 |
193 | `mapStateToProps` is an optional function to provide if you want to
194 | select only some props of the loader state or wish to rename them.
195 |
196 | Example:
197 |
198 | ```js
199 | const ConnectedComponent = reduxAutoloader(options, state => ({
200 | isLoadingUsers: state.isLoading,
201 | users: state.data,
202 | }));
203 | ```
204 |
205 | ### Props
206 |
207 | Props provided to the wrapped component.
208 |
209 | - **`isLoading`** _(Bool)_: `true` if `apiCall` is triggered and not yet resolved.
210 | - **`isRefreshing`** _(Bool)_: `true` if loader is auto-refreshing.
211 | - **`data`** _(any)_: Resolved data received from the apiCall Promise.
212 | - **`dataReceivedAt`** _(Number)_: Datetime as UNIX epoch when data was received.
213 | - **`error`** _(any)_: Rejected data received from the apiCall Promise.
214 | - **`errorReceivedAt`** _(Number)_: Datetime as UNIX epoch when error was received.
215 | - **`refresh`** _(Function)_: Call to refresh (reload) data immediately.
216 | - **`startAutoRefresh`** _(Function)_: Call to start auto-refreshing. Takes `refreshInterval` as first optional argument. Takes `options` object as second argument. Set `options={ loadImmediately: false }` to start refreshing but skip first load.
217 | - **`stopAutoRefresh`** _(Function)_: Call to stop auto-refreshing.
218 |
219 | ## License
220 |
221 | MIT
222 |
--------------------------------------------------------------------------------
/demo/Demo.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/prop-types, no-console, react/forbid-prop-types */
2 | import React, { Component } from 'react';
3 | import PropTypes from 'prop-types';
4 | import { Provider } from 'react-redux';
5 |
6 | import makeStore from './makeStore';
7 | import { reduxAutoloader } from '../src/index';
8 |
9 | const store = makeStore();
10 |
11 | const demoApi = () =>
12 | new Promise(resolve =>
13 | setTimeout(() => {
14 | const data = new Date();
15 | resolve(data);
16 | console.info(`demoApi request: ${JSON.stringify(data)}`);
17 | }, 1000)
18 | );
19 |
20 | const LoaderView = ({
21 | style,
22 | data,
23 | isLoading,
24 | isRefreshing,
25 | refresh,
26 | stopAutoRefresh,
27 | startAutoRefresh,
28 | }) => (
29 |
30 | {JSON.stringify(data)} {!!isLoading && 'Updating...'}
31 |
32 | isLoading: {JSON.stringify(isLoading)}
33 | isRefreshing: {JSON.stringify(isRefreshing)}
34 |
35 |
36 |
37 | Refresh
38 |
39 |
40 | startAutoRefresh(2000)}>
41 | Start refresh
42 |
43 |
44 |
45 | Stop refresh
46 |
47 |
48 |
49 | );
50 |
51 | LoaderView.propTypes = {
52 | data: PropTypes.object,
53 | isLoading: PropTypes.bool.isRequired,
54 | isRefreshing: PropTypes.bool.isRequired,
55 | style: PropTypes.object,
56 | refresh: PropTypes.func.isRequired,
57 | stopAutoRefresh: PropTypes.func.isRequired,
58 | startAutoRefresh: PropTypes.func.isRequired,
59 | };
60 |
61 | const createMounter = (name, Wrapped) =>
62 | class MountedComponent extends Component {
63 | constructor(props) {
64 | super(props);
65 | this.state = { mounted: true };
66 | }
67 |
68 | render() {
69 | const { mounted } = this.state;
70 |
71 | return (
72 |
80 |
83 | {name}
84 |
85 | this.setState({ mounted: !mounted })}
88 | >
89 | {mounted ? 'Unmount' : 'Mount'}
90 |
91 |
92 |
93 |
94 | {mounted ? (
95 |
96 | ) : (
97 | [loader unmounted]
98 | )}
99 |
100 |
101 | );
102 | }
103 | };
104 |
105 | const LoaderView1 = createMounter(
106 | 'Loader 1 (auto refresh in 2000ms)',
107 | reduxAutoloader({
108 | name: 'demo-loader-1',
109 | autoRefreshInterval: 2000,
110 | reloadOnMount: true,
111 | resetOnUnmount: true,
112 | cacheExpiresIn: 10000,
113 | apiCall: demoApi,
114 | })(LoaderView)
115 | );
116 |
117 | const LoaderView2 = createMounter(
118 | 'Loader 2 (no reload on mount)',
119 | reduxAutoloader({
120 | name: 'demo-loader-2',
121 | autoRefreshInterval: 3000,
122 | reloadOnMount: false,
123 | resetOnUnmount: false,
124 | cacheExpiresIn: 20000,
125 | apiCall: demoApi,
126 | })(LoaderView)
127 | );
128 |
129 | const LoaderView3 = createMounter(
130 | 'Loader 3 (no initial auto refresh)',
131 | reduxAutoloader({
132 | name: 'demo-loader-3',
133 | autoRefreshInterval: false,
134 | apiCall: demoApi,
135 | })(LoaderView)
136 | );
137 |
138 | const LoaderView4 = createMounter(
139 | 'Loader 4 (autorefresh start and load prevented on mount)',
140 | reduxAutoloader({
141 | name: 'demo-loader-4',
142 | autoRefreshInterval: 1000,
143 | loadOnInitialize: false,
144 | startOnMount: false,
145 | apiCall: demoApi,
146 | })(LoaderView)
147 | );
148 |
149 | const MainView = () => (
150 |
151 |
152 |
redux-autoloader demo
153 |
154 |
155 |
156 |
157 |
158 |
159 | );
160 |
161 | export default MainView;
162 |
--------------------------------------------------------------------------------
/demo/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | redux-autoloader/demo
6 |
7 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/demo/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import Demo from './Demo';
4 |
5 | ReactDOM.render( , document.querySelector('#app'));
6 |
--------------------------------------------------------------------------------
/demo/makeStore.js:
--------------------------------------------------------------------------------
1 | import { createStore, combineReducers, applyMiddleware } from 'redux';
2 | import createSagaMiddleware from 'redux-saga';
3 |
4 | import { reducer, saga } from '../src/index';
5 |
6 | const makeStore = () => {
7 | const sagaMiddleware = createSagaMiddleware();
8 |
9 | const store = createStore(
10 | combineReducers({ reduxAutoloader: reducer }),
11 | applyMiddleware(sagaMiddleware)
12 | );
13 | sagaMiddleware.run(saga);
14 | return store;
15 | };
16 |
17 | export default makeStore;
18 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "redux-autoloader",
3 | "version": "1.0.0-rc.21",
4 | "description": "A higher order component for declarative data loading in React and Redux.",
5 | "engines": {
6 | "node": ">=6.9.0",
7 | "npm": ">=5.0.0"
8 | },
9 | "main": "./lib/redux-autoloader.js",
10 | "scripts": {
11 | "build": "webpack -p --config webpack.config.js --progress --colors",
12 | "lint": "eslint ./src",
13 | "test": "jest",
14 | "test:watch": "jest --watchAll",
15 | "demo": "webpack-dev-server --config webpack.demo.config.js --content-base demo/ --mode development",
16 | "prepublishOnly": "npm run build",
17 | "start": "npm run demo",
18 | "analyze-bundle-size": "ANALYZE_BUNDLE=true npm run build",
19 | "prettier:all": "prettier --write '**/*.*(js|jsx|css|scss|scssm|json|md)'"
20 | },
21 | "author": "nygardk",
22 | "license": "MIT",
23 | "repository": {
24 | "type": "git",
25 | "url": "https://github.com/woltapp/redux-autoloader"
26 | },
27 | "keywords": [
28 | "react",
29 | "redux",
30 | "loader",
31 | "auto",
32 | "refresh",
33 | "data",
34 | "component",
35 | "react-component"
36 | ],
37 | "peerDependencies": {
38 | "react": ">=16.3.0",
39 | "react-redux": ">=5.1.0",
40 | "redux": ">=4.0.0",
41 | "redux-saga": "^1.0.0"
42 | },
43 | "devDependencies": {
44 | "@babel/cli": "7.8.4",
45 | "@babel/core": "7.9.0",
46 | "@babel/plugin-proposal-class-properties": "7.8.3",
47 | "@babel/plugin-proposal-export-default-from": "7.8.3",
48 | "@babel/plugin-transform-runtime": "7.9.0",
49 | "@babel/polyfill": "7.8.7",
50 | "@babel/preset-env": "7.9.5",
51 | "@babel/preset-react": "7.9.4",
52 | "@redux-saga/testing-utils": "^1.1.3",
53 | "babel-eslint": "10.1.0",
54 | "babel-jest": "25.3.0",
55 | "babel-loader": "8.1.0",
56 | "cross-env": "7.0.2",
57 | "eslint": "6.8.0",
58 | "eslint-config-airbnb": "18.1.0",
59 | "eslint-config-prettier": "6.10.1",
60 | "eslint-loader": "3.0.3",
61 | "eslint-plugin-import": "2.20.2",
62 | "eslint-plugin-jest": "23.8.2",
63 | "eslint-plugin-jsx-a11y": "6.2.3",
64 | "eslint-plugin-prettier": "3.1.3",
65 | "eslint-plugin-react": "7.19.0",
66 | "husky": "4.2.5",
67 | "jest": "25.3.0",
68 | "precise-commits": "1.0.2",
69 | "prettier": "1.19.1",
70 | "prop-types": "^15.7.2",
71 | "react": "16.9.0",
72 | "react-dom": "16.9.0",
73 | "react-redux": "7.1.0",
74 | "redux": "4.0.4",
75 | "redux-saga": "1.1.3",
76 | "sinon": "9.0.2",
77 | "webpack": "4.42.1",
78 | "webpack-bundle-analyzer": "3.7.0",
79 | "webpack-cli": "3.3.11",
80 | "webpack-dev-server": "3.10.3"
81 | },
82 | "dependencies": {
83 | "@babel/runtime": "^7.9.2",
84 | "lodash.debounce": "4.0.8",
85 | "react-display-name": "^0.2.0"
86 | },
87 | "husky": {
88 | "hooks": {
89 | "pre-commit": "precise-commits"
90 | }
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/src/actionTypes.js:
--------------------------------------------------------------------------------
1 | export const INITIALIZE = '@@redux-autoloader/INITIALIZE';
2 | export const FETCH_DATA_REQUEST = '@@redux-autoloader/FETCH_DATA_REQUEST';
3 | export const FETCH_DATA_SUCCESS = '@@redux-autoloader/FETCH_DATA_SUCCESS';
4 | export const FETCH_DATA_FAILURE = '@@redux-autoloader/FETCH_DATA_FAILURE';
5 | export const START_REFRESH = '@@redux-autoloader/START_REFRESH';
6 | export const STOP_REFRESH = '@@redux-autoloader/STOP_REFRESH';
7 | export const LOAD = '@@redux-autoloader/LOAD';
8 | export const RESET = '@@redux-autoloader/RESET';
9 | export const SET_CONFIG = '@@redux-autoloader/SET_CONFIG';
10 |
--------------------------------------------------------------------------------
/src/actions.js:
--------------------------------------------------------------------------------
1 | import {
2 | INITIALIZE,
3 | FETCH_DATA_REQUEST,
4 | FETCH_DATA_SUCCESS,
5 | FETCH_DATA_FAILURE,
6 | START_REFRESH,
7 | STOP_REFRESH,
8 | LOAD,
9 | RESET,
10 | SET_CONFIG,
11 | } from './actionTypes';
12 |
13 | export const initialize = (loader, config) => ({
14 | type: INITIALIZE,
15 | meta: { loader },
16 | payload: { config },
17 | });
18 |
19 | export const fetchDataRequest = (loader, { apiCall }) => ({
20 | type: FETCH_DATA_REQUEST,
21 | meta: { loader },
22 | payload: { apiCall },
23 | });
24 |
25 | export const fetchDataSuccess = (loader, { data }) => ({
26 | type: FETCH_DATA_SUCCESS,
27 | meta: { loader },
28 | payload: { data, dataReceivedAt: Date.now() },
29 | });
30 |
31 | export const fetchDataFailure = (loader, { error }) => ({
32 | type: FETCH_DATA_FAILURE,
33 | meta: { loader },
34 | payload: { error, errorReceivedAt: Date.now() },
35 | error: true,
36 | });
37 |
38 | export const startRefresh = (
39 | loader,
40 | { apiCall, newAutoRefreshInterval, loadImmediately }
41 | ) => ({
42 | type: START_REFRESH,
43 | meta: { loader },
44 | payload: { apiCall, newAutoRefreshInterval, loadImmediately },
45 | });
46 |
47 | export const stopRefresh = loader => ({ type: STOP_REFRESH, meta: { loader } });
48 |
49 | export const load = (loader, { apiCall }) => ({
50 | type: LOAD,
51 | meta: { loader },
52 | payload: { apiCall },
53 | });
54 |
55 | export const reset = loader => ({ type: RESET, meta: { loader } });
56 |
57 | export const setConfig = (loader, config) => ({
58 | type: SET_CONFIG,
59 | meta: { loader },
60 | payload: config,
61 | });
62 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | export saga from './sagas';
2 | export reducer from './reducer';
3 | export * from './selectors';
4 | export * from './actions';
5 | export reduxAutoloader from './reduxAutoloader';
6 |
--------------------------------------------------------------------------------
/src/reducer.js:
--------------------------------------------------------------------------------
1 | import {
2 | INITIALIZE,
3 | FETCH_DATA_REQUEST,
4 | FETCH_DATA_SUCCESS,
5 | FETCH_DATA_FAILURE,
6 | START_REFRESH,
7 | STOP_REFRESH,
8 | RESET,
9 | SET_CONFIG,
10 | } from './actionTypes';
11 |
12 | const initialState = {
13 | refreshing: false,
14 | loading: false,
15 | data: undefined,
16 | error: undefined,
17 | updatedAt: undefined,
18 | };
19 |
20 | function reducer(state = {}, action) {
21 | switch (action.type) {
22 | case INITIALIZE:
23 | return {
24 | ...initialState,
25 | config: action.payload.config,
26 | };
27 |
28 | case RESET:
29 | return undefined;
30 |
31 | case FETCH_DATA_REQUEST:
32 | return {
33 | ...state,
34 | loading: true,
35 | };
36 |
37 | case FETCH_DATA_SUCCESS:
38 | return {
39 | ...state,
40 | loading: false,
41 | data: action.payload.data,
42 | dataReceivedAt: action.payload.dataReceivedAt,
43 | error: undefined,
44 | errorReceivedAt: undefined,
45 | updatedAt: action.payload.dataReceivedAt,
46 | };
47 |
48 | case FETCH_DATA_FAILURE:
49 | return {
50 | ...state,
51 | loading: false,
52 | errorReceivedAt: action.payload.errorReceivedAt,
53 | error: action.payload.error,
54 | updatedAt: action.payload.errorReceivedAt,
55 | };
56 |
57 | case START_REFRESH:
58 | return {
59 | ...state,
60 | refreshing: true,
61 | };
62 |
63 | case STOP_REFRESH:
64 | return {
65 | ...state,
66 | refreshing: false,
67 | };
68 |
69 | case SET_CONFIG:
70 | return {
71 | ...state,
72 | config: {
73 | ...state.config,
74 | ...action.payload,
75 | },
76 | };
77 |
78 | default:
79 | return state;
80 | }
81 | }
82 |
83 | export default function(state = {}, action) {
84 | if (!action.meta || !action.meta.loader) {
85 | return state;
86 | }
87 |
88 | return {
89 | ...state,
90 | [action.meta.loader]: reducer(state[action.meta.loader], action),
91 | };
92 | }
93 |
--------------------------------------------------------------------------------
/src/reduxAutoloader.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/no-unused-prop-types,
2 | react/prefer-stateless-function,
3 | react/forbid-prop-types
4 | */
5 | import React, { PureComponent, createElement } from 'react';
6 | import PropTypes from 'prop-types';
7 | import { connect } from 'react-redux';
8 | import getDisplayName from 'react-display-name';
9 |
10 | import { assert } from './utils';
11 | import * as actions from './actions';
12 | import {
13 | isInitialized,
14 | isLoading,
15 | isRefreshing,
16 | getDataReceivedAt,
17 | getError,
18 | getErrorReceivedAt,
19 | getUpdatedAt,
20 | createMemoizedGetData,
21 | } from './selectors';
22 |
23 | const REDUX_AUTOLOADER_DEBUG = process.env.REDUX_AUTOLOADER_DEBUG === 'true';
24 |
25 | function cacheIsStale(dataReceivedAt, expiresIn) {
26 | if (!dataReceivedAt || !expiresIn) {
27 | return true;
28 | }
29 |
30 | return Date.now() > dataReceivedAt + expiresIn;
31 | }
32 |
33 | export default function reduxAutoloader(
34 | {
35 | /* eslint-disable react/prop-types */
36 | debug,
37 | name,
38 | apiCall,
39 | loadOnInitialize = true,
40 | startOnMount = true,
41 | reloadOnMount = true,
42 | resetOnUnmount = true,
43 | cacheExpiresIn = 0,
44 | autoRefreshInterval = 0,
45 | reinitialize = () => false,
46 | reload = () => false,
47 | pureConnect = true,
48 | renderUninitialized = false,
49 | /* eslint-enable react/prop-types */
50 | },
51 | mapStateToProps = state => state
52 | ) {
53 | assert(name, 'name is required');
54 | assert(
55 | typeof name === 'function' || typeof name === 'string',
56 | 'name must be a function or a string'
57 | );
58 | assert(typeof mapStateToProps === 'function', 'selector must be a function');
59 | assert(typeof startOnMount === 'boolean', 'startOnMount must be a boolean');
60 | assert(typeof reloadOnMount === 'boolean', 'reloadOnMount must be a boolean');
61 | assert(
62 | typeof resetOnUnmount === 'boolean',
63 | 'resetOnUnmount must be a boolean'
64 | );
65 | assert(typeof pureConnect === 'boolean', 'pureConnect must be a boolean');
66 | assert(typeof reinitialize === 'function', 'reinitialize must be a boolean');
67 | assert(typeof reload === 'function', 'reload must be a boolean');
68 |
69 | if (autoRefreshInterval) {
70 | assert(
71 | typeof autoRefreshInterval === 'function' ||
72 | typeof autoRefreshInterval === 'number',
73 | 'autoRefreshInterval must be a function or a number'
74 | );
75 | }
76 |
77 | if (apiCall) {
78 | assert(apiCall, 'apiCall must be a function');
79 | }
80 |
81 | const getData = createMemoizedGetData();
82 |
83 | const getReducerName = typeof name === 'function' ? name : () => name;
84 | const getAutoRefreshInterval =
85 | typeof autoRefreshInterval === 'function'
86 | ? autoRefreshInterval
87 | : () => autoRefreshInterval;
88 |
89 | const connector = connect(
90 | (state, props) => {
91 | const reducerName = getReducerName(props);
92 |
93 | if (!isInitialized(state, reducerName)) {
94 | return { hasBeenInitialized: false };
95 | }
96 |
97 | return {
98 | hasBeenInitialized: true,
99 | isLoading: isLoading(state, reducerName),
100 | isRefreshing: isRefreshing(state, reducerName),
101 | data: getData(state, reducerName),
102 | dataReceivedAt: getDataReceivedAt(state, reducerName),
103 | error: getError(state, reducerName),
104 | errorReceivedAt: getErrorReceivedAt(state, reducerName),
105 | updatedAt: getUpdatedAt(state, reducerName),
106 | };
107 | },
108 | {
109 | initialize: actions.initialize,
110 | load: actions.load,
111 | startRefresh: actions.startRefresh,
112 | stopRefresh: actions.stopRefresh,
113 | reset: actions.reset,
114 | setConfig: actions.setConfig,
115 | },
116 | null,
117 | { pure: pureConnect }
118 | );
119 |
120 | return WrappedComponent => {
121 | class DataComponent extends PureComponent {
122 | /* eslint-disable no-console */
123 | debugLog =
124 | REDUX_AUTOLOADER_DEBUG || debug
125 | ? msg => console.info(`${getReducerName(this.props)} | ${msg}`)
126 | : () => {};
127 | /* eslint-enable no-console */
128 |
129 | componentDidMount() {
130 | if (!this.props.hasBeenInitialized) {
131 | this.debugLog('initialize: on mount');
132 | this.props.initialize(getReducerName(this.props), {
133 | autoRefreshInterval: getAutoRefreshInterval(this.props),
134 | });
135 | }
136 |
137 | if (
138 | this.props.hasBeenInitialized &&
139 | reloadOnMount &&
140 | !this.props.isLoading
141 | ) {
142 | this.debugLog('reload: on mount');
143 | this.refresh();
144 | } else if (
145 | cacheExpiresIn &&
146 | this.props.updatedAt &&
147 | !this.props.isLoading &&
148 | cacheIsStale(this.props.updatedAt, cacheExpiresIn)
149 | ) {
150 | this.debugLog('reload: cache is stale');
151 | this.refresh();
152 | }
153 |
154 | if (
155 | this.props.hasBeenInitialized &&
156 | startOnMount &&
157 | getAutoRefreshInterval(this.props)
158 | ) {
159 | this.debugLog('startRefresh: on mount with autoRefreshInterval');
160 | this.props.startRefresh(getReducerName(this.props), {
161 | apiCall: () => apiCall(this.getMappedProps(this.props)),
162 | loadImmediately:
163 | (this.props.updatedAt && reloadOnMount) ||
164 | (!this.props.updatedAt && loadOnInitialize),
165 | });
166 | }
167 | }
168 |
169 | componentDidUpdate(prevProps) {
170 | /* config setting */
171 |
172 | if (
173 | getAutoRefreshInterval(prevProps) !==
174 | getAutoRefreshInterval(this.props)
175 | ) {
176 | this.debugLog('setConfig: autoRefreshInterval changed');
177 | prevProps.setConfig(getReducerName(this.props), {
178 | autoRefreshInterval: getAutoRefreshInterval(this.props),
179 | });
180 | }
181 |
182 | /* initialization, startRefresh and load */
183 |
184 | if (
185 | !prevProps.hasBeenInitialized &&
186 | this.props.hasBeenInitialized &&
187 | loadOnInitialize &&
188 | !this.props.isLoading &&
189 | !autoRefreshInterval
190 | ) {
191 | this.debugLog('load: on initialization without autoRefresh');
192 | this.props.load(getReducerName(this.props), {
193 | apiCall: () => apiCall(this.getMappedProps(this.props)),
194 | });
195 | } else if (
196 | !prevProps.hasBeenInitialized &&
197 | this.props.hasBeenInitialized &&
198 | autoRefreshInterval &&
199 | startOnMount
200 | ) {
201 | this.debugLog('startRefresh: after initialized');
202 | this.props.startRefresh(getReducerName(this.props), {
203 | apiCall: () => apiCall(this.getMappedProps(this.props)),
204 | loadImmediately: loadOnInitialize,
205 | });
206 | } else if (
207 | prevProps.hasBeenInitialized &&
208 | !this.props.hasBeenInitialized
209 | ) {
210 | this.debugLog('initialize: was unitialized');
211 | this.props.initialize(getReducerName(this.props), {
212 | autoRefreshInterval: getAutoRefreshInterval(this.props),
213 | });
214 | } else if (!prevProps.hasBeenInitialized) {
215 | return;
216 | } else if (reload(prevProps, this.props)) {
217 | this.debugLog('load: reload');
218 | this.props.load(getReducerName(this.props), {
219 | apiCall: () => apiCall(this.getMappedProps(this.props)),
220 | });
221 | } else if (
222 | cacheExpiresIn &&
223 | this.props.updatedAt &&
224 | !this.props.isLoading &&
225 | cacheIsStale(this.props.updatedAt, cacheExpiresIn)
226 | ) {
227 | this.debugLog('load: cache is stale');
228 | this.props.load(getReducerName(this.props), {
229 | apiCall: () => apiCall(this.getMappedProps(this.props)),
230 | });
231 | } else if (reinitialize(prevProps, this.props)) {
232 | this.debugLog('reset: reinitialize');
233 | this.props.reset(getReducerName(this.props));
234 | }
235 |
236 | if (getReducerName(prevProps) !== getReducerName(this.props)) {
237 | this.debugLog('stopRefresh: name changed');
238 | this.stopAutoRefresh(prevProps);
239 | }
240 | }
241 |
242 | componentWillUnmount() {
243 | if (this.props.isRefreshing) {
244 | this.debugLog('stopRefresh: was refreshing and unmounted');
245 | this.props.stopRefresh(getReducerName(this.props));
246 | }
247 |
248 | if (resetOnUnmount) {
249 | this.debugLog('reset: on unmount');
250 | this.props.reset(getReducerName(this.props));
251 | }
252 | }
253 |
254 | getMappedProps = props => {
255 | const exposedProps = {
256 | isLoading: props.isLoading,
257 | isRefreshing: props.isRefreshing,
258 | data: props.data,
259 | dataReceivedAt: props.dataReceivedAt,
260 | error: props.error,
261 | errorReceivedAt: props.errorReceivedAt,
262 | refresh: this.refresh,
263 | startAutoRefresh: this.startAutoRefresh,
264 | stopAutoRefresh: this.stopAutoRefresh,
265 | };
266 |
267 | return {
268 | ...props.passedProps,
269 | ...mapStateToProps(exposedProps, props.passedProps),
270 | };
271 | };
272 |
273 | refresh = () => {
274 | this.props.load(getReducerName(this.props), {
275 | apiCall: () => apiCall(this.getMappedProps(this.props)),
276 | });
277 | };
278 |
279 | startAutoRefresh = (newInterval, opts = {}) => {
280 | const loadImmediately = opts.loadImmediately || true;
281 |
282 | this.props.startRefresh(getReducerName(this.props), {
283 | apiCall: () => apiCall(this.getMappedProps(this.props)),
284 | newAutoRefreshInterval: newInterval,
285 | loadImmediately,
286 | });
287 | };
288 |
289 | stopAutoRefresh = () => {
290 | this.props.stopRefresh(getReducerName(this.props));
291 | };
292 |
293 | render() {
294 | if (this.props.hasBeenInitialized || renderUninitialized) {
295 | return ;
296 | }
297 |
298 | return null;
299 | }
300 | }
301 |
302 | DataComponent.propTypes = {
303 | hasBeenInitialized: PropTypes.bool.isRequired,
304 | initialize: PropTypes.func.isRequired,
305 | load: PropTypes.func.isRequired,
306 | startRefresh: PropTypes.func.isRequired,
307 | stopRefresh: PropTypes.func.isRequired,
308 | reset: PropTypes.func.isRequired,
309 | setConfig: PropTypes.func.isRequired,
310 | passedProps: PropTypes.object,
311 | updatedAt: PropTypes.number,
312 |
313 | // exposed props
314 | error: PropTypes.any,
315 | errorReceivedAt: PropTypes.number,
316 | isLoading: PropTypes.bool,
317 | data: PropTypes.any,
318 | dataReceivedAt: PropTypes.number,
319 | isRefreshing: PropTypes.bool,
320 | };
321 |
322 | DataComponent.displayName = `reduxAutoloader-${getDisplayName(
323 | WrappedComponent
324 | )}`;
325 | DataComponent.WrappedComponent = WrappedComponent;
326 |
327 | const ConnectedDataloader = connector(DataComponent);
328 |
329 | return class ReduxAutoloader extends PureComponent {
330 | render() {
331 | return createElement(ConnectedDataloader, {
332 | ...this.props,
333 | passedProps: this.props,
334 | });
335 | }
336 | };
337 | };
338 | }
339 |
--------------------------------------------------------------------------------
/src/reduxAutoloader.spec.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable
2 | react/prop-types,
3 | react/prefer-stateless-function,
4 | no-unused-expressions,
5 | react/require-default-props,
6 | prefer-destructuring,
7 | */
8 | import sinon from 'sinon';
9 | import React, { Component } from 'react';
10 | import PropTypes from 'prop-types';
11 | import ReactDOM from 'react-dom';
12 | import TestUtils from 'react-dom/test-utils';
13 | import createSagaMiddleware from 'redux-saga';
14 | import { Provider } from 'react-redux';
15 | import { createStore, combineReducers, applyMiddleware } from 'redux';
16 |
17 | import saga from './sagas';
18 | import reducer from './reducer';
19 | import reduxAutoloader from './reduxAutoloader';
20 | import * as actionTypes from './actionTypes';
21 | import { fetchDataSuccess, startRefresh } from './actions';
22 |
23 | const makeStore = () => {
24 | const sagaMiddleware = createSagaMiddleware();
25 |
26 | const store = createStore(
27 | combineReducers({ reduxAutoloader: reducer }),
28 | applyMiddleware(sagaMiddleware)
29 | );
30 | sagaMiddleware.run(saga);
31 | return store;
32 | };
33 |
34 | const mockApi = sinon.stub().returns('mock-data');
35 |
36 | const render = (Wrapped, store = makeStore()) =>
37 | TestUtils.renderIntoDocument(
38 |
39 |
40 |
41 | );
42 |
43 | const renderDecorated = (Wrapped, config = {}, store) => {
44 | const Decorated = reduxAutoloader({ name: 'test-loader', ...config })(
45 | Wrapped
46 | );
47 |
48 | return render(Decorated, store);
49 | };
50 |
51 | const renderAndGetProps = (component, config, store) => {
52 | const dom = renderDecorated(component, config, store);
53 |
54 | const renderedComponent = TestUtils.findRenderedComponentWithType(
55 | dom,
56 | component
57 | );
58 | return renderedComponent.props;
59 | };
60 |
61 | class TestComponent extends Component {
62 | render() {
63 | return
;
64 | }
65 | }
66 |
67 | TestComponent.propTypes = {
68 | className: PropTypes.string,
69 | };
70 |
71 | describe('reduxAutoloader', () => {
72 | beforeEach(() => {
73 | mockApi.reset();
74 | });
75 |
76 | test('should be a decorator function', () => {
77 | expect(typeof reduxAutoloader).toBe('function');
78 | });
79 |
80 | test('should render without error', () => {
81 | expect(() => {
82 | renderDecorated(() =>
);
83 | }).not.toThrow();
84 | });
85 |
86 | test('should expose the correct props', () => {
87 | const dom = renderDecorated(TestComponent);
88 | const props = TestUtils.findRenderedComponentWithType(dom, TestComponent)
89 | .props;
90 |
91 | expect(Object.keys(props).sort()).toEqual(
92 | [
93 | 'data',
94 | 'dataReceivedAt',
95 | 'isLoading',
96 | 'refresh',
97 | 'startAutoRefresh',
98 | 'stopAutoRefresh',
99 | 'isRefreshing',
100 | 'error',
101 | 'errorReceivedAt',
102 | ].sort()
103 | );
104 | });
105 |
106 | test('should pass also props from parent', () => {
107 | const WrappedTestComponent = props => (
108 |
109 | );
110 | const dom = renderDecorated(WrappedTestComponent);
111 | const props = TestUtils.findRenderedComponentWithType(dom, TestComponent)
112 | .props;
113 |
114 | expect(Object.keys(props)).toContain('className');
115 | expect(Object.keys(props)).toContain('data');
116 | expect(props.className).toBe('test');
117 | });
118 |
119 | test('should call api on mount', () => {
120 | const fakeApi = sinon.stub().returns(new Promise(() => {}));
121 | renderDecorated(TestComponent, { apiCall: fakeApi });
122 | expect(fakeApi.callCount).toBe(1);
123 | });
124 |
125 | test('should call api on re-render if reloadOnMount is true', () => {
126 | const fakeApi = sinon.stub().returns('somedata');
127 | const store = makeStore();
128 | const Decorated = reduxAutoloader({
129 | name: 'test-loader',
130 | apiCall: fakeApi,
131 | reloadOnMount: true,
132 | })(TestComponent);
133 |
134 | TestUtils.renderIntoDocument(
135 |
136 |
137 |
138 | );
139 |
140 | TestUtils.renderIntoDocument(
141 |
142 |
143 |
144 | );
145 |
146 | expect(fakeApi.callCount).toBe(2);
147 | });
148 |
149 | test('should not call api on re-render if reloadOnMount is false', () => {
150 | const fakeApi = sinon.stub().returns(new Promise(() => {}));
151 | const store = makeStore();
152 | const Decorated = reduxAutoloader({
153 | name: 'test-loader',
154 | apiCall: fakeApi,
155 | reloadOnMount: false,
156 | })(TestComponent);
157 |
158 | TestUtils.renderIntoDocument(
159 |
160 |
161 |
162 | );
163 |
164 | TestUtils.renderIntoDocument(
165 |
166 |
167 |
168 | );
169 |
170 | expect(fakeApi.callCount).toBe(1);
171 | });
172 |
173 | test('should not call api on re-render if reloadOnMount is false and cache is valid', () => {
174 | const clock = sinon.useFakeTimers(Date.now());
175 |
176 | const fakeApi = sinon.stub().returns(new Promise(() => {}));
177 | const store = makeStore();
178 | const Decorated = reduxAutoloader({
179 | name: 'test-loader',
180 | apiCall: fakeApi,
181 | reloadOnMount: false,
182 | cacheExpiresIn: 1000,
183 | })(TestComponent);
184 |
185 | TestUtils.renderIntoDocument(
186 |
187 |
188 |
189 | );
190 | store.dispatch(
191 | fetchDataSuccess('test-loader', { data: 'test-result-data' })
192 | );
193 | clock.tick(500);
194 | TestUtils.renderIntoDocument(
195 |
196 |
197 |
198 | );
199 |
200 | expect(fakeApi.callCount).toBe(1);
201 |
202 | clock.restore();
203 | });
204 |
205 | test('should call api on re-render if reloadOnMount is false and cache is stale', () => {
206 | const clock = sinon.useFakeTimers(Date.now());
207 |
208 | const store = makeStore();
209 | const Decorated = reduxAutoloader({
210 | name: 'test-loader',
211 | apiCall: mockApi,
212 | reloadOnMount: false,
213 | cacheExpiresIn: 1000,
214 | })(TestComponent);
215 |
216 | TestUtils.renderIntoDocument(
217 |
218 |
219 |
220 | );
221 |
222 | clock.tick(1100);
223 |
224 | TestUtils.renderIntoDocument(
225 |
226 |
227 |
228 | );
229 |
230 | expect(mockApi.callCount).toBe(2);
231 |
232 | clock.restore();
233 | });
234 |
235 | test('should not call api on re-render if reloadOnMount is false and cache is not stale', () => {
236 | const clock = sinon.useFakeTimers(Date.now());
237 |
238 | const store = makeStore();
239 | const Decorated = reduxAutoloader({
240 | name: 'test-loader',
241 | apiCall: mockApi,
242 | reloadOnMount: false,
243 | cacheExpiresIn: 1000,
244 | })(TestComponent);
245 |
246 | TestUtils.renderIntoDocument(
247 |
248 |
249 |
250 | );
251 |
252 | clock.tick(900);
253 |
254 | TestUtils.renderIntoDocument(
255 |
256 |
257 |
258 | );
259 |
260 | expect(mockApi.callCount).toBe(1);
261 |
262 | clock.restore();
263 | });
264 |
265 | describe('On initial mount', () => {
266 | test('should load when only apiCall is set', () => {
267 | const store = makeStore();
268 | const Decorated = reduxAutoloader({
269 | name: 'test-loader',
270 | apiCall: mockApi,
271 | })(TestComponent);
272 |
273 | TestUtils.renderIntoDocument(
274 |
275 |
276 |
277 | );
278 |
279 | expect(mockApi.callCount).toBe(1);
280 | });
281 |
282 | test('should load when autoRefreshInterval is set', () => {
283 | const store = makeStore();
284 | const Decorated = reduxAutoloader({
285 | name: 'test-loader',
286 | apiCall: mockApi,
287 | autoRefreshInterval: 10000,
288 | })(TestComponent);
289 |
290 | TestUtils.renderIntoDocument(
291 |
292 |
293 |
294 | );
295 |
296 | expect(mockApi.callCount).toBe(1);
297 | });
298 |
299 | test('should load when reloadOnMount=true, resetOnUnmount=true, cacheExpiresIn and autoRefreshInterval are set', () => {
300 | const clock = sinon.useFakeTimers(Date.now());
301 |
302 | const fakeApi = sinon.stub().returns('somedata');
303 | const store = makeStore();
304 | const Decorated = reduxAutoloader({
305 | name: 'test-loader',
306 | reloadOnMount: true,
307 | resetOnUnmount: true,
308 | cacheExpiresIn: 120000,
309 | autoRefreshInterval: 120000,
310 | apiCall: fakeApi,
311 | })(TestComponent);
312 |
313 | TestUtils.renderIntoDocument(
314 |
315 |
316 |
317 | );
318 |
319 | expect(fakeApi.callCount).toBe(1);
320 |
321 | clock.restore();
322 | });
323 |
324 | test(
325 | 'should not call api after mount if startOnMount=false, reloadOnMount=false, loadOnInitialize=false ' +
326 | 'and autoRefreshInterval=false but should after manual start',
327 | () => {
328 | const clock = sinon.useFakeTimers(Date.now());
329 |
330 | const fakeApi = sinon.stub().returns('somedata');
331 | const store = makeStore();
332 | const Decorated = reduxAutoloader({
333 | name: 'test-loader',
334 | startOnMount: false,
335 | reloadOnMount: false,
336 | loadOnInitialize: false,
337 | autoRefreshInterval: false,
338 | apiCall: fakeApi,
339 | })(TestComponent);
340 |
341 | TestUtils.renderIntoDocument(
342 |
343 |
344 |
345 | );
346 |
347 | store.dispatch(
348 | fetchDataSuccess('test-loader', { data: 'test-result-data' })
349 | );
350 |
351 | clock.tick(1100);
352 |
353 | TestUtils.renderIntoDocument(
354 |
355 |
356 |
357 | );
358 |
359 | expect(fakeApi.callCount).toBe(0);
360 |
361 | store.dispatch(
362 | startRefresh('test-loader', {
363 | apiCall: fakeApi,
364 | newAutoRefreshInterval: 1000,
365 | loadImmediately: true,
366 | props: {},
367 | })
368 | );
369 |
370 | expect(fakeApi.callCount).toBe(1);
371 |
372 | clock.restore();
373 | }
374 | );
375 | });
376 |
377 | describe('reinitialize', () => {
378 | test('should reset and reload when reinitialize returns true', () => {
379 | const renderNode = document.createElement('div');
380 |
381 | const fakeApi = sinon.stub().returns('somedata');
382 | const store = makeStore();
383 | const Decorated = reduxAutoloader({
384 | name: 'test-loader',
385 | apiCall: fakeApi,
386 | reloadOnMount: false,
387 | reinitialize: (props, nextProps) => props.test !== nextProps.test,
388 | })(TestComponent);
389 |
390 | ReactDOM.render(
391 |
392 |
393 | ,
394 | renderNode
395 | );
396 |
397 | ReactDOM.render(
398 |
399 |
400 | ,
401 | renderNode
402 | );
403 |
404 | expect(fakeApi.callCount).toBe(2);
405 | });
406 |
407 | test('should not reset nor reload when reinitialize returns false', () => {
408 | const renderNode = document.createElement('div');
409 |
410 | const fakeApi = sinon.stub().returns('somedata');
411 | const store = makeStore();
412 | const Decorated = reduxAutoloader({
413 | name: 'test-loader',
414 | apiCall: fakeApi,
415 | reloadOnMount: false,
416 | reinitialize: (props, nextProps) => props.test !== nextProps.test,
417 | })(TestComponent);
418 |
419 | ReactDOM.render(
420 |
421 |
422 | ,
423 | renderNode
424 | );
425 |
426 | ReactDOM.render(
427 |
428 |
429 | ,
430 | renderNode
431 | );
432 |
433 | expect(fakeApi.callCount).toBe(1);
434 | });
435 | });
436 |
437 | describe('refresh prop', () => {
438 | test('should fire action to reload from api', () => {
439 | const fakeApi = sinon.stub().returns('somedata');
440 | const store = makeStore();
441 | sinon.spy(store, 'dispatch');
442 |
443 | const props = renderAndGetProps(
444 | TestComponent,
445 | { apiCall: fakeApi },
446 | store
447 | );
448 | store.dispatch.reset();
449 | fakeApi.reset();
450 | props.refresh();
451 |
452 | const dispatchedAction = store.dispatch.firstCall.returnValue;
453 | expect(dispatchedAction.type).toBe(actionTypes.LOAD);
454 | expect(store.dispatch.callCount).toBe(1);
455 | expect(fakeApi.callCount).toBe(1);
456 | });
457 | });
458 |
459 | describe('isLoading prop', () => {
460 | test('should be "false" by default', () => {
461 | const props = renderAndGetProps(TestComponent);
462 | expect(props.isLoading).toBe(false);
463 | });
464 |
465 | test('should be "true" when data loading is triggered', () => {
466 | const fakeApi = () => new Promise(() => {});
467 | const dom = renderDecorated(TestComponent, { apiCall: fakeApi });
468 | const props = TestUtils.findRenderedComponentWithType(dom, TestComponent)
469 | .props;
470 | expect(props.isLoading).toBe(true);
471 | });
472 |
473 | test('should be "false" when data loading is finished', () => {
474 | const store = makeStore();
475 |
476 | const fakeApi = () => new Promise(() => {});
477 |
478 | const DecoratedTestComponent = reduxAutoloader({
479 | name: 'test-loader',
480 | apiCall: fakeApi,
481 | reloadOnMount: false,
482 | })(TestComponent);
483 |
484 | const dom = TestUtils.renderIntoDocument(
485 |
486 |
487 |
488 | );
489 |
490 | // simulate promise resolve
491 | store.dispatch(
492 | fetchDataSuccess('test-loader', { data: 'test-result-data' })
493 | );
494 |
495 | const props = TestUtils.findRenderedComponentWithType(dom, TestComponent)
496 | .props;
497 |
498 | expect(props.isLoading).toBe(false);
499 | });
500 | });
501 |
502 | describe('data prop', () => {
503 | test('should be "undefined" by default', () => {
504 | const props = renderAndGetProps(TestComponent);
505 | expect(props.data).toBe(undefined);
506 | });
507 | });
508 |
509 | describe('dataReceivedAt prop', () => {
510 | test('should be "undefined" by default', () => {
511 | const props = renderAndGetProps(TestComponent);
512 | expect(props.dataReceivedAt).toBe(undefined);
513 | });
514 | });
515 | });
516 |
--------------------------------------------------------------------------------
/src/sagas.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-constant-condition, no-loop-func */
2 | import {
3 | delay,
4 | takeEvery,
5 | race,
6 | call,
7 | put,
8 | take,
9 | cancel,
10 | fork,
11 | select,
12 | } from 'redux-saga/effects';
13 |
14 | import { START_REFRESH, STOP_REFRESH, LOAD, RESET } from './actionTypes';
15 | import {
16 | fetchDataRequest,
17 | fetchDataSuccess,
18 | fetchDataFailure,
19 | } from './actions';
20 | import { getLoaderState } from './selectors';
21 |
22 | export function* fetchData(action) {
23 | yield put(
24 | fetchDataRequest(action.meta.loader, {
25 | apiCall: action.payload.apiCall,
26 | })
27 | );
28 |
29 | try {
30 | const data = yield call(action.payload.apiCall);
31 | yield put(fetchDataSuccess(action.meta.loader, { data }));
32 | } catch (err) {
33 | yield put(fetchDataFailure(action.meta.loader, { error: err }));
34 | }
35 | }
36 |
37 | export function* autoRefresh(action) {
38 | if (action.payload.loadImmediately) {
39 | yield call(fetchData, action);
40 | }
41 |
42 | while (true) {
43 | const loaderState = yield select(getLoaderState);
44 | const interval = action.payload.newAutoRefreshInterval
45 | ? action.payload.newAutoRefreshInterval
46 | : loaderState[action.meta.loader].config.autoRefreshInterval;
47 |
48 | const { delayed, loadAction } = yield race({
49 | delayed: delay(interval),
50 | loadAction: take(
51 | act => act.type === LOAD && act.meta.loader === action.meta.loader
52 | ),
53 | });
54 |
55 | if (loadAction) {
56 | yield call(fetchData, loadAction);
57 | } else if (delayed) {
58 | yield call(fetchData, action);
59 | }
60 | }
61 | }
62 |
63 | export const createDataLoaderFlow = (taskConf = {}) => {
64 | const tasks = { ...taskConf };
65 |
66 | return function* dataLoaderFlow(action) {
67 | const loaderTasks = tasks[action.meta.loader] || {};
68 |
69 | if (!tasks[action.meta.loader]) {
70 | tasks[action.meta.loader] = {};
71 | }
72 |
73 | if (action.type === START_REFRESH && !loaderTasks.autoRefresh) {
74 | tasks[action.meta.loader].autoRefresh = yield fork(autoRefresh, action);
75 | }
76 |
77 | if (
78 | (action.type === RESET || action.type === STOP_REFRESH) &&
79 | loaderTasks.autoRefresh
80 | ) {
81 | yield cancel(loaderTasks.autoRefresh);
82 | delete loaderTasks.autoRefresh;
83 | }
84 |
85 | if (action.type === LOAD && !loaderTasks.autoRefresh) {
86 | yield call(fetchData, action);
87 | }
88 | };
89 | };
90 |
91 | export default function* rootSaga() {
92 | const dataLoaderFlow = createDataLoaderFlow();
93 |
94 | yield takeEvery([START_REFRESH, STOP_REFRESH, LOAD, RESET], dataLoaderFlow);
95 | }
96 |
--------------------------------------------------------------------------------
/src/sagas.spec.js:
--------------------------------------------------------------------------------
1 | import sinon from 'sinon';
2 | import { cancel, call, put, fork } from 'redux-saga/effects';
3 | import { createMockTask } from '@redux-saga/testing-utils';
4 |
5 | import { load, fetchDataRequest, startRefresh, stopRefresh } from './actions';
6 | import { START_REFRESH, STOP_REFRESH, LOAD, RESET } from './actionTypes';
7 | import rootSaga, {
8 | createDataLoaderFlow,
9 | fetchData,
10 | autoRefresh,
11 | } from './sagas';
12 |
13 | describe('fetchData', () => {
14 | const fakeApi = sinon.stub().returns(Promise.resolve('testresult'));
15 |
16 | test('should call api', () => {
17 | const gen = fetchData(load('test-loader', { apiCall: fakeApi }));
18 |
19 | expect(gen.next().value).toEqual(
20 | put(
21 | fetchDataRequest('test-loader', {
22 | apiCall: fakeApi,
23 | })
24 | )
25 | );
26 | expect(gen.next().value).toEqual(call(fakeApi));
27 | });
28 | });
29 |
30 | describe('rootSaga', () => {
31 | let gen;
32 |
33 | beforeAll(() => {
34 | gen = rootSaga();
35 | });
36 |
37 | test('should take every START_REFRESH, STOP_REFRESH, LOAD and RESET action', () => {
38 | const val = gen.next().value;
39 |
40 | expect(val.type).toEqual('FORK');
41 | expect(val.payload.args[0]).toEqual([
42 | START_REFRESH,
43 | STOP_REFRESH,
44 | LOAD,
45 | RESET,
46 | ]);
47 | });
48 | });
49 |
50 | describe('dataLoaderFlow', () => {
51 | const fakeApi = sinon.stub().returns(Promise.resolve('testresult'));
52 | const mockProps = { testProp: 'test' };
53 | const startRefreshAction = startRefresh('test-loader', {
54 | apiCall: fakeApi,
55 | props: mockProps,
56 | });
57 | const stopRefreshAction = stopRefresh('test-loader', {
58 | apiCall: fakeApi,
59 | props: mockProps,
60 | });
61 | const loadAction = load('test-loader', {
62 | apiCall: fakeApi,
63 | props: mockProps,
64 | });
65 |
66 | let gen;
67 | let dataLoaderFlow;
68 |
69 | beforeEach(() => {
70 | dataLoaderFlow = createDataLoaderFlow();
71 | });
72 |
73 | describe('on START_REFRESH action', () => {
74 | beforeEach(() => {
75 | gen = dataLoaderFlow(startRefreshAction);
76 | });
77 |
78 | test('should fork autoRefresh', () => {
79 | expect(gen.next().value).toEqual(fork(autoRefresh, startRefreshAction));
80 | });
81 | });
82 |
83 | describe('on STOP_REFRESH action', () => {
84 | beforeEach(() => {
85 | gen = dataLoaderFlow(stopRefreshAction);
86 | });
87 |
88 | test('should cancel autoRefresh task if it is running', () => {
89 | const mockLoaderTask = createMockTask();
90 | mockLoaderTask.name = 'autoRefresh';
91 | mockLoaderTask.meta = { loader: 'test-loader' };
92 | const startGen = dataLoaderFlow(startRefreshAction);
93 | startGen.next();
94 | startGen.next(mockLoaderTask);
95 | expect(gen.next(stopRefreshAction).value).toEqual(cancel(mockLoaderTask));
96 | });
97 | });
98 |
99 | describe('on LOAD action', () => {
100 | beforeEach(() => {
101 | gen = dataLoaderFlow(loadAction);
102 | });
103 |
104 | test('should call fetchData if autoRefresh is not running', () => {
105 | expect(gen.next().value).toEqual(call(fetchData, loadAction));
106 | });
107 |
108 | test('should not call fetchData if autoRefresh is running', () => {
109 | const startGen = dataLoaderFlow(startRefreshAction);
110 | startGen.next(startRefreshAction);
111 | startGen.next(startRefreshAction);
112 |
113 | expect(gen.next().value).not.toEqual(call(fetchData, loadAction));
114 | });
115 | });
116 | });
117 |
--------------------------------------------------------------------------------
/src/selectors.js:
--------------------------------------------------------------------------------
1 | const reducerMountPoint = 'reduxAutoloader';
2 |
3 | export const getLoaderState = state => state[reducerMountPoint];
4 |
5 | export const isInitialized = (state, loaderName) =>
6 | !!getLoaderState(state)[loaderName];
7 |
8 | export const isLoading = (state, loaderName) =>
9 | getLoaderState(state)[loaderName].loading;
10 |
11 | export const isRefreshing = (state, loaderName) =>
12 | getLoaderState(state)[loaderName].refreshing;
13 |
14 | export const getData = (state, loaderName) =>
15 | getLoaderState(state)[loaderName].data;
16 |
17 | export const getDataReceivedAt = (state, loaderName) =>
18 | getLoaderState(state)[loaderName].dataReceivedAt;
19 |
20 | export const getError = (state, loaderName) =>
21 | getLoaderState(state)[loaderName].error;
22 |
23 | export const getErrorReceivedAt = (state, loaderName) =>
24 | getLoaderState(state)[loaderName].errorReceivedAt;
25 |
26 | export const getUpdatedAt = (state, loaderName) =>
27 | getLoaderState(state)[loaderName].updatedAt;
28 |
29 | export const createMemoizedGetData = () => {
30 | let memData;
31 | let memDataReceivedAt;
32 |
33 | return (state, loaderName) => {
34 | const data = getData(state, loaderName);
35 | const dataReceivedAt = getDataReceivedAt(state, loaderName);
36 |
37 | if (memDataReceivedAt === dataReceivedAt) {
38 | return memData;
39 | }
40 |
41 | memData = data;
42 | memDataReceivedAt = dataReceivedAt;
43 |
44 | return data;
45 | };
46 | };
47 |
--------------------------------------------------------------------------------
/src/utils.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/prefer-default-export */
2 | export function assert(condition, message = 'Assertion failed') {
3 | if (!condition) {
4 | if (typeof Error !== 'undefined') {
5 | throw new Error(message);
6 | }
7 |
8 | throw message; // fallback if Error not supported
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
3 |
4 | module.exports = {
5 | devtool: 'source-map',
6 | entry: './src/index.js',
7 | output: {
8 | publicPath: 'lib/',
9 | path: path.resolve(__dirname, 'lib'),
10 | filename: 'redux-autoloader.js',
11 | sourceMapFilename: 'redux-autoloader.js.map',
12 | library: 'redux-autoloader',
13 | libraryTarget: 'commonjs2',
14 | },
15 | externals: {
16 | react: 'react',
17 | 'react-redux': 'react-redux',
18 | 'redux-saga': 'redux-saga',
19 | redux: 'redux',
20 | 'redux-saga/effects': 'redux-saga/effects',
21 | },
22 | module: {
23 | rules: [
24 | {
25 | enforce: 'pre',
26 | test: /\.js$/,
27 | loader: 'eslint-loader',
28 | exclude: /node_modules/,
29 | },
30 | {
31 | test: /\.js$/,
32 | loader: 'babel-loader',
33 | query: {
34 | plugins: ['@babel/transform-runtime'],
35 | },
36 | exclude: /node_modules/,
37 | },
38 | ],
39 | },
40 | plugins: [
41 | ...(process.env.ANALYZE_BUNDLE === 'true'
42 | ? [new BundleAnalyzerPlugin()]
43 | : []),
44 | ],
45 | };
46 |
--------------------------------------------------------------------------------
/webpack.demo.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const webpack = require('webpack');
3 |
4 | module.exports = {
5 | devtool: process.env !== 'PRODUCTION' ? '#cheap-module-source-map' : false,
6 | entry: {
7 | demo: ['@babel/polyfill', './demo/index.js'],
8 | },
9 | resolve: {
10 | alias: {
11 | 'redux-autoloader': './src/index',
12 | },
13 | },
14 | output: {
15 | filename: '[name].js',
16 | publicPath: '/',
17 | path: path.resolve(__dirname, 'demo'),
18 | },
19 | module: {
20 | rules: [
21 | {
22 | enforce: 'pre',
23 | test: /\.js$/,
24 | loader: 'eslint-loader',
25 | exclude: /node_modules/,
26 | },
27 | {
28 | test: /\.js$/,
29 | loader: 'babel-loader',
30 | exclude: /node_modules/,
31 | },
32 | ],
33 | },
34 | plugins: [
35 | new webpack.DefinePlugin({
36 | 'process.env': {
37 | REDUX_AUTOLOADER_DEBUG: JSON.stringify(
38 | process.env.REDUX_AUTOLOADER_DEBUG
39 | ),
40 | },
41 | }),
42 | ],
43 | };
44 |
--------------------------------------------------------------------------------