├── .babelrc ├── .editorconfig ├── .gitignore ├── .npmignore ├── .travis.yml ├── README.md ├── package.json ├── src └── index.js └── test └── index.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "stage": 0, 3 | "loose": "all" 4 | } 5 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | examples 3 | test 4 | .babelrc 5 | .npmignore 6 | .travis.yml 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.12" 4 | - "iojs" 5 | script: "npm run-script test-travis" 6 | # Send coverage data to Coveralls 7 | after_script: "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js" 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | redux-async 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 | [RSA](https://github.com/kolodny/redux-standard-action)-compliant actions which resolve when any prop is a promise [middleware](https://github.com/gaearon/redux/blob/master/docs/middleware.md) for Redux. 10 | 11 | 12 | ## Install 13 | 14 | ```js 15 | npm install --save redux-async 16 | ``` 17 | 18 | ## Adding as middleware 19 | 20 | ```js 21 | import asyncMiddleware from 'redux-async'; 22 | let createStoreWithMiddleware = applyMiddleware( 23 | asyncMiddleware, 24 | )(createStore); 25 | ``` 26 | 27 | ## Usage 28 | 29 | ```js 30 | // action-creators.js 31 | export const loadUsersForAdmin = adminId => { 32 | return { 33 | types: [GET_USERS_REQUEST, GET_USERS_SUCCESS, GET_USERS_FAILURE], 34 | payload: { 35 | users: api.getUsersForAdmin(adminId).then(response => response.data.users), 36 | adminId 37 | } 38 | }; 39 | } 40 | 41 | // reducers.js 42 | import { createReducer } from 'redux-create-reducer'; 43 | import { GET_USERS_REQUEST, GET_USERS_SUCCESS, GET_USERS_FAILURE } from '../constants/actions'; 44 | 45 | const initialState = {}; 46 | 47 | export default createReducer(initialState, { 48 | [GET_USERS_REQUEST](state, action) { 49 | const { adminId } = action.payload; 50 | 51 | return { 52 | isFetching: true, 53 | adminId // we always have access to all non promise properties 54 | }; 55 | }, 56 | [GET_USERS_SUCCESS](state, action) { 57 | const { adminId, users } = action.payload; 58 | 59 | return { 60 | isFetching: false, 61 | users, // all promise properties resolved 62 | adminId // we always have access to all non promise properties - same as above 63 | }; 64 | }, 65 | [GET_USERS_FAILURE](state, action) { 66 | // assert(action.error === true && action.payload instanceof Error); 67 | 68 | // when a property gets rejected then the non promise properties go in the meta object 69 | // assert(action.meta.adminId); 70 | 71 | return {errorMessage: action.payload.message}; // from Error.prototype.message 72 | }, 73 | }); 74 | 75 | 76 | // smart-container.js 77 | // ... snipped to the middle of the render function 78 |
79 | { 80 | !users ? 81 | : 82 | (isFetching) ? (isFetching for {adminId}...) : (
{JSON.stringify(users, null, 2)}
) 83 | } 84 | { errorMessage &&
errorMessage
} 85 |
86 | ``` 87 | 88 | 89 | [npm-image]: https://img.shields.io/npm/v/redux-async.svg?style=flat-square 90 | [npm-url]: https://npmjs.org/package/redux-async 91 | [travis-image]: https://img.shields.io/travis/symbiont-io/redux-async.svg?style=flat-square 92 | [travis-url]: https://travis-ci.org/symbiont-io/redux-async 93 | [coveralls-image]: https://img.shields.io/coveralls/kolodny/redux-async.svg?style=flat-square 94 | [coveralls-url]: https://coveralls.io/r/kolodny/redux-async 95 | [downloads-image]: http://img.shields.io/npm/dm/redux-async.svg?style=flat-square 96 | [downloads-url]: https://npmjs.org/package/redux-async 97 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-async", 3 | "version": "2.0.1", 4 | "description": "dispatch async actions in redux", 5 | "main": "./lib/index.js", 6 | "scripts": { 7 | "build": "babel src --out-dir lib", 8 | "prepublish": "npm run test && npm run build", 9 | "test-cov": "node ./node_modules/istanbul/lib/cli.js cover node_modules/mocha/bin/_mocha 'test/**/*.js' -- --reporter dot --require babel/register", 10 | "test-travis": "node ./node_modules/istanbul/lib/cli.js cover ./node_modules/mocha/bin/_mocha 'test/**/*.js' -- -R spec --require babel/register", 11 | "test": "mocha --compilers js:babel/register --recursive" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/kolodny/redux-async.git" 16 | }, 17 | "keywords": [ 18 | "redux", 19 | "react" 20 | ], 21 | "author": "Moshe Kolodny", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/kolodny/redux-async/issues" 25 | }, 26 | "homepage": "https://github.com/kolodny/redux-async#readme", 27 | "devDependencies": { 28 | "babel": "^5.8.23", 29 | "babel-core": "^5.8.22", 30 | "coveralls": "^2.11.4", 31 | "expect": "^1.9.0", 32 | "istanbul": "^0.3.19", 33 | "mocha": "^2.3.1", 34 | "redux": "^2.0.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const isPromise = obj => obj && typeof obj.then === 'function'; 2 | const hasPromiseProps = obj => Object.keys(obj).some(key => isPromise(obj[key])); 3 | 4 | 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 | 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 | 24 | export default function promisePropsMiddleware() { 25 | return next => action => { 26 | const { types, payload } = action; 27 | if (!types || !hasPromiseProps(action.payload)) { 28 | return next(action); 29 | } 30 | 31 | const nonPromiseProperties = getNonPromiseProperties(payload); 32 | 33 | const [PENDING, RESOLVED, REJECTED] = types; 34 | 35 | const pendingAction = { type: PENDING, payload: nonPromiseProperties }; 36 | const successAction = { type: RESOLVED }; 37 | const failureAction = { type: REJECTED, error: true, meta: nonPromiseProperties }; 38 | if (action.meta) { 39 | [pendingAction, successAction, failureAction].forEach(nextAction => { 40 | nextAction.meta = { ...nextAction.meta, ...action.meta } 41 | }); 42 | } 43 | 44 | next(pendingAction); 45 | return resolveProps(payload).then( 46 | results => next({ ...successAction, payload: results }), 47 | error => next({ ...failureAction, payload: error }) 48 | ); 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | import { createStore, applyMiddleware } from 'redux'; 3 | 4 | import middleware from '../src'; 5 | 6 | const getNewStore = (saveAction) => { 7 | const reducer = function(state, action) { 8 | if (saveAction) saveAction.action = action; 9 | return action; 10 | } 11 | const store = applyMiddleware(middleware)(createStore)(reducer); 12 | return store; 13 | }; 14 | 15 | 16 | describe('redux-async', () => { 17 | 18 | it('handles a normal case', (done) => { 19 | const randomData = {randomData: Math.random()}; 20 | const store = getNewStore(); 21 | store.subscribe(() => { 22 | expect(store.getState()).toEqual({type: 'SOMETHING_RESOLVED', payload: randomData}); 23 | done(); 24 | }); 25 | store.dispatch({type: 'SOMETHING_RESOLVED', payload: randomData}); 26 | }); 27 | 28 | it('handles a resolved promise case', (done) => { 29 | const store = getNewStore(); 30 | const thingsToHappen = [ 31 | { type: 'SOMETHING_PENDING', payload: { rest: 'ing'} }, 32 | { type: 'SOMETHING_RESOLVED', payload: {isOk: true, rest: 'ing'} } 33 | ]; 34 | store.subscribe(() => { 35 | const expectedCurrentState = thingsToHappen.shift(); 36 | expect(store.getState()).toEqual(expectedCurrentState); 37 | if (!thingsToHappen.length) done(); 38 | }); 39 | store.dispatch({ 40 | types: ['SOMETHING_PENDING', 'SOMETHING_RESOLVED', 'SOMETHING_REJECTED'], 41 | payload: { 42 | isOk: Promise.resolve(true), 43 | rest: 'ing' 44 | } 45 | }); 46 | }); 47 | 48 | it("doesn't overwrite the meta property", () => { 49 | const saveAction = {}; 50 | const store = getNewStore(saveAction); 51 | store.dispatch({ 52 | types: ['SOMETHING_PENDING', 'SOMETHING_RESOLVED', 'SOMETHING_REJECTED'], 53 | meta: { so: 'meta' }, 54 | payload: { 55 | isOk: Promise.resolve(true), 56 | rest: 'ing' 57 | } 58 | }); 59 | expect(saveAction.action.meta).toEqual({so: 'meta'}); 60 | }); 61 | 62 | it('handles a rejected promise case', (done) => { 63 | const store = getNewStore(); 64 | const thingsToHappen = [ { type: 'SOMETHING_PENDING', payload: { rest: 'ing'} }]; 65 | store.subscribe(() => { 66 | if (thingsToHappen.length) { 67 | const expectedCurrentState = thingsToHappen.shift(); 68 | expect(store.getState()).toEqual(expectedCurrentState); 69 | } else { 70 | const state = store.getState(); 71 | expect(state.type).toEqual('SOMETHING_REJECTED'); 72 | expect(state.error).toBeTruthy(); 73 | expect(state.meta).toEqual({rest: 'ing'}); 74 | expect(state.payload).toBeAn(Error); 75 | expect(state.payload.message).toEqual('something went wrong'); 76 | done(); 77 | } 78 | }); 79 | store.dispatch({ 80 | types: ['SOMETHING_PENDING', 'SOMETHING_RESOLVED', 'SOMETHING_REJECTED'], 81 | payload: { 82 | isOk: Promise.reject(new Error('something went wrong')), 83 | rest: 'ing', 84 | } 85 | }); 86 | }); 87 | 88 | it('handles resolved and rejected promises case in the same payload', (done) => { 89 | const store = getNewStore(); 90 | const thingsToHappen = [ { type: 'SOMETHING_PENDING', payload: { rest: 'ing'} }]; 91 | store.subscribe(() => { 92 | if (thingsToHappen.length) { 93 | const expectedCurrentState = thingsToHappen.shift(); 94 | expect(store.getState()).toEqual(expectedCurrentState); 95 | } else { 96 | const state = store.getState(); 97 | expect(state.type).toEqual('SOMETHING_REJECTED'); 98 | expect(state.error).toBeTruthy(); 99 | expect(state.meta).toEqual({rest: 'ing'}); 100 | expect(state.payload).toBeAn(Error); 101 | expect(state.payload.message).toEqual('something went wrong'); 102 | done(); 103 | } 104 | }); 105 | store.dispatch({ 106 | types: ['SOMETHING_PENDING', 'SOMETHING_RESOLVED', 'SOMETHING_REJECTED'], 107 | payload: { 108 | isOk: Promise.reject(new Error('something went wrong')), 109 | isOk2: Promise.resolve(33), 110 | rest: 'ing', 111 | } 112 | }); 113 | }); 114 | 115 | }); 116 | --------------------------------------------------------------------------------