├── .babelrc
├── .editorconfig
├── .gitignore
├── .npmignore
├── .travis.yml
├── README.md
├── package.json
├── src
├── connect.js
├── constants.js
├── index.js
├── middleware.js
├── reducer.js
└── utils.js
└── test
├── redux-e2e.js
├── test-redux
├── code-to-run.js
├── project
│ └── index.js
└── run-e2e-test.js
└── units.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["es2015", "stage-0"]
3 | }
4 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_style = space
6 | indent_size = 2
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
11 | [*.md]
12 | trim_trailing_whitespace = false
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | lib/
2 | node_modules/
3 | coverage/
4 | .nyc_output/
5 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | src
2 | examples
3 | test.js
4 | .babelrc
5 | .npmignore
6 | .travis.yml
7 | oldtest.js
8 | loader.js
9 | .nyc_output/
10 | test/
11 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - "iojs"
4 | - "4"
5 | - "5"
6 | script: "npm run-script test-travis"
7 | # Send coverage data to Coveralls
8 | after_script: "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js"
9 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | redux-await
2 | =============
3 |
4 | [![NPM version][npm-image]][npm-url]
5 | [![Build status][travis-image]][travis-url]
6 | [![Test coverage][coveralls-image]][coveralls-url]
7 | [![Downloads][downloads-image]][downloads-url]
8 |
9 | Manage async redux actions sanely
10 |
11 | # Breaking Changes!!
12 | `redux-await` now takes control of a branch of your state/reducer tree similar to `redux-form`, and also like `redux-form` you need to use this module's version of `connect` and not `react-redux`'s
13 |
14 |
15 | ## Install
16 |
17 | ```js
18 | npm install --save redux-await
19 | ```
20 |
21 | ## Usage
22 |
23 | This module exposes a middleware, reducer, and connector to take care of async state in a redux
24 | app. You'll need to:
25 |
26 | 1. Apply the middleware:
27 |
28 | ```js
29 | import { middleware as awaitMiddleware } from 'redux-await';
30 | let createStoreWithMiddleware = applyMiddleware(
31 | awaitMiddleware
32 | )(createStore);
33 | ```
34 |
35 | 2. Install the reducer into the `await` path of your `combineReducers`
36 |
37 | ```js
38 | import reducers from './reducers';
39 |
40 | // old code
41 | // const store = applyMiddleware(thunk)(createStore)(reducers);
42 |
43 | // new code
44 | import { reducer as awaitReducer } from 'redux-await';
45 | const store = applyMiddleware(thunk, awaitMiddleware)(createStore)({
46 | ...reducers,
47 | await: awaitReducer,
48 | });
49 | ```
50 |
51 | 3. Use the `connect` function from this module and not `react-redux`'s
52 |
53 | ```js
54 | // old code
55 | // import { connect } from 'react-redux';
56 |
57 | // new code
58 | import { connect } from 'redux-await';
59 |
60 | class FooPage extends Component {
61 | render() { /* ... */ }
62 | }
63 |
64 | export default connect(state => state.foo)(FooPage)
65 |
66 | ```
67 |
68 |
69 | Now your action payloads can contain promises, you just need to add `AWAIT_MARKER` to the
70 | action like this:
71 |
72 | ```js
73 | // old code
74 | //export const getTodos = () => ({
75 | // type: GET_TODOS,
76 | // payload: {
77 | // loadedTodos: localStorage.todos,
78 | // },
79 | //});
80 | //export const addTodo = todo => ({
81 | // type: ADD_TODO,
82 | // payload: {
83 | // savedTodo: todo,
84 | // },
85 | //});
86 |
87 | // new code
88 | import { AWAIT_MARKER } from 'redux-await';
89 | export const getTodos = () => ({
90 | type: GET_TODOS,
91 | AWAIT_MARKER,
92 | payload: {
93 | loadedTodos: api.getTodos(), // returns promise
94 | },
95 | });
96 | export const addTodo = todo => ({
97 | type: ADD_TODO,
98 | AWAIT_MARKER,
99 | payload: {
100 | savedTodo: api.saveTodo(todo), // returns promise
101 | },
102 | });
103 | ```
104 |
105 | Now your containers barely need to change:
106 |
107 | ```js
108 | class Container extends Component {
109 | render() {
110 | const { todos, statuses, errors } = this.props;
111 |
112 | // old code
113 | //return
114 | //
115 | //
;
116 |
117 | // new code
118 | return
119 | { statuses.loadedTodos === 'pending' &&
Loading...
}
120 | { statuses.loadedTodos === 'success' &&
}
121 | { statuses.loadedTodos.status === 'failure' &&
Oops: {errors.loadedTodos.message}
}
122 | { statuses.savedTodo === 'pending' &&
Saving new savedTodo
}
123 | { statuses.savedTodo === 'failure' &&
There was an error saving
}
124 |
;
125 | }
126 | }
127 |
128 | //old code
129 | // import { connect } from 'react-redux';
130 |
131 | // new code
132 | import { connect } from 'redux-await'; // it just spreads state.await on props
133 |
134 | export default connect(state => state.todos)(Container)
135 | ```
136 |
137 | # Why
138 |
139 | Redux is mostly concerned about how to manage state in a synchronous setting. Async apps create
140 | challenges like keeping track of the async status and dealing with async errors.
141 | While it is possible to build an app this way using
142 | [redux-thunk](https://github.com/gaearon/redux-thunk)
143 | and/or
144 | [redux-promise](https://github.com/acdlite/redux-promise)
145 | it tends to bloat the app and it makes unit testing needlessly verbose
146 |
147 | `redux-await` tries to solve all of these problems by keeping track of async payloads by means
148 | of a middleware and a reducer keeping track of payload properties statuses. Let's walk
149 | through the development of a TODO app (App 1) that starts without any async and then needs to
150 | start converting action from sync to async. We'll first try only using `redux-thunk` to solve
151 | this (App 2), and then see how to solve this with `redux-await` (App 3)
152 |
153 | For the first version of the app we're going to store the todos in localStorage. Here's a simple way we would do it:
154 |
155 | ## [App1 demo](http://kolodny.github.io/redux-await/app1/)
156 | ### App 1
157 | ```js
158 | import React, { Component } from 'react';
159 | import ReactDOM from 'react-dom';
160 | import { Provider, connect } from 'react-redux';
161 | import { applyMiddleware, createStore, combineReducers } from 'redux';
162 | import thunk from 'redux-thunk';
163 | import createLogger from 'redux-logger';
164 |
165 | const GET_TODOS = 'GET_TODOS';
166 | const ADD_TODO = 'ADD_TODO';
167 | const SAVE_APP = 'SAVE_APP';
168 | const actions = {
169 | getTodos() {
170 | const todos = JSON.parse(localStorage.todos || '[]');
171 | return { type: GET_TODOS, payload: { todos } };
172 | },
173 | addTodo(todo) {
174 | return { type: ADD_TODO, payload: { todo } };
175 | },
176 | saveApp() {
177 | return (dispatch, getState) => {
178 | localStorage.todos = JSON.stringify(getState().todos.todos);
179 | dispatch({ type: SAVE_APP });
180 | }
181 | },
182 | };
183 | const initialState = { isAppSynced: false, todos: [] };
184 | const todosReducer = (state = initialState, action = {}) => {
185 | if (action.type === GET_TODOS) {
186 | return { ...state, isAppSynced: true, todos: action.payload.todos };
187 | }
188 | if (action.type === ADD_TODO) {
189 | return { ...state, isAppSynced: false, todos: state.todos.concat(action.payload.todo) };
190 | }
191 | if (action.type === SAVE_APP) {
192 | return { ...state, isAppSynced: true };
193 | }
194 | return state;
195 | };
196 | const reducer = combineReducers({
197 | todos: todosReducer,
198 | })
199 | const store = applyMiddleware(thunk, createLogger())(createStore)(reducer);
200 |
201 | class App extends Component {
202 | componentDidMount() {
203 | this.props.dispatch(actions.getTodos());
204 | }
205 | render() {
206 | const { dispatch, todos, isAppSynced } = this.props;
207 | const { input } = this.refs;
208 | return
209 | {isAppSynced && 'app is synced up'}
210 |
{todos.map(todo => - {todo}
)}
211 |
dispatch(actions.addTodo(input.value))} />
212 |
213 |
214 |
{JSON.stringify(store.getState(), null, 2)}
215 |
;
216 | }
217 | }
218 | const ConnectedApp = connect(state => state.todos)(App);
219 |
220 | ReactDOM.render(, document.getElementById('root'));
221 | ```
222 |
223 | Looks cool (it's a POC so it's purposely minimal), but let's say you want to start using an API
224 | which is async to store the state, now your app will look something like App 2:
225 |
226 | ## [App2 demo](http://kolodny.github.io/redux-await/app2/)
227 | ### App 2
228 | ```js
229 | import React, { Component } from 'react';
230 | import ReactDOM from 'react-dom';
231 | import { Provider, connect } from 'react-redux';
232 | import { applyMiddleware, createStore, combineReducers } from 'redux';
233 | import thunk from 'redux-thunk';
234 | import createLogger from 'redux-logger';
235 |
236 | // this not an API, this is a tribute
237 | const api = {
238 | save(data) {
239 | return new Promise(resolve => {
240 | setTimeout(() => {
241 | localStorage.todos = JSON.stringify(data);
242 | resolve(true);
243 | }, 2000);
244 | });
245 | },
246 | get() {
247 | return new Promise(resolve => {
248 | setTimeout(() => {
249 | resolve(JSON.parse(localStorage.todos || '[]'));
250 | }, 1000);
251 | });
252 | }
253 | }
254 |
255 | const GET_TODOS_PENDING = 'GET_TODOS_PENDING';
256 | const GET_TODOS = 'GET_TODOS';
257 | const GET_TODOS_ERROR = 'GET_TODOS_ERROR';
258 | const ADD_TODO = 'ADD_TODO';
259 | const SAVE_APP_PENDING = 'SAVE_APP_PENDING'
260 | const SAVE_APP = 'SAVE_APP';
261 | const SAVE_APP_ERROR = 'SAVE_APP_ERROR';
262 | const actions = {
263 | getTodos() {
264 | return dispatch => {
265 | dispatch({ type: GET_TODOS_PENDING });
266 | api.get()
267 | .then(todos => dispatch({ type: GET_TODOS, payload: { todos } }))
268 | .catch(error => dispatch({ type: GET_TODOS_ERROR, payload: error, error: true }))
269 | ;
270 | ;
271 | }
272 | },
273 | addTodo(todo) {
274 | return { type: ADD_TODO, payload: { todo } };
275 | },
276 | saveApp() {
277 | return (dispatch, getState) => {
278 | dispatch({ type: SAVE_APP_PENDING });
279 | api.save(getState().todos.todos)
280 | .then(() => dispatch({ type: SAVE_APP }))
281 | .catch(error => dispatch({ type: SAVE_APP_ERROR, payload: error, error: true }))
282 | ;
283 | }
284 | },
285 | };
286 | const initialState = {
287 | isAppSynced: false,
288 | isFetching: false,
289 | fetchingError: null,
290 | isSaving: false,
291 | savingError: null,
292 | todos: [],
293 | };
294 | const todosReducer = (state = initialState, action = {}) => {
295 | if (action.type === GET_TODOS_PENDING) {
296 | return { ...state, isFetching: true, fetchingError: null };
297 | }
298 | if (action.type === GET_TODOS) {
299 | return {
300 | ...state,
301 | isAppSynced: true,
302 | isFetching: false,
303 | fetchingError: null,
304 | todos: action.payload.todos,
305 | };
306 | }
307 | if (action.type === GET_TODOS_ERROR) {
308 | return { ...state, isFetching: false, fetchingError: action.payload.message };
309 | }
310 | if (action.type === ADD_TODO) {
311 | return { ...state, isAppSynced: false, todos: state.todos.concat(action.payload.todo) };
312 | }
313 | if (action.type === SAVE_APP_PENDING) {
314 | return { ...state, isSaving: true, savingError: null };
315 | }
316 | if (action.type === SAVE_APP) {
317 | return { ...state, isAppSynced: true, isSaving: false, savingError: null };
318 | }
319 | if (action === SAVE_APP_ERROR) {
320 | return { ...state, isSaving: false, savingError: action.payload.message }
321 | }
322 | return state;
323 | };
324 | const reducer = combineReducers({
325 | todos: todosReducer,
326 | })
327 | const store = applyMiddleware(thunk, createLogger())(createStore)(reducer);
328 |
329 | class App extends Component {
330 | componentDidMount() {
331 | this.props.dispatch(actions.getTodos());
332 | }
333 | render() {
334 | const { dispatch, todos, isAppSynced, isFetching, fetchingError, isSaving, savingError } = this.props;
335 | const { input } = this.refs;
336 | return
337 | {isAppSynced && 'app is synced up'}
338 | {isFetching && 'getting todos'}
339 | {fetchingError && 'there was an error getting todos: ' + fetchingError}
340 | {isSaving && 'saving todos'}
341 | {savingError && 'there was an error saving todos: ' + savingError}
342 |
{todos.map(todo => - {todo}
)}
343 |
dispatch(actions.addTodo(input.value))} />
344 |
345 |
346 |
{JSON.stringify(store.getState(), null, 2)}
347 |
;
348 | }
349 | }
350 |
351 | const ConnectedApp = connect(state => state.todos)(App);
352 |
353 | ReactDOM.render(, document.getElementById('root'));
354 | ```
355 |
356 | As you can see there's a lot of async logic and state we don't want to have to deal with.
357 | This is 62 more LOC than the first version. Here's how you would do it in App 3 with
358 | `redux-await`:
359 |
360 | ## [App3 demo](http://kolodny.github.io/redux-await/app3/)
361 | ### App 3
362 |
363 |
364 | ```js
365 | import React, { Component } from 'react';
366 | import ReactDOM from 'react-dom';
367 | import { Provider } from 'react-redux';
368 | import { applyMiddleware, createStore, combineReducers } from 'redux';
369 | import thunk from 'redux-thunk';
370 | import createLogger from 'redux-logger';
371 | import {
372 | AWAIT_MARKER,
373 | createReducer,
374 | connect,
375 | reducer as awaitReducer,
376 | middleware as awaitMiddleware,
377 | } from 'redux-await';
378 |
379 | // this not an API, this is a tribute
380 | const api = {
381 | save(data) {
382 | return new Promise(resolve => {
383 | setTimeout(() => {
384 | localStorage.todos = JSON.stringify(data);
385 | resolve(true);
386 | }, 2000);
387 | });
388 | },
389 | get() {
390 | return new Promise(resolve => {
391 | setTimeout(() => {
392 | resolve(JSON.parse(localStorage.todos || '[]'));
393 | }, 1000);
394 | });
395 | }
396 | }
397 |
398 | const GET_TODOS = 'GET_TODOS';
399 | const ADD_TODO = 'ADD_TODO';
400 | const SAVE_APP = 'SAVE_APP';
401 | const actions = {
402 | getTodos() {
403 | return {
404 | type: GET_TODOS,
405 | AWAIT_MARKER,
406 | payload: {
407 | todos: api.get(),
408 | },
409 | };
410 | },
411 | addTodo(todo) {
412 | return { type: ADD_TODO, payload: { todo } };
413 | },
414 | saveApp() {
415 | return (dispatch, getState) => {
416 | dispatch({
417 | type: SAVE_APP,
418 | AWAIT_MARKER,
419 | payload: {
420 | save: api.save(getState().todos.todos),
421 | },
422 | });
423 | }
424 | },
425 | };
426 | const initialState = { isAppSynced: false, todos: [] };
427 | const todosReducer = (state = initialState, action = {}) => {
428 | if (action.type === GET_TODOS) {
429 | return { ...state, isAppSynced: true, todos: action.payload.todos };
430 | }
431 | if (action.type === ADD_TODO) {
432 | return { ...state, isAppSynced: false, todos: state.todos.concat(action.payload.todo) };
433 | }
434 | if (action.type === SAVE_APP) {
435 | return { ...state, isAppSynced: true };
436 | }
437 | return state;
438 | };
439 | const reducer = combineReducers({
440 | todos: todosReducer,
441 | await: awaitReducer,
442 | })
443 |
444 | const store = applyMiddleware(thunk, awaitMiddleware, createLogger())(createStore)(reducer);
445 |
446 | class App extends Component {
447 | componentDidMount() {
448 | this.props.dispatch(actions.getTodos());
449 | }
450 | render() {
451 | const { dispatch, todos, isAppSynced, statuses, errors } = this.props;
452 | const { input } = this.refs;
453 | return
454 | {isAppSynced && 'app is synced up'}
455 | {statuses.todos === 'pending' && 'getting todos'}
456 | {statuses.todos === 'failure' && 'there was an error getting todos: ' + errors.todos.message}
457 | {statuses.save === 'pending' && 'saving todos'}
458 | {errors.save && 'there was an error saving todos: ' + errors.save.message}
459 |
{todos.map(todo => - {todo}
)}
460 |
dispatch(actions.addTodo(input.value))} />
461 |
462 |
463 |
{JSON.stringify(store.getState(), null, 2)}
464 |
;
465 | }
466 | }
467 |
468 |
469 | const ConnectedApp = connect(state => state.todos)(App);
470 |
471 | ReactDOM.render(, document.getElementById('root'));
472 | ```
473 |
474 | This version is very easy to reason about, in fact you can completely ignore the fact that the app is async at all. The `todosReducer` didn't need to have a single line changed!
475 | Note that this is 107 LOC compared to app2's 125 LOC
476 |
477 | ## Some pitfalls to watch out for
478 |
479 | You must either use this modules `connect` or manually spread the `await` part of the tree over
480 | `mapStateToProps`, you can also choose to name it something other than `await` and spread that
481 | yourself too.
482 |
483 | `redux-await` will name the `statuses` and `errors` prop the same as the payload prop so try to be
484 | as descriptive as possible when naming payload props since any payload props collision will
485 | overwrite the `statuses`/`errors` value. For a CRUD app don't always name it something like
486 | `records` because when you're loading `users.records` the app will also think you're loading
487 | `todos.records`
488 |
489 | ## How it works:
490 |
491 | The middleware checks to see if the `AWAIT_MARKER` was set on the action
492 | and if it was then dispatches three events with a `[AWAIT_META_CONTAINER]`
493 | property on the meta property of the action.
494 | The reducer listens for actions with a meta of `[AWAIT_META_CONTAINER]` and
495 | when found will set the `await` property of the state accordingly.
496 |
497 |
498 | [npm-image]: https://img.shields.io/npm/v/redux-await.svg?style=flat-square
499 | [npm-url]: https://npmjs.org/package/redux-await
500 | [travis-image]: https://img.shields.io/travis/kolodny/redux-await.svg?style=flat-square
501 | [travis-url]: https://travis-ci.org/kolodny/redux-await
502 | [coveralls-image]: https://img.shields.io/coveralls/kolodny/redux-await.svg?style=flat-square
503 | [coveralls-url]: https://coveralls.io/r/kolodny/redux-await
504 | [downloads-image]: http://img.shields.io/npm/dm/redux-await.svg?style=flat-square
505 | [downloads-url]: https://npmjs.org/package/redux-await
506 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "redux-await",
3 | "version": "5.0.1",
4 | "description": "Manage async redux actions sanely",
5 | "main": "./lib/index.js",
6 | "scripts": {
7 | "build": "babel src --out-dir lib",
8 | "prepublish": "npm run test && npm run test-react-e2e && npm run build",
9 | "test-cov": "nyc --reporter=lcov --reporter=text-lcov npm test && nyc report",
10 | "test-travis": "nyc npm test && nyc report --reporter=text-lcov | coveralls",
11 | "test-react-e2e": "npm run build && node test/test-redux/run-e2e-test.js",
12 | "test": "mocha --compilers js:babel-register 'test/*.js'"
13 | },
14 | "author": "Moshe Kolodny",
15 | "license": "MIT",
16 | "devDependencies": {
17 | "babel-cli": "^6.3.17",
18 | "babel-preset-es2015": "^6.3.13",
19 | "babel-preset-stage-0": "^6.3.13",
20 | "babel-register": "^6.3.13",
21 | "coveralls": "^2.11.4",
22 | "expect": "^1.13.0",
23 | "fs-extra": "^0.26.2",
24 | "istanbul": "^0.4.1",
25 | "jsdom": "^5.6.1",
26 | "mkdirp": "^0.5.1",
27 | "mocha": "^2.3.4",
28 | "nyc": "git://github.com/bcoe/nyc.git#master",
29 | "react": "^0.14.3",
30 | "react-dom": "^0.14.3",
31 | "react-redux": "^4.0.0",
32 | "redux": "^3.0.4",
33 | "redux-thunk": "^1.0.0",
34 | "rimraf": "^2.4.4"
35 | },
36 | "dependencies": {},
37 | "peerDependencies": {
38 | "react-redux": "*"
39 | },
40 | "repository": {
41 | "type": "git",
42 | "url": "git+https://github.com/kolodny/redux-await.git"
43 | },
44 | "keywords": [
45 | "redux",
46 | "async",
47 | "await"
48 | ],
49 | "bugs": {
50 | "url": "https://github.com/kolodny/redux-await/issues"
51 | },
52 | "homepage": "https://github.com/kolodny/redux-await#readme"
53 | }
54 |
--------------------------------------------------------------------------------
/src/connect.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 |
3 | // can't really test this higher order function without overwriting the require cache
4 | /* istanbul ignore next */
5 | export default (mapStateToProps, ...args) => {
6 | return connect((state, ownProps) => {
7 | const props = mapStateToProps(state, ownProps);
8 | const { statuses, errors } = state.await;
9 | return { ...props, statuses, errors };
10 | }, ...args);
11 | }
12 |
--------------------------------------------------------------------------------
/src/constants.js:
--------------------------------------------------------------------------------
1 | export const AWAIT_MARKER = '@@redux-await/AWAIT_MARKER';
2 | export const AWAIT_META_CONTAINER = '@@redux-await/AWAIT_META_CONTAINER';
3 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | export { AWAIT_MARKER, AWAIT_META_CONTAINER } from './constants';
2 | export { middleware, getPendingActionType, getFailedActionType } from './middleware';
3 | export reducer from './reducer';
4 | export connect from './connect';
5 |
--------------------------------------------------------------------------------
/src/middleware.js:
--------------------------------------------------------------------------------
1 | import { resolveProps, getNonPromiseProperties, getPromiseKeys, objectWithoutProperties } from './utils';
2 |
3 | import { AWAIT_MARKER, AWAIT_META_CONTAINER } from './constants';
4 |
5 | export const getPendingActionType = type => `${AWAIT_MARKER}/pending/${type}`;
6 | export const getFailedActionType = type => `${AWAIT_MARKER}/fail/${type}`;
7 |
8 | export const middleware = ({ dispatch }) => next => action => {
9 | const { payload, type, meta } = action;
10 |
11 | if (payload && action.AWAIT_MARKER === AWAIT_MARKER) {
12 |
13 | const promiseKeys = getPromiseKeys(payload);
14 | const scalarValues = getNonPromiseProperties(payload);
15 | const pendingMeta = { [AWAIT_META_CONTAINER]: { promiseKeys, scalarValues, status: 'pending' } };
16 | const successMeta = { [AWAIT_META_CONTAINER]: { promiseKeys, scalarValues, status: 'success' } };
17 | const failureMeta = { [AWAIT_META_CONTAINER]: { promiseKeys, scalarValues, status: 'failure' } };
18 |
19 | const newAction = objectWithoutProperties(action, ['type', 'payload', 'AWAIT_MARKER']);
20 |
21 | dispatch({
22 | ...newAction,
23 | type: getPendingActionType(type),
24 | meta: { ...meta, ...pendingMeta, type },
25 | });
26 |
27 | const successCallback = payload => {
28 | dispatch({
29 | ...newAction,
30 | type,
31 | payload,
32 | meta: { ...meta, ...successMeta },
33 | });
34 | };
35 |
36 | const failureCallback = error => {
37 | dispatch({
38 | ...newAction,
39 | type: getFailedActionType(type),
40 | payload: error,
41 | meta: { ...meta, ...failureMeta, type },
42 | });
43 | }
44 |
45 | resolveProps(payload).then(successCallback, failureCallback);
46 |
47 | } else {
48 | next(action);
49 | }
50 | };
51 |
--------------------------------------------------------------------------------
/src/reducer.js:
--------------------------------------------------------------------------------
1 | import { AWAIT_META_CONTAINER, AWAIT_INFO_CONTAINER } from './constants';
2 | import { getPromiseKeys } from './utils';
3 |
4 | const initialState = { statuses: {}, errors: {} };
5 | export default (state = initialState, action = {}) => {
6 | if (action.meta && action.meta[AWAIT_META_CONTAINER]) {
7 | const awaitMeta = action.meta[AWAIT_META_CONTAINER];
8 | const { status } = awaitMeta;
9 |
10 | const statuses = { ...state.statuses };
11 | const errors = { ...state.errors };
12 | awaitMeta.promiseKeys.forEach(prop => {
13 | statuses[prop] = status;
14 | if (status === 'failure') {
15 | errors[prop] = action.payload;
16 | } else {
17 | // only unset errors prop if previously set
18 | if (errors[prop]) {
19 | errors[prop] = null;
20 | }
21 | }
22 | });
23 | return { statuses, errors };
24 | }
25 | return state;
26 | };
27 |
--------------------------------------------------------------------------------
/src/utils.js:
--------------------------------------------------------------------------------
1 | export const isPromise = obj => obj && typeof obj.then === 'function';
2 | export const getPromiseKeys = obj => Object.keys(obj).filter(key => isPromise(obj[key]));
3 |
4 | export const resolveProps = obj => {
5 | const props = Object.keys(obj);
6 | const values = props.map(prop => obj[prop]);
7 |
8 | return Promise.all(values).then(resolvedArray => {
9 | return props.reduce((acc, prop, index) => {
10 | acc[prop] = resolvedArray[index];
11 | return acc;
12 | }, {});
13 | });
14 | };
15 |
16 | export const getNonPromiseProperties = obj => {
17 | return Object.keys(obj).filter(key => !isPromise(obj[key])).reduce((acc, key) => {
18 | acc[key] = obj[key];
19 | return acc;
20 | }, {});
21 | };
22 |
23 | // this is taken from babel's source, no need to test
24 | /* istanbul ignore next */
25 | export const objectWithoutProperties = (obj, keys) => {
26 | var target = {};
27 | for (var i in obj) {
28 | if (keys.indexOf(i) >= 0) continue;
29 |
30 | if (!Object.prototype.hasOwnProperty.call(obj, i)) continue;
31 | target[i] = obj[i];
32 | }
33 | return target;
34 | }
35 |
--------------------------------------------------------------------------------
/test/redux-e2e.js:
--------------------------------------------------------------------------------
1 | import expect from 'expect';
2 | import { createStore, applyMiddleware, combineReducers } from 'redux';
3 | import {
4 | AWAIT_MARKER,
5 | middleware,
6 | reducer as awaitReducer,
7 | getPendingActionType,
8 | } from '../src';
9 |
10 | describe('redux-await', () => {
11 |
12 | it("works with redux", done => {
13 | const actions = [];
14 | const createStoreWithMiddleware = applyMiddleware(middleware)(createStore);
15 | const appReducer = (state = {}, action) => {
16 | actions.push(action);
17 | if (action.type === getPendingActionType('TESTING')) { return { ...state, wasPending: true } };
18 | if (action.type === 'TESTING') { return { ...state, wasTested: true, soon: action.payload.soon + '!' } }
19 | return state;
20 | };
21 | const reducers = combineReducers({
22 | app: appReducer,
23 | await: awaitReducer,
24 | })
25 |
26 | const store = createStoreWithMiddleware(reducers);
27 | const states = [];
28 | store.subscribe(() => {
29 | states.push(store.getState());
30 | if (states.length === 6) {
31 | try {
32 | expect(states[0].app).toEqual({wasPending: true});
33 | expect(states[0].await.statuses.soon).toEqual('pending');
34 | expect(states[0].await.statuses.heyo).toEqual('pending');
35 |
36 | // doesn't overwrite old statuses
37 | expect(states[1].await.statuses.heyo).toEqual('pending');
38 |
39 | expect(states[2].app).toEqual({ wasPending: true, wasTested: true, soon: 'v!' });
40 | expect(states[2].await.statuses.soon).toEqual('success');
41 | expect(states[2].await.statuses.heyo).toEqual('success');
42 |
43 | expect(states[3].await.statuses.soon).toEqual('failure');
44 | expect(states[3].await.errors.soon.message).toEqual('no!');
45 |
46 | expect(states[4].await.statuses.soon).toEqual('pending');
47 | expect(states[4].await.errors.soon).toBeFalsy();
48 |
49 | expect(states[5].await.statuses.soon).toEqual('success');
50 |
51 | // make sure we don't overwrite action.meta
52 | expect(actions[actions.length - 1].meta.so).toEqual('meta');
53 | done();
54 | } catch (e) {
55 | done(e);
56 | }
57 | }
58 | });
59 | const generateRejection = () => new Promise((_, reject) => setTimeout(() => reject(new Error('no!')), 15));
60 |
61 | // smiley face;
62 | store.dispatch({ type: 'TESTING', AWAIT_MARKER, payload: { soon: Promise.resolve('v'), heyo: Promise.resolve('heyo'), ignore: 123 } });
63 | store.dispatch({ type: 'TESTING', AWAIT_MARKER, payload: { soon: generateRejection(), ignore: 123 } });
64 | setTimeout(() => store.dispatch({ type: 'TESTING', AWAIT_MARKER, meta: {so: 'meta'}, payload: { soon: Promise.resolve('v'), heyo: Promise.resolve('heyo'), ignore: 123 } }), 20)
65 |
66 | });
67 |
68 | });
69 |
--------------------------------------------------------------------------------
/test/test-redux/code-to-run.js:
--------------------------------------------------------------------------------
1 | import jsdom from 'jsdom';
2 | global.document = jsdom.jsdom('');
3 | global.window = document.defaultView;
4 | global.navigator = {
5 | userAgent: 'node.js'
6 | };
7 |
8 | import expect from 'expect';
9 | import React, { Component } from 'react';
10 | import ReactDOM from 'react-dom';
11 | import { Provider } from 'react-redux';
12 | import { applyMiddleware, createStore, combineReducers } from 'redux';
13 | import thunk from 'redux-thunk';
14 | import {
15 | AWAIT_MARKER,
16 | AWAIT_META_CONTAINER,
17 | createReducer,
18 | getPendingActionType,
19 | getFailedActionType,
20 | connect,
21 | reducer as awaitReducer,
22 | middleware as awaitMiddleware,
23 | } from 'redux-await';
24 |
25 | describe('redux-await', () => {
26 | it('passes an intense react-redux e2e test suite ;)', done => {
27 |
28 | const savedActions = [], savedStates = [], savedProps = [];
29 |
30 | const counter = (state = 0, action = {}) => {
31 | savedActions.push(action);
32 | if (action.type === 'INC') return state + 1;
33 | return state;
34 | };
35 | const reducer = combineReducers({
36 | counter,
37 | await: awaitReducer,
38 | });
39 | const store = applyMiddleware(awaitMiddleware)(createStore)(reducer);
40 | store.subscribe(() => {
41 | savedStates.push(store.getState());
42 | if (savedActions.length > 7) {
43 | setTimeout(() => {
44 | checkThatEverythingWentOk({ savedActions, savedStates, savedProps }, done);
45 | }, 100)
46 | }
47 | });
48 |
49 | class App extends Component {
50 | render() {
51 | savedProps.push(this.props)
52 | return null;
53 | }
54 | }
55 | const ConnectedApp = connect(state => ({ counter: state.counter }) )(App);
56 |
57 | ReactDOM.render(React.createElement(Provider, { store }, React.createElement(ConnectedApp)), document.getElementById('root'));
58 | store.dispatch({ type: 'IGNORE_ME', AWAIT_MARKER, payload: { soon: Promise.resolve('v'), heyo: Promise.resolve('heyo'), ignore: 123 } });
59 | store.dispatch({ type: 'IGNORE_ME', AWAIT_MARKER, payload: { soon: new Promise((_, rej) => setTimeout(() => rej(new Error('no!')), 100)) } });
60 | store.dispatch({ type: 'INC' });
61 |
62 | });
63 | });
64 |
65 |
66 | function checkThatEverythingWentOk({ savedActions, savedStates, savedProps }, done) {
67 | try {
68 | const dispatch = savedProps[0].dispatch;
69 | savedActions.shift();
70 | savedActions.shift();
71 | savedActions.shift();
72 |
73 | expect(savedActions[0].type).toEqual(getPendingActionType('IGNORE_ME'));
74 | expect(savedActions[0].meta[AWAIT_META_CONTAINER].promiseKeys).toEqual(['soon', 'heyo']);
75 | expect(savedActions[0].meta[AWAIT_META_CONTAINER].scalarValues).toEqual({ignore: 123});
76 |
77 | expect(savedActions[1].type).toEqual(getPendingActionType('IGNORE_ME'));
78 | expect(savedActions[1].meta[AWAIT_META_CONTAINER].promiseKeys).toEqual(['soon']);
79 | expect(savedActions[1].meta[AWAIT_META_CONTAINER].scalarValues).toEqual({});
80 |
81 | expect(savedActions[2].type).toEqual('INC');
82 |
83 | expect(savedActions[3].type).toEqual('IGNORE_ME');
84 | expect(savedActions[3].payload).toEqual({ soon: "v", heyo: "heyo", ignore: 123 });
85 |
86 | expect(savedActions[4].type).toEqual(getFailedActionType('IGNORE_ME'));
87 | expect(savedActions[4].payload).toBeAn(Error);
88 |
89 | expect(savedStates[0]).toEqual({counter: 0, await: { errors: {}, statuses: {
90 | soon: 'pending', heyo: 'pending'
91 | }}});
92 |
93 | expect(savedStates[1]).toEqual(savedStates[0]);
94 | expect(savedStates[2]).toEqual({ ...savedStates[0], counter: 1 });
95 |
96 | expect(savedStates[3]).toEqual({counter: 1, await: { errors: {}, statuses: {
97 | soon: 'success', heyo: 'success'
98 | }}});
99 |
100 | expect(savedStates[4]).toEqual({counter: 1, await: { errors: { soon: new Error() }, statuses: {
101 | soon: 'failure', heyo: 'success'
102 | }}});
103 |
104 | expect(savedProps[0]).toEqual({ dispatch, counter: 0, statuses: {}, errors: {} });
105 | expect(savedProps[1]).toEqual({ dispatch, counter: 0, statuses: { soon: 'pending', heyo: 'pending' }, errors: {} });
106 | expect(savedProps[2]).toEqual({ dispatch, counter: 0, statuses: { soon: 'pending', heyo: 'pending' }, errors: {} });
107 | expect(savedProps[3]).toEqual({ dispatch, counter: 1, statuses: { soon: 'pending', heyo: 'pending' }, errors: {} });
108 | expect(savedProps[4]).toEqual({ dispatch, counter: 1, statuses: { soon: 'success', heyo: 'success' }, errors: {} });
109 | expect(savedProps[5]).toEqual({ dispatch, counter: 1, statuses: { soon: 'failure', heyo: 'success' }, errors: {
110 | soon: new Error()
111 | } });
112 |
113 | done();
114 | } catch (e) { done(e) }
115 | }
116 |
--------------------------------------------------------------------------------
/test/test-redux/project/index.js:
--------------------------------------------------------------------------------
1 | import jsdom from 'jsdom';
2 | global.document = jsdom.jsdom('');
3 | global.window = document.defaultView;
4 | global.navigator = {
5 | userAgent: 'node.js'
6 | };
7 |
8 | import expect from 'expect';
9 | import React, { Component } from 'react';
10 | import ReactDOM from 'react-dom';
11 | import { Provider } from 'react-redux';
12 | import { applyMiddleware, createStore, combineReducers } from 'redux';
13 | import thunk from 'redux-thunk';
14 | import {
15 | AWAIT_MARKER,
16 | AWAIT_META_CONTAINER,
17 | createReducer,
18 | getPendingActionType,
19 | getFailedActionType,
20 | connect,
21 | reducer as awaitReducer,
22 | middleware as awaitMiddleware,
23 | } from 'redux-await';
24 |
25 | describe('redux-await', () => {
26 | it('passes an intense react-redux e2e test suite ;)', done => {
27 |
28 | const savedActions = [], savedStates = [], savedProps = [];
29 |
30 | const counter = (state = 0, action = {}) => {
31 | savedActions.push(action);
32 | if (action.type === 'INC') return state + 1;
33 | return state;
34 | };
35 | const reducer = combineReducers({
36 | counter,
37 | await: awaitReducer,
38 | });
39 | const store = applyMiddleware(awaitMiddleware)(createStore)(reducer);
40 | store.subscribe(() => {
41 | savedStates.push(store.getState());
42 | if (savedActions.length > 7) {
43 | setTimeout(() => {
44 | checkThatEverythingWentOk({ savedActions, savedStates, savedProps }, done);
45 | }, 100)
46 | }
47 | });
48 |
49 | class App extends Component {
50 | render() {
51 | savedProps.push(this.props)
52 | return null;
53 | }
54 | }
55 | const ConnectedApp = connect(state => ({ counter: state.counter }) )(App);
56 |
57 | ReactDOM.render(React.createElement(Provider, { store }, React.createElement(ConnectedApp)), document.getElementById('root'));
58 | store.dispatch({ type: 'IGNORE_ME', AWAIT_MARKER, payload: { soon: Promise.resolve('v'), heyo: Promise.resolve('heyo'), ignore: 123 } });
59 | store.dispatch({ type: 'IGNORE_ME', AWAIT_MARKER, payload: { soon: new Promise((_, rej) => setTimeout(() => rej(new Error('no!')), 100)) } });
60 | store.dispatch({ type: 'INC' });
61 |
62 | });
63 | });
64 |
65 |
66 | function checkThatEverythingWentOk({ savedActions, savedStates, savedProps }, done) {
67 | try {
68 | const dispatch = savedProps[0].dispatch;
69 | savedActions.shift();
70 | savedActions.shift();
71 | savedActions.shift();
72 |
73 | expect(savedActions[0].type).toEqual(getPendingActionType('IGNORE_ME'));
74 | expect(savedActions[0].meta[AWAIT_META_CONTAINER].promiseKeys).toEqual(['soon', 'heyo']);
75 | expect(savedActions[0].meta[AWAIT_META_CONTAINER].scalarValues).toEqual({ignore: 123});
76 |
77 | expect(savedActions[1].type).toEqual(getPendingActionType('IGNORE_ME'));
78 | expect(savedActions[1].meta[AWAIT_META_CONTAINER].promiseKeys).toEqual(['soon']);
79 | expect(savedActions[1].meta[AWAIT_META_CONTAINER].scalarValues).toEqual({});
80 |
81 | expect(savedActions[2].type).toEqual('INC');
82 |
83 | expect(savedActions[3].type).toEqual('IGNORE_ME');
84 | expect(savedActions[3].payload).toEqual({ soon: "v", heyo: "heyo", ignore: 123 });
85 |
86 | expect(savedActions[4].type).toEqual(getFailedActionType('IGNORE_ME'));
87 | expect(savedActions[4].payload).toBeAn(Error);
88 |
89 | expect(savedStates[0]).toEqual({counter: 0, await: { errors: {}, statuses: {
90 | soon: 'pending', heyo: 'pending'
91 | }}});
92 |
93 | expect(savedStates[1]).toEqual(savedStates[0]);
94 | expect(savedStates[2]).toEqual({ ...savedStates[0], counter: 1 });
95 |
96 | expect(savedStates[3]).toEqual({counter: 1, await: { errors: {}, statuses: {
97 | soon: 'success', heyo: 'success'
98 | }}});
99 |
100 | expect(savedStates[4]).toEqual({counter: 1, await: { errors: { soon: new Error() }, statuses: {
101 | soon: 'failure', heyo: 'success'
102 | }}});
103 |
104 | expect(savedProps[0]).toEqual({ dispatch, counter: 0, statuses: {}, errors: {} });
105 | expect(savedProps[1]).toEqual({ dispatch, counter: 0, statuses: { soon: 'pending', heyo: 'pending' }, errors: {} });
106 | expect(savedProps[2]).toEqual({ dispatch, counter: 0, statuses: { soon: 'pending', heyo: 'pending' }, errors: {} });
107 | expect(savedProps[3]).toEqual({ dispatch, counter: 1, statuses: { soon: 'pending', heyo: 'pending' }, errors: {} });
108 | expect(savedProps[4]).toEqual({ dispatch, counter: 1, statuses: { soon: 'success', heyo: 'success' }, errors: {} });
109 | expect(savedProps[5]).toEqual({ dispatch, counter: 1, statuses: { soon: 'failure', heyo: 'success' }, errors: {
110 | soon: new Error()
111 | } });
112 |
113 | done();
114 | } catch (e) { done(e) }
115 | }
116 |
--------------------------------------------------------------------------------
/test/test-redux/run-e2e-test.js:
--------------------------------------------------------------------------------
1 | var path = require('path');
2 | var spawn = require('child_process').spawn;
3 | var fs = require('fs-extra');
4 | var rimraf = require('rimraf').sync;
5 | var mkdirp = require('mkdirp').sync;
6 |
7 | var codeToRunPath = path.join(__dirname, 'code-to-run.js');
8 | var projectPath = path.join(__dirname, 'project');
9 | var nodeModulesReduxAwaitPath = path.join(projectPath, 'node_modules', 'redux-await');
10 | var indexFileLocation = path.join(projectPath, 'index.js');
11 |
12 | var reduxAwaitPath = path.join(__dirname, '..', '..');
13 | var reduxAwaitLibPath = path.join(reduxAwaitPath, 'lib');
14 | var _mochaPath = path.join(reduxAwaitPath, 'node_modules', '.bin', '_mocha');
15 |
16 | rimraf(projectPath);
17 | mkdirp(nodeModulesReduxAwaitPath);
18 |
19 | fs.copySync(reduxAwaitLibPath, nodeModulesReduxAwaitPath);
20 | fs.copySync(codeToRunPath, indexFileLocation)
21 |
22 | spawn(_mochaPath, ['--require', 'babel-register', indexFileLocation], {stdio: 'inherit'}).on('exit', process.exit);
23 |
--------------------------------------------------------------------------------
/test/units.js:
--------------------------------------------------------------------------------
1 | import expect from 'expect';
2 | import {
3 | AWAIT_MARKER,
4 | AWAIT_META_CONTAINER,
5 | middleware,
6 | reducer,
7 | connect,
8 | getPendingActionType,
9 | } from '../src';
10 |
11 | describe('redux-await', () => {
12 | it('exports AWAIT_MARKER', () => {
13 | expect(AWAIT_MARKER).toBeTruthy();
14 | });
15 | it('exports AWAIT_META_CONTAINER', () => {
16 | expect(AWAIT_META_CONTAINER).toBeTruthy();
17 | });
18 | it('exports middleware', () => {
19 | expect(middleware).toBeTruthy();
20 | });
21 | it('exports reducer', () => {
22 | expect(reducer).toBeTruthy();
23 | });
24 | it('exports connect', () => {
25 | expect(connect).toBeTruthy();
26 | });
27 |
28 | describe('reducer', () => {
29 | it('handles initial actions', () => {
30 | expect( reducer(undefined, {}) ).toEqual({ statuses: {}, errors: {} });
31 | });
32 | it('handles AWAIT_META_CONTAINER pending actions', () => {
33 | const action = {
34 | meta: {
35 | [AWAIT_META_CONTAINER]: {
36 | status: 'pending',
37 | promiseKeys: ['a'],
38 | }
39 | }
40 | };
41 | expect( reducer({ statuses: {}, errors: {} }, action )).toEqual(
42 | { statuses: { a: 'pending' }, errors: {} }
43 | );
44 | });
45 |
46 | it('handles AWAIT_META_CONTAINER success actions', () => {
47 | const action = {
48 | meta: {
49 | [AWAIT_META_CONTAINER]: {
50 | status: 'success',
51 | promiseKeys: ['a'],
52 | }
53 | }
54 | };
55 | expect( reducer({ statuses: { a: 'pending' }, errors: {} }, action )).toEqual(
56 | { statuses: { a: 'success' }, errors: {} }
57 | );
58 | });
59 |
60 |
61 | it('handles AWAIT_META_CONTAINER failure actions', () => {
62 | const action = {
63 | payload: new Error('fail!'),
64 | meta: {
65 | [AWAIT_META_CONTAINER]: {
66 | status: 'failure',
67 | promiseKeys: ['a'],
68 | }
69 | }
70 | };
71 | expect( reducer({ statuses: { a: 'pending' }, errors: {} }, action )).toEqual(
72 | { statuses: { a: 'failure' }, errors: { a: new Error('fail!') } }
73 | );
74 | });
75 |
76 |
77 | });
78 |
79 |
80 | });
81 |
--------------------------------------------------------------------------------