├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .npmrc ├── .travis.yml ├── README.md ├── notes.md ├── package.json ├── src ├── api │ └── travelServiceApi.js ├── components │ ├── Dashboard.js │ └── Dashboard.spec.js ├── containers │ ├── App.js │ ├── CustomMixin.js │ ├── Panel.js │ ├── Panel2.js │ └── Panel3.js ├── favicon.ico ├── index.ejs ├── index.js ├── reducers │ ├── dashboardReducer.js │ ├── index.js │ └── userReducer.js ├── routes.js ├── sagas │ ├── apiCalls.js │ ├── index.js │ ├── loadDashBoardSequenced.spec.js │ ├── loadDashboardNonSequenced.js │ ├── loadDashboardNonSequenced.spec.js │ ├── loadDashboardNonSequencedNonBlocking.js │ ├── loadDashboardNonSequencedNonBlocking.spec.js │ ├── loadDashboardSequenced.js │ ├── loadUser.js │ └── testHelpers.js ├── store │ ├── configureStore.dev.js │ ├── configureStore.js │ └── configureStore.prod.js ├── styles │ └── styles.scss └── webpack-public-path.js ├── tools ├── build.js ├── chalkConfig.js ├── distServer.js ├── nodeVersionCheck.js ├── removeDemo.js ├── setup │ ├── setup.js │ ├── setupMessage.js │ └── setupPrompts.js ├── srcServer.js ├── startMessage.js └── testSetup.js ├── webpack.config.dev.js └── webpack.config.prod.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "react", 5 | "stage-1" 6 | ], 7 | "env": { 8 | "test" : { 9 | "plugins": ["transform-runtime"] 10 | }, 11 | "development": { 12 | "plugins": ["transform-runtime"], 13 | "presets": [ 14 | "react-hmre" 15 | ] 16 | }, 17 | "production": { 18 | "plugins": [ 19 | "transform-runtime", 20 | "transform-react-constant-elements", 21 | "transform-react-remove-prop-types" 22 | ] 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:import/errors", 5 | "plugin:import/warnings" 6 | ], 7 | "plugins": [ 8 | "react" 9 | ], 10 | "parserOptions": { 11 | "ecmaVersion": 6, 12 | "sourceType": "module", 13 | "ecmaFeatures": { 14 | "jsx": true, 15 | "experimentalObjectRestSpread": true 16 | } 17 | }, 18 | "env": { 19 | "es6": true, 20 | "browser": true, 21 | "node": true, 22 | "jquery": true, 23 | "mocha": true 24 | }, 25 | "rules": { 26 | "quotes": 0, 27 | "no-console": 1, 28 | "no-debugger": 1, 29 | "no-var": 1, 30 | "semi": [1, "always"], 31 | "no-trailing-spaces": 0, 32 | "eol-last": 0, 33 | "no-underscore-dangle": 0, 34 | "no-alert": 0, 35 | "no-constant-condition" : 0, 36 | "no-extra-boolean-cast": 0, 37 | "no-lone-blocks": 0, 38 | "jsx-quotes": 1, 39 | "react/display-name": [ 1, {"ignoreTranspilerName": false }], 40 | "react/forbid-prop-types": [1, {"forbid": ["any"]}], 41 | "react/jsx-boolean-value": 0, 42 | "react/jsx-closing-bracket-location": 0, 43 | "react/jsx-curly-spacing": 1, 44 | "react/jsx-indent-props": 0, 45 | "react/jsx-key": 1, 46 | "react/jsx-max-props-per-line": 0, 47 | "react/jsx-no-bind": 0, 48 | "react/jsx-no-duplicate-props": 1, 49 | "react/jsx-no-literals": 0, 50 | "react/jsx-no-undef": 1, 51 | "react/jsx-pascal-case": 1, 52 | "react/jsx-sort-prop-types": 0, 53 | "react/jsx-sort-props": 0, 54 | "react/jsx-uses-react": 1, 55 | "react/jsx-uses-vars": 1, 56 | "react/no-danger": 1, 57 | "react/no-did-mount-set-state": 1, 58 | "react/no-did-update-set-state": 1, 59 | "react/no-direct-mutation-state": 1, 60 | "react/no-multi-comp": 1, 61 | "react/no-set-state": 0, 62 | "react/no-unknown-property": 1, 63 | "react/prefer-es6-class": 1, 64 | "react/prop-types": 1, 65 | "react/react-in-jsx-scope": 1, 66 | "react/require-extension": 1, 67 | "react/self-closing-comp": 1, 68 | "react/sort-comp": 1, 69 | "react/wrap-multilines": 1 70 | }, 71 | "globals": { 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | #dist folder 30 | dist 31 | 32 | #Webstorm metadata 33 | .idea 34 | 35 | #VSCode metadata 36 | .vscode 37 | 38 | # Mac files 39 | .DS_Store 40 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" 4 | - "5" 5 | - "4" 6 | after_success: 7 | # Send coverage data to coveralls. / 8 | - npm run test:cover:travis 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Distributed Async calls with redux sagas [![Build Status](https://travis-ci.org/andresmijares/async-redux-saga.svg?branch=master)](https://travis-ci.org/andresmijares/async-redux-saga) 2 | 3 | A similar approach to [Flattering Promise Chains](http://solutionoptimist.com/2013/12/27/javascript-promise-chains-2/) using redux sagas. 4 | 5 | Check the final demo [here](http://async-redux-saga.surge.sh/). 6 | 7 | Check the article [here](https://medium.com/@andresmijares25/async-operations-using-redux-saga-2ba02ae077b3#.sxsygk28s) 8 | 9 | ## Installation 10 | 11 | 1. Clone the repo 12 | 2. Run `npm install` 13 | 3. Run `npm start` 14 | 4. Find way to make it better, and pull request! 15 | 16 | ## License 17 | [CC-BY](https://creativecommons.org/licenses/by/3.0/) 18 | -------------------------------------------------------------------------------- /notes.md: -------------------------------------------------------------------------------- 1 | thomas 2 | 3 | * Transform the responses before subsequent handlers (in the chain) are notified of the response. 4 | * Use the response to invoke more async requests (which could generate more promises). 5 | 6 | ## Process 7 | 8 | * The Component will fire an action ```dispatch({type: 'LOAD_DASHBOARD'})``` file: Panel.js 9 | * 10 | 11 | 12 | ## Create loadUser Saga 13 | 14 | ``` 15 | import { call, put }; 16 | import {loadUser as getUser } from './apiCalls'; 17 | 18 | export function* loadUser() { 19 | try { 20 | //Get User Info 21 | const user = yield call(getUser); 22 | 23 | //Tell the store to save the user Info 24 | //Activate loadDashboardSecuenced 25 | 26 | yield put({type: 'FETCH_USER_SUCCESS', payload: user}); 27 | 28 | } catch(error) { 29 | yield put({type: 'FETCH_FAILED', error.message}); 30 | } 31 | } 32 | 33 | ``` 34 | 35 | ## Demostrate Fork in rootSaga 36 | 37 | ``` 38 | import { takeLatest } from 'redux-saga'; 39 | import { fork } from 'redux-saga/effects'; 40 | import {loadUser} from './loadUser'; 41 | import {loadDashboard} from './loadDashboard'; 42 | 43 | 44 | yield[ 45 | fork(loadUser) 46 | ] 47 | ``` 48 | 49 | * Explain 'take' effects, making the saga wait for an action... 50 | * Explain takeEvery and takeLatest 51 | * Add the Action ```LOAD_DASHBOARD``` to the rootSaga 52 | 53 | 54 | ## Create loadDashboard 55 | 56 | * import loadDashboard from './loadDashboard'; 57 | 58 | ``` 59 | import {take, call, put, select} from 'redux-saga/effects'; 60 | import {loadDeparture, loadFlight, loadForecast} from './apiCalls'; 61 | 62 | 63 | export function* loadDashboard() { 64 | try { 65 | //waits for user to be loaded 66 | yield take('FETCH_USER_SUCCESS'); 67 | 68 | //get user from store 69 | const user = yield select((state) => state.user); 70 | 71 | //get departure 72 | const departure = yield call(loadDeparture, user); 73 | 74 | //get flight 75 | const flight = yield call(loadFlight, departure.flightID); 76 | 77 | //get forecast 78 | const forecast = yield call(loadForecast, departure.date); 79 | 80 | yield put({type: 'FETCH_DASHBOARD_SUCCESS', payload: {flight, departure, forecast}}); 81 | 82 | } catch (e) { 83 | yield put({ type: 'FETCH_FAILED', error: e.message}); 84 | } 85 | 86 | } 87 | ``` 88 | 89 | ## Load forecast and flight in paralel 90 | 91 | ``` 92 | //get flight and forecast 93 | const [flight, forecast] = yield [call(loadFlight, departure.flightID), call(loadForecast, departure.date)]; 94 | ``` 95 | 96 | ## Isolate forecast and flight 97 | 98 | ``` 99 | yield [ 100 | fork(flightSaga, departure), 101 | fork(forecastSaga, departure), 102 | put({type: 'FETCH_DASHBOARD_SUCCESS', payload: {departure}}) 103 | ]; 104 | ``` 105 | 106 | ## Create Flight 107 | 108 | ``` 109 | function* flightSaga(departure) { 110 | try { 111 | const flight = yield call(loadFlight, departure.flightID); 112 | 113 | yield put({type: 'FETCH_DASHBOARD_SUCCESS', payload: {flight}}); 114 | 115 | } catch(e) { 116 | yield put({type: 'FETCH_FAILED', message: e.message}); 117 | } 118 | } 119 | ``` 120 | 121 | ## Create Forecast 122 | 123 | ``` 124 | function* forecastSaga(departure) { 125 | try { 126 | const forecast = yield call(loadForecast, departure.date); 127 | 128 | yield put({type: 'FETCH_DASHBOARD_SUCCESS', payload: {forecast}}); 129 | } catch(e) { 130 | yield put({type: 'FETCH_FAILED', message: e.message}); 131 | } 132 | } 133 | ``` 134 | 135 | Comment it's better to open a listener... 136 | 137 | 138 | ## Testing 139 | 140 | ``` 141 | import expect from 'expect'; 142 | import {loadDashboard, getUser, flightSaga, forecastSaga} from './loadDashboard'; 143 | import {take, call, put, select, fork} from 'redux-saga/effects'; 144 | import {loadDeparture} from './apiCalls'; 145 | import {flight, departure, forecast, user} from './testHelpers'; 146 | 147 | describe('loadDashboard Saga', () => { 148 | let output = null; 149 | const saga = loadDashboard(); 150 | 151 | it('sould take FETCH_USER_SUCCESS', () => { 152 | output = saga.next().value; 153 | let expected = take('FETCH_USER_SUCCESS'); 154 | expect(output).toEqual(expected); 155 | }); 156 | 157 | it('should select user from state', () => { 158 | output = saga.next().value; 159 | let expected = select(getUser); 160 | expect(output).toEqual(expected); 161 | }); 162 | 163 | it('should call departure with user obj', () => { 164 | output = saga.next(user).value; 165 | let expected = call(loadDeparture, user); 166 | expect(output).toEqual(expected); 167 | }); 168 | 169 | it('should fork and put other effects', () => { 170 | output = saga.next(departure).value; 171 | let expected = [fork(flightSaga, departure), 172 | fork(forecastSaga, departure), 173 | put({type: 'FETCH_DASHBOARD_SUCCESS', payload: {departure}}) 174 | ]; 175 | expect(output).toEqual(expected); 176 | }); 177 | 178 | }); 179 | 180 | describe('flightSaga', () => { 181 | let output = null; 182 | const saga = flightSaga(departure); 183 | 184 | it('should call loadFlight', () => { 185 | output = saga.next().value; 186 | let expected = call(loadFlight, departure.flightID); 187 | expect(output).toEqual(expected); 188 | }); 189 | 190 | /**TEST The Error**/ 191 | it('should break on error is something is missing', (done) => { 192 | const sagaError = flightSaga(); 193 | let message = "Cannot read property 'flightID' of undefined"; 194 | output = sagaError.next().value; 195 | call(loadDeparture, user); 196 | let expected = put({type: 'FETCH_FAILED', message}); 197 | done(); 198 | expect(output).toEqual(expected); 199 | }) 200 | 201 | it('should put FETCH_DASHBOARD_SUCCESS', ()=> { 202 | output = saga.next(flight).value; 203 | let expected = put({type: 'FETCH_DASHBOARD_SUCCESS', payload: {flight} }); 204 | expect(output).toEqual(expected); 205 | }); 206 | 207 | }); 208 | 209 | 210 | ``` 211 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "async-redux-saga", 3 | "version": "1.0.0", 4 | "description": "Sample distributed calls using sagas", 5 | "engines": { 6 | "npm": ">=3" 7 | }, 8 | "scripts": { 9 | "preinstall": "node tools/nodeVersionCheck.js", 10 | "setup": "node tools/setup/setupMessage.js && npm install && node tools/setup/setup.js", 11 | "start-message": "babel-node tools/startMessage.js", 12 | "prestart": "npm-run-all --parallel start-message remove-dist", 13 | "start": "npm-run-all --parallel test:watch open:src lint:watch", 14 | "open:src": "babel-node tools/srcServer.js", 15 | "open:dist": "babel-node tools/distServer.js", 16 | "lint": "esw webpack.config.* src tools --color", 17 | "lint:watch": "npm run lint -- --watch", 18 | "clean-dist": "npm run remove-dist && mkdir dist", 19 | "remove-dist": "rimraf ./dist", 20 | "prebuild": "npm run clean-dist && npm run lint && npm run test", 21 | "build": "babel-node tools/build.js && npm run open:dist", 22 | "build:travis": "babel-node tools/build.js", 23 | "test": "mocha tools/testSetup.js \"src/**/*.spec.js\" --reporter progress", 24 | "test:cover": "babel-node node_modules/isparta/bin/isparta cover --root src --report html node_modules/mocha/bin/_mocha -- --require ./tools/testSetup.js \"src/**/*.spec.js\" --reporter progress", 25 | "test:cover:travis": "babel-node node_modules/isparta/bin/isparta cover --root src --report lcovonly _mocha -- --require ./tools/testSetup.js \"src/**/*.spec.js\" && cat ./coverage/lcov.info | node_modules/coveralls/bin/coveralls.js", 26 | "test:watch": "npm run test -- --watch", 27 | "open:cover": "npm run test:cover && open coverage/index.html" 28 | }, 29 | "author": "Andres Mijares", 30 | "license": "MIT", 31 | "dependencies": { 32 | "bootstrap": "3.3.7", 33 | "object-assign": "4.1.0", 34 | "react": "15.2.1", 35 | "react-dom": "15.2.1", 36 | "react-redux": "4.4.5", 37 | "react-router": "2.6.0", 38 | "react-router-redux": "4.0.5", 39 | "redux": "3.5.2", 40 | "redux-saga": "0.11.0" 41 | }, 42 | "devDependencies": { 43 | "autoprefixer": "6.3.7", 44 | "babel-cli": "6.11.4", 45 | "babel-core": "6.11.4", 46 | "babel-loader": "6.2.4", 47 | "babel-plugin-react-display-name": "2.0.0", 48 | "babel-plugin-transform-react-constant-elements": "6.9.1", 49 | "babel-plugin-transform-react-remove-prop-types": "0.2.7", 50 | "babel-plugin-transform-runtime": "6.12.0", 51 | "babel-preset-es2015": "6.9.0", 52 | "babel-preset-react": "6.11.1", 53 | "babel-preset-react-hmre": "1.1.1", 54 | "babel-preset-stage-1": "6.5.0", 55 | "babel-register": "6.11.5", 56 | "babel-runtime": "6.11.6", 57 | "browser-sync": "2.13.0", 58 | "chai": "3.5.0", 59 | "chalk": "1.1.3", 60 | "connect-history-api-fallback": "1.2.0", 61 | "coveralls": "2.11.11", 62 | "cross-env": "2.0.0", 63 | "css-loader": "0.23.1", 64 | "eslint": "3.1.1", 65 | "eslint-plugin-import": "1.11.1", 66 | "eslint-plugin-jsx-a11y": "2.0.1", 67 | "eslint-plugin-react": "5.2.2", 68 | "eslint-watch": "2.1.13", 69 | "expect": "1.20.2", 70 | "extract-text-webpack-plugin": "1.0.1", 71 | "file-loader": "0.9.0", 72 | "html-webpack-plugin": "2.22.0", 73 | "isparta": "4.0.0", 74 | "mocha": "2.5.3", 75 | "node-sass": "3.8.0", 76 | "npm-run-all": "2.3.0", 77 | "open": "0.0.5", 78 | "postcss-loader": "0.9.1", 79 | "prompt": "1.0.0", 80 | "react-addons-test-utils": "15.2.1", 81 | "redux-immutable-state-invariant": "1.2.3", 82 | "replace": "0.3.0", 83 | "rimraf": "2.5.4", 84 | "sass-loader": "4.0.0", 85 | "style-loader": "0.13.1", 86 | "url-loader": "0.5.7", 87 | "webpack": "1.13.1", 88 | "webpack-dev-middleware": "1.6.1", 89 | "webpack-hot-middleware": "2.12.1", 90 | "webpack-md5-hash": "0.0.5" 91 | }, 92 | "keywords": [ 93 | "react", 94 | "reactjs", 95 | "react-router", 96 | "hot", 97 | "reload", 98 | "hmr", 99 | "live", 100 | "edit", 101 | "webpack", 102 | "redux", 103 | "flux", 104 | "boilerplate", 105 | "starter" 106 | ], 107 | "repository": { 108 | "type": "git", 109 | "url": "https://github.com/andresmijares/distrbuted-asyn-react-redux-saga" 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/api/travelServiceApi.js: -------------------------------------------------------------------------------- 1 | class TravelServiceApi { 2 | 3 | static getUser() { 4 | return new Promise((resolve) => { 5 | setTimeout(() => { 6 | resolve(Object.assign({}, { 7 | email : "andresmijares@gmail.com", 8 | repository: "https://github.com/andresmijares/distributed-async-react-redux-saga" 9 | })); 10 | }, 3000); 11 | }); 12 | } 13 | 14 | static getDeparture(user) { 15 | return new Promise((resolve) => { 16 | setTimeout(() => { 17 | resolve(Object.assign({}, { 18 | userID : user.email, 19 | flightID : "AR1973", 20 | date : "10/27/2016 16:00PM" 21 | })); 22 | }, 2500); 23 | }); 24 | } 25 | 26 | static getFlight(flightID) { 27 | return new Promise((resolve) => { 28 | setTimeout(() => { 29 | resolve(Object.assign({}, { 30 | id: flightID, 31 | pilot: "Jhonny Bravo", 32 | plane: { 33 | make: "Boeing 747 RC", 34 | model: "TA-889" 35 | }, 36 | state: "onTime" 37 | })); 38 | }, 4500); 39 | }); 40 | } 41 | 42 | static getForecast(date) { 43 | return new Promise((resolve) => { 44 | setTimeout(() => { 45 | resolve(Object.assign({}, { 46 | date: date, 47 | forecast: "rain" 48 | })); 49 | }, 2000); 50 | }); 51 | } 52 | 53 | } 54 | 55 | export default TravelServiceApi; 56 | -------------------------------------------------------------------------------- /src/components/Dashboard.js: -------------------------------------------------------------------------------- 1 | import React, {PropTypes} from 'react'; 2 | 3 | const Dashboard = ({title, user, data = {}}) => { 4 | 5 | const {departure, flight, forecast} = data; 6 | 7 | const displayUserName = () => { 8 | return (!!user) ? user.email : null; 9 | }; 10 | 11 | const displayFlight = () => { 12 | return (!!flight) ? flight.plane.make : null; 13 | }; 14 | 15 | const displayDeparture = () => { 16 | return (!!departure) ? departure.date : null; 17 | }; 18 | 19 | const displayForecast = () => { 20 | return (!!forecast) ? forecast.forecast : null; 21 | }; 22 | 23 | return (
24 |

{title}

25 |
26 |
27 |
Here is your itinerary:
28 |   29 | {displayUserName() || } 30 |
31 |
32 | Your Departure: {displayDeparture() || } 33 |
34 |
35 | Your Flight: {displayFlight() || } 36 |
37 |
38 | Weather Forecast: {displayForecast() || } 39 |
40 |
41 |
); 42 | }; 43 | 44 | Dashboard.propTypes = { 45 | user : PropTypes.object, 46 | data : PropTypes.object, 47 | title : PropTypes.string 48 | }; 49 | 50 | export default Dashboard; 51 | -------------------------------------------------------------------------------- /src/components/Dashboard.spec.js: -------------------------------------------------------------------------------- 1 | // Must have at least one test file in this directory or Mocha will throw an error. 2 | -------------------------------------------------------------------------------- /src/containers/App.js: -------------------------------------------------------------------------------- 1 | import React, {PropTypes} from 'react'; 2 | 3 | class App extends React.Component { 4 | render() { 5 | return ( 6 |
7 |
8 |
9 | {this.props.panel} 10 | {this.props.panel2} 11 | {this.props.panel3} 12 | Check the repo here 13 |
14 |
15 |
16 | ); 17 | } 18 | } 19 | 20 | App.propTypes = { 21 | panel: PropTypes.object.isRequired, 22 | panel2: PropTypes.object.isRequired, 23 | panel3: PropTypes.object.isRequired 24 | }; 25 | 26 | export default App; 27 | -------------------------------------------------------------------------------- /src/containers/CustomMixin.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/display-name*/ 2 | import React, {PropTypes} from 'react'; 3 | 4 | let Mixin = InnerComponent => class extends React.Component { 5 | 6 | static propTypes() { 7 | return { 8 | loadDashboard : PropTypes.loadDashboard.func.isRequired, 9 | user : PropTypes.object.isRequired, 10 | title: PropTypes.string.isRequired 11 | }; 12 | } 13 | 14 | componentDidMount() { 15 | /*start the loading*/ 16 | this.props.loadDashboard(); 17 | } 18 | render() { 19 | return (); 22 | } 23 | }; 24 | 25 | export default Mixin; 26 | -------------------------------------------------------------------------------- /src/containers/Panel.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | import React from 'react'; 3 | import {connect} from 'react-redux'; 4 | import Dashboard from '../components/Dashboard'; 5 | import Mixin from './CustomMixin'; 6 | 7 | const Panel = (props) => { 8 | return( 9 |
10 | 11 |
12 | ); 13 | }; 14 | 15 | let PanelMixed = Mixin(Panel); 16 | 17 | const mapStateToProps =(state) => ({ 18 | user : state.user, 19 | dashboard : state.dashboard 20 | }); 21 | 22 | function mapDispatchToProps(dispatch) { 23 | return { 24 | loadDashboard : function() { 25 | return dispatch({type: 'LOAD_DASHBOARD'}); 26 | } 27 | }; 28 | } 29 | 30 | export default connect(mapStateToProps, mapDispatchToProps)(PanelMixed); 31 | -------------------------------------------------------------------------------- /src/containers/Panel2.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | import React from 'react'; 3 | import {connect} from 'react-redux'; 4 | import Dashboard from '../components/Dashboard'; 5 | import Mixin from './CustomMixin'; 6 | 7 | const Panel = (props) => { 8 | return( 9 |
10 | 11 |
12 | ); 13 | }; 14 | 15 | let PanelMixed2 = Mixin(Panel); 16 | 17 | const mapStateToProps =(state) => ({ 18 | user : state.user, 19 | dashboard2 : state.dashboard2 20 | }); 21 | 22 | function mapDispatchToProps(dispatch) { 23 | return { 24 | loadDashboard : function() { 25 | return dispatch({type: 'LOAD_DASHBOARD_NON_SEQUENCED'}); 26 | } 27 | }; 28 | } 29 | 30 | export default connect(mapStateToProps, mapDispatchToProps)(PanelMixed2); 31 | -------------------------------------------------------------------------------- /src/containers/Panel3.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | import React from 'react'; 3 | import {connect} from 'react-redux'; 4 | import Dashboard from '../components/Dashboard'; 5 | import Mixin from './CustomMixin'; 6 | 7 | const Panel = (props) => { 8 | return( 9 |
10 | 11 |
12 | ); 13 | }; 14 | 15 | let PanelMixed3 = Mixin(Panel); 16 | 17 | const mapStateToProps =(state) => ({ 18 | user : state.user, 19 | dashboard3 : state.dashboard3 20 | }); 21 | 22 | function mapDispatchToProps(dispatch) { 23 | return { 24 | loadDashboard : function() { 25 | return dispatch({type: 'LOAD_DASHBOARD_NON_SEQUENCED_NON_BLOCKING'}); 26 | } 27 | }; 28 | } 29 | 30 | export default connect(mapStateToProps, mapDispatchToProps)(PanelMixed3); 31 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andresmijares/async-redux-saga/6e9bdb318e3d9e42c8cff7d139f4f03cad998cec/src/favicon.ico -------------------------------------------------------------------------------- /src/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <% if (htmlWebpackPlugin.options.trackJSToken) { %> 5 | 6 | 7 | <% } %> 8 | 9 | 10 | 11 | Async Redux with Sagas 12 | 13 | 14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/default */ 2 | import React from 'react'; 3 | import {render} from 'react-dom'; 4 | import { Provider } from 'react-redux'; 5 | import { Router, browserHistory } from 'react-router'; 6 | import routes from './routes'; 7 | import configureStore from './store/configureStore'; 8 | require('./favicon.ico'); 9 | import '../node_modules/bootstrap/dist/css/bootstrap.min.css'; 10 | import './styles/styles.scss'; 11 | 12 | 13 | const store = configureStore(); 14 | 15 | render( 16 | 17 | 18 | , 19 | document.getElementById('app') 20 | ); 21 | -------------------------------------------------------------------------------- /src/reducers/dashboardReducer.js: -------------------------------------------------------------------------------- 1 | export const dashboard = (state = {}, action) => { 2 | switch(action.type) { 3 | case 'FETCH_DASHBOARD_SUCCESS': 4 | return Object.assign({}, state, action.payload); 5 | default : 6 | return state; 7 | } 8 | }; 9 | 10 | export const dashboard2 = (state = {}, action) => { 11 | switch(action.type) { 12 | case 'FETCH_DASHBOARD2_SUCCESS': 13 | return Object.assign({}, state, action.payload); 14 | default : 15 | return state; 16 | } 17 | }; 18 | 19 | export const dashboard3 = (state = {}, action) => { 20 | switch(action.type) { 21 | case 'FETCH_DASHBOARD3_SUCCESS': 22 | return Object.assign({}, state, action.payload); 23 | default : 24 | return state; 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import user from './userReducer'; 3 | import {dashboard, dashboard2, dashboard3} from './dashboardReducer'; 4 | 5 | 6 | const rootReducer = combineReducers({ 7 | user, 8 | dashboard, 9 | dashboard2, 10 | dashboard3 11 | }); 12 | 13 | export default rootReducer; 14 | -------------------------------------------------------------------------------- /src/reducers/userReducer.js: -------------------------------------------------------------------------------- 1 | const user = (state = {}, action) => { 2 | switch(action.type) { 3 | case 'FETCH_USER_SUCCESS' : 4 | return action.payload; 5 | default : 6 | return state; 7 | } 8 | }; 9 | 10 | export default user; 11 | -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Route, IndexRoute} from 'react-router'; 3 | import App from './containers/App'; 4 | import PanelMixed from './containers/Panel'; 5 | import PanelMixed2 from './containers/Panel2'; 6 | import PanelMixed3 from './containers/Panel3'; 7 | 8 | export default( 9 | 10 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /src/sagas/apiCalls.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import TravelServiceApi from '../api/travelServiceApi'; 3 | 4 | export const loadUser = () => { 5 | console.log('loading user'); 6 | return TravelServiceApi.getUser().then(res => res); 7 | }; 8 | 9 | export const loadDeparture = (user) => { 10 | console.log('loading departure'); 11 | return TravelServiceApi.getDeparture(user).then(res => res); 12 | }; 13 | 14 | export const loadFlight = (flightID) => { 15 | console.log('loading flight'); 16 | return TravelServiceApi.getFlight(flightID).then(res => res); 17 | }; 18 | 19 | export const loadForecast = (date) => { 20 | console.log('loading forecast'); 21 | return TravelServiceApi.getForecast(date).then(res => res); 22 | }; 23 | -------------------------------------------------------------------------------- /src/sagas/index.js: -------------------------------------------------------------------------------- 1 | import { takeLatest } from 'redux-saga'; 2 | import { fork } from 'redux-saga/effects'; 3 | import {loadUser} from './loadUser'; 4 | import {loadDashboardSequenced} from './loadDashboardSequenced'; 5 | import {loadDashboardNonSequenced} from './loadDashboardNonSequenced'; 6 | import {loadDashboardNonSequencedNonBlocking, isolatedForecast, isolatedFlight } from './loadDashboardNonSequencedNonBlocking'; 7 | 8 | function* rootSaga() { 9 | /*The saga is waiting for a action called LOAD_DASHBOARD to be activated */ 10 | yield [ 11 | fork(loadUser), 12 | takeLatest('LOAD_DASHBOARD', loadDashboardSequenced), 13 | takeLatest('LOAD_DASHBOARD_NON_SEQUENCED', loadDashboardNonSequenced), 14 | takeLatest('LOAD_DASHBOARD_NON_SEQUENCED_NON_BLOCKING', loadDashboardNonSequencedNonBlocking), 15 | fork(isolatedForecast), 16 | fork(isolatedFlight) 17 | ]; 18 | } 19 | 20 | export default rootSaga; 21 | -------------------------------------------------------------------------------- /src/sagas/loadDashBoardSequenced.spec.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | import {loadDashboardSequenced, getUserFromState} from './loadDashboardSequenced'; 3 | import { call, put, select , take} from 'redux-saga/effects'; 4 | import {loadDeparture, loadFlight, loadForecast } from './apiCalls'; 5 | import {flight, departure, forecast, user} from './testHelpers'; 6 | 7 | 8 | describe('Sequenced Saga', () => { 9 | const saga = loadDashboardSequenced(); 10 | let output = null; 11 | 12 | it('should take fetch users success', () => { 13 | output = saga.next().value; 14 | let expected = take('FETCH_USER_SUCCESS'); 15 | expect(output).toEqual(expected); 16 | }); 17 | 18 | it('should select the state from store', () => { 19 | output = saga.next().value; 20 | let expected = select(getUserFromState); 21 | expect(output).toEqual(expected); 22 | }); 23 | 24 | it('should call LoadDeparture with the user obj', (done) => { 25 | output = saga.next(user).value; 26 | let expected = call(loadDeparture, user); 27 | done(); 28 | expect(output).toEqual(expected); 29 | }); 30 | 31 | 32 | it('should Load the flight with the flightId', (done) => { 33 | let output = saga.next(departure).value; 34 | let expected = call(loadFlight, departure.flightID); 35 | done(); 36 | expect(output).toEqual(expected); 37 | }); 38 | 39 | it('should load the forecast with the departure date', (done) => { 40 | output = saga.next(flight).value; 41 | let expected = call(loadForecast, departure.date); 42 | done(); 43 | expect(output).toEqual(expected); 44 | }); 45 | 46 | it('should put Fetch dashboard success', (done) => { 47 | output = saga.next(forecast, departure, flight ).value; 48 | let expected = put({type: 'FETCH_DASHBOARD_SUCCESS', payload: {forecast, flight, departure}}); 49 | const finished = saga.next().done; 50 | done(); 51 | expect(finished).toEqual(true); 52 | expect(output).toEqual(expected); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/sagas/loadDashboardNonSequenced.js: -------------------------------------------------------------------------------- 1 | import { call, put, select , take} from 'redux-saga/effects'; 2 | import {loadDeparture, loadFlight, loadForecast } from './apiCalls'; 3 | 4 | export const getUserFromState = (state) => state.user; 5 | 6 | export function* loadDashboardNonSequenced() { 7 | try { 8 | //Wait for the user to be loaded 9 | yield take('FETCH_USER_SUCCESS'); 10 | 11 | //Take the user info from the store 12 | const user = yield select(getUserFromState); 13 | 14 | //Get Departure information 15 | const departure = yield call(loadDeparture, user); 16 | 17 | //Flight and Forecast can be called non-sequenced /* BUT BLOCKING */ 18 | const [flight, forecast] = yield [call(loadFlight, departure.flightID), call(loadForecast, departure.date)]; 19 | 20 | //Tell the store we are ready to be displayed 21 | yield put({type: 'FETCH_DASHBOARD2_SUCCESS', payload: {departure, flight, forecast}}); 22 | 23 | } catch(error) { 24 | yield put({type: 'FETCH_FAILED', error: error.message}); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/sagas/loadDashboardNonSequenced.spec.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | import {loadDashboardNonSequenced, getUserFromState} from './loadDashboardNonSequenced'; 3 | import { call, put, select , take} from 'redux-saga/effects'; 4 | import {loadDeparture, loadFlight, loadForecast } from './apiCalls'; 5 | import {flight, departure, forecast, user} from './testHelpers'; 6 | 7 | describe('NonSequenced Saga', () => { 8 | const saga = loadDashboardNonSequenced(); 9 | let output = null; 10 | 11 | it('should take the FETCH_USER_SUCCESS action', () => { 12 | output = saga.next().value; 13 | let expected = take('FETCH_USER_SUCCESS'); 14 | expect(output).toEqual(expected); 15 | }); 16 | 17 | it('should select the user info from the store', () => { 18 | output = saga.next().value; 19 | let expected = select(getUserFromState); 20 | expect(output).toEqual(expected); 21 | }); 22 | 23 | it('should call loadDeparture with the user object', (done) => { 24 | output = saga.next(user).value; 25 | let expected = call(loadDeparture, user); 26 | done(); 27 | expect(output).toEqual(expected); 28 | }); 29 | 30 | it('should call loadFlight and loadForecast at the same time', (done)=> { 31 | output = saga.next(departure).value; 32 | let expected = [call(loadFlight, departure.flightID), call(loadForecast, departure.date)]; 33 | done(); 34 | expect(output).toEqual(expected); 35 | }); 36 | 37 | it('should put an action FETCH_DASHBOARD2_SUCCESS time', ()=> { 38 | output = saga.next([flight, forecast]).value; 39 | let expected = put({type: 'FETCH_DASHBOARD2_SUCCESS', payload: {departure, flight, forecast}}); 40 | const finished = saga.next().done; 41 | expect(finished).toEqual(true); 42 | expect(output).toEqual(expected); 43 | }); 44 | 45 | it('should break on error if the departure yield is missing', (done) => { 46 | const sagaError = loadDashboardNonSequenced(); 47 | let error = "Cannot read property 'flightID' of undefined"; 48 | sagaError.next(); //take 49 | sagaError.next(); //salect 50 | sagaError.next(); //departure 51 | output = sagaError.next().value; 52 | let expected = put({type: 'FETCH_FAILED', error}); 53 | done(); 54 | expect(output).toEqual(expected); 55 | }); 56 | 57 | }); 58 | -------------------------------------------------------------------------------- /src/sagas/loadDashboardNonSequencedNonBlocking.js: -------------------------------------------------------------------------------- 1 | import { call, put, select , take} from 'redux-saga/effects'; 2 | import {loadDeparture, loadFlight, loadForecast } from './apiCalls'; 3 | 4 | export const getUserFromState = (state) => state.user; 5 | 6 | export function* loadDashboardNonSequencedNonBlocking() { 7 | try { 8 | //Wait for the user to be loaded 9 | yield take('FETCH_USER_SUCCESS'); 10 | 11 | //Take the user info from the store 12 | const user = yield select(getUserFromState); 13 | 14 | //Get Departure information 15 | const departure = yield call(loadDeparture, user); 16 | 17 | //Update the UI 18 | yield put({type: 'FETCH_DASHBOARD3_SUCCESS', payload: {departure}}); 19 | 20 | //trigger actions for Forecast and Flight to start... 21 | //We can pass and object into the put statement 22 | yield put({type: 'FETCH_DEPARTURE3_SUCCESS', departure}); 23 | 24 | } catch(error) { 25 | yield put({type: 'FETCH_FAILED', error: error.message}); 26 | } 27 | } 28 | 29 | export function* isolatedFlight() { 30 | try { 31 | /* departure will take the value of the object passed by the put*/ 32 | const departure = yield take('FETCH_DEPARTURE3_SUCCESS'); 33 | 34 | //Flight can be called unsequenced /* BUT NON BLOCKING VS FORECAST*/ 35 | const flight = yield call(loadFlight, departure.flightID); 36 | //Tell the store we are ready to be displayed 37 | yield put({type: 'FETCH_DASHBOARD3_SUCCESS', payload: {flight}}); 38 | 39 | } catch (error) { 40 | yield put({type: 'FETCH_FAILED', error: error.message}); 41 | } 42 | } 43 | 44 | export function* isolatedForecast() { 45 | try { 46 | /* departure will take the value of the object passed by the put*/ 47 | const departure = yield take('FETCH_DEPARTURE3_SUCCESS'); 48 | 49 | const forecast = yield call(loadForecast, departure.date); 50 | yield put({type: 'FETCH_DASHBOARD3_SUCCESS', payload: { forecast }}); 51 | 52 | } catch(error) { 53 | yield put({type: 'FETCH_FAILED', error: error.message}); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/sagas/loadDashboardNonSequencedNonBlocking.spec.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | import {loadDashboardNonSequencedNonBlocking, getUserFromState, isolatedFlight, isolatedForecast} from './loadDashboardNonSequencedNonBlocking'; 3 | import { call, put, select , take} from 'redux-saga/effects'; 4 | import {loadDeparture, loadFlight, loadForecast } from './apiCalls'; 5 | import {flight, departure, forecast, user} from './testHelpers'; 6 | 7 | 8 | describe('NonSequencedNonBlocking Saga', () => { 9 | let output = null; 10 | const saga = loadDashboardNonSequencedNonBlocking(); 11 | 12 | it('should take FETCH_USER_SUCCESS', () => { 13 | output = saga.next().value; 14 | let expected = take('FETCH_USER_SUCCESS'); 15 | expect(output).toEqual(expected); 16 | }); 17 | 18 | it('should select the user from store', ()=>{ 19 | output = saga.next().value; 20 | let expected = select(getUserFromState); 21 | expect(output).toEqual(expected); 22 | }); 23 | 24 | it('should call loadDeparture with user yield', (done) => { 25 | output = saga.next(user).value; 26 | let expected = call(loadDeparture, user); 27 | done(); 28 | expect(output).toEqual(expected); 29 | }); 30 | 31 | it('should put FETCH_DEPARTURE3_SUCCESS', ()=> { 32 | output = saga.next(departure).value; 33 | let expected = put({type: 'FETCH_DASHBOARD3_SUCCESS', payload: {departure}}); 34 | expect(output).toEqual(expected); 35 | }); 36 | 37 | it('should put FETCH_DEPARTURE3_SUCCESS', ()=> { 38 | output = saga.next(departure).value; 39 | let expected = put({type: 'FETCH_DEPARTURE3_SUCCESS', departure}); 40 | expect(output).toEqual(expected); 41 | }); 42 | 43 | describe('isolatedFlight', () => { 44 | const saga = isolatedFlight(); 45 | output = null; 46 | 47 | it('should take FETCH_DEPARTURE3_SUCCESS', () => { 48 | output = saga.next().value; 49 | let expected = take('FETCH_DEPARTURE3_SUCCESS'); 50 | expect(output).toEqual(expected); 51 | }); 52 | 53 | it('should call loadFlight with the departure object', (done) => { 54 | output = saga.next(departure).value; 55 | let expected = call(loadFlight, departure.flightID); 56 | done(); 57 | expect(output).toEqual(expected); 58 | }); 59 | 60 | it('should break on error if departure is undefined', (done) => { 61 | const sagaError = isolatedFlight(); 62 | let error = "Cannot read property 'flightID' of undefined"; 63 | sagaError.next(); 64 | output = sagaError.next().value; 65 | call(loadFlight, departure.flightID); 66 | let expected = put({type: 'FETCH_FAILED', error}); 67 | done(); 68 | expect(output).toEqual(expected); 69 | }); 70 | 71 | it('should put FETCH_DASHBOARD3_SUCCESS', () => { 72 | output = saga.next(flight).value; 73 | let expected = put({type: 'FETCH_DASHBOARD3_SUCCESS', payload: {flight}}); 74 | expect(output).toEqual(expected); 75 | }); 76 | 77 | }); 78 | 79 | describe('isolatedForecast', () => { 80 | const saga = isolatedForecast(); 81 | output = null; 82 | 83 | it('shoudl take FETCH_DEPARTURE3_SUCCESS', () => { 84 | output = saga.next().value; 85 | let expected = take('FETCH_DEPARTURE3_SUCCESS'); 86 | expect(output).toEqual(expected); 87 | }); 88 | 89 | it('should call loadForecast with departure yield', (done)=>{ 90 | output = saga.next(departure).value; 91 | let expected = call(loadForecast, departure.date); 92 | done(); 93 | expect(output).toEqual(expected); 94 | }); 95 | 96 | it('should fail to call loadForecast if departure is missing', (done)=>{ 97 | const sagaError = isolatedForecast(); 98 | let error = "Cannot read property 'date' of undefined"; 99 | sagaError.next(); 100 | call(loadForecast, departure.date); 101 | output = sagaError.next().value; 102 | let expected = put({type: 'FETCH_FAILED', error}); 103 | done(); 104 | expect(output).toEqual(expected); 105 | }); 106 | 107 | it('should put FETCH_DASHBOARD3_SUCCESS', (done) => { 108 | output = saga.next(forecast).value; 109 | let expected = put({type: 'FETCH_DASHBOARD3_SUCCESS', payload: {forecast}}); 110 | const finished = saga.next().done; 111 | done(); 112 | expect(finished).toEqual(true); 113 | expect(output).toEqual(expected); 114 | }); 115 | 116 | }); 117 | 118 | }); 119 | -------------------------------------------------------------------------------- /src/sagas/loadDashboardSequenced.js: -------------------------------------------------------------------------------- 1 | import { call, put, select , take} from 'redux-saga/effects'; 2 | import {loadDeparture, loadFlight, loadForecast } from './apiCalls'; 3 | 4 | export const getUserFromState = (state) => state.user; 5 | 6 | export function* loadDashboardSequenced() { 7 | 8 | try { 9 | //Wait for the user to be loaded 10 | yield take('FETCH_USER_SUCCESS'); 11 | 12 | //Take the user info from the store 13 | const user = yield select(getUserFromState); 14 | //Get Departure information 15 | const departure = yield call(loadDeparture, user); 16 | 17 | //When Departure is ready, Get the Flight Info 18 | const flight = yield call(loadFlight, departure.flightID); 19 | 20 | //Finally get the forecast 21 | const forecast = yield call(loadForecast, departure.date); 22 | 23 | //Tell the store we are ready to be displayed 24 | yield put({type: 'FETCH_DASHBOARD_SUCCESS', payload: {forecast, flight, departure} }); 25 | 26 | 27 | } catch(error) { 28 | yield put({type: 'FETCH_FAILED', error: error.message}); 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /src/sagas/loadUser.js: -------------------------------------------------------------------------------- 1 | import { call, put } from 'redux-saga/effects'; 2 | import {loadUser as getUser } from './apiCalls'; 3 | 4 | export function* loadUser() { 5 | try { 6 | //Get User Info 7 | const user = yield call(getUser); 8 | 9 | //Tell the store to save the user Info also activate loadDashboardSecuenced 10 | yield put({type: 'FETCH_USER_SUCCESS', payload: user}); 11 | 12 | } catch(error) { 13 | yield put({type: 'FETCH_FAILED', error}); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/sagas/testHelpers.js: -------------------------------------------------------------------------------- 1 | export const user = { 2 | email : 'somemockemail@gmail.com' 3 | }; 4 | export const departure = { 5 | flightID : '12HH', 6 | date: '10/27/2016 16:00PM' 7 | }; 8 | export const flight = { 9 | pilot: "Jhonny Bravo", 10 | plane: { 11 | make: "Boeing 747 RC", 12 | model: "TA-889" 13 | } 14 | }; 15 | export const forecast = { 16 | forecast: "rain" 17 | }; 18 | -------------------------------------------------------------------------------- /src/store/configureStore.dev.js: -------------------------------------------------------------------------------- 1 | import { createStore, compose, applyMiddleware } from 'redux'; 2 | import rootReducer from '../reducers'; 3 | import createSagaMiddleware from 'redux-saga'; 4 | import rootSaga from '../sagas'; 5 | 6 | const sagaMiddleware = createSagaMiddleware(); 7 | 8 | export default function configureStore(initialState) { 9 | const store = createStore(rootReducer, initialState, compose( 10 | applyMiddleware(sagaMiddleware), 11 | window.devToolsExtension ? window.devToolsExtension() : f => f // add support for Redux dev tools 12 | ) 13 | ); 14 | sagaMiddleware.run(rootSaga); 15 | 16 | if (module.hot) { 17 | // Enable Webpack hot module replacement for reducers 18 | module.hot.accept('../reducers', () => { 19 | const nextReducer = require('../reducers').default; // eslint-disable-line global-require 20 | store.replaceReducer(nextReducer); 21 | }); 22 | } 23 | 24 | return store; 25 | } 26 | -------------------------------------------------------------------------------- /src/store/configureStore.js: -------------------------------------------------------------------------------- 1 | if (process.env.NODE_ENV === 'production') { 2 | module.exports = require('./configureStore.prod'); 3 | } else { 4 | module.exports = require('./configureStore.dev'); 5 | } 6 | -------------------------------------------------------------------------------- /src/store/configureStore.prod.js: -------------------------------------------------------------------------------- 1 | import { createStore, compose, applyMiddleware } from 'redux'; 2 | import rootReducer from '../reducers'; 3 | import createSagaMiddleware from 'redux-saga'; 4 | import rootSaga from '../sagas'; 5 | 6 | const sagaMiddleware = createSagaMiddleware(); 7 | 8 | export default function configureStore(initialState) { 9 | const store = createStore(rootReducer, initialState, compose( 10 | applyMiddleware(sagaMiddleware)) 11 | ); 12 | sagaMiddleware.run(rootSaga); 13 | return store; 14 | } 15 | -------------------------------------------------------------------------------- /src/styles/styles.scss: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 3em 0; 3 | } 4 | 5 | 6 | 7 | .loading:nth-of-type(odd) { 8 | color: red; 9 | } 10 | 11 | .loading:after { 12 | overflow: hidden; 13 | display: inline-block; 14 | vertical-align: bottom; 15 | -webkit-animation: ellipsis steps(4,end) 900ms infinite; 16 | animation: ellipsis steps(4,end) 900ms infinite; 17 | content: "\2026"; /* ascii code for the ellipsis character */ 18 | width: 0px; 19 | } 20 | 21 | @keyframes ellipsis { 22 | to { 23 | width: 1.25em; 24 | } 25 | } 26 | 27 | @-webkit-keyframes ellipsis { 28 | to { 29 | width: 1.25em; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/webpack-public-path.js: -------------------------------------------------------------------------------- 1 | // Dynamically set the webpack public path at runtime below 2 | // This magic global is used by webpack to set the public path at runtime. 3 | // The public path is set dynamically to avoid the following issues: 4 | // 1. https://github.com/coryhouse/react-slingshot/issues/205 5 | // 2. https://github.com/coryhouse/react-slingshot/issues/181 6 | // 3. https://github.com/coryhouse/react-slingshot/pull/125 7 | // Documentation: http://webpack.github.io/docs/configuration.html#output-publicpath 8 | // eslint-disable-next-line no-undef 9 | __webpack_public_path__ = window.location.protocol + "//" + window.location.host + "/"; 10 | -------------------------------------------------------------------------------- /tools/build.js: -------------------------------------------------------------------------------- 1 | // More info on Webpack's Node API here: https://webpack.github.io/docs/node.js-api.html 2 | // Allowing console calls below since this is a build file. 3 | /* eslint-disable no-console */ 4 | import webpack from 'webpack'; 5 | import config from '../webpack.config.prod'; 6 | import {chalkError, chalkSuccess, chalkWarning, chalkProcessing} from './chalkConfig'; 7 | 8 | process.env.NODE_ENV = 'production'; // this assures React is built in prod mode and that the Babel dev config doesn't apply. 9 | 10 | console.log(chalkProcessing('Generating minified bundle for production via Webpack. This will take a moment...')); 11 | 12 | webpack(config).run((error, stats) => { 13 | if (error) { // so a fatal error occurred. Stop here. 14 | console.log(chalkError(error)); 15 | return 1; 16 | } 17 | 18 | const jsonStats = stats.toJson(); 19 | 20 | if (jsonStats.hasErrors) { 21 | return jsonStats.errors.map(error => console.log(chalkError(error))); 22 | } 23 | 24 | if (jsonStats.hasWarnings) { 25 | console.log(chalkWarning('Webpack generated the following warnings: ')); 26 | jsonStats.warnings.map(warning => console.log(chalkWarning(warning))); 27 | } 28 | 29 | console.log(`Webpack stats: ${stats}`); 30 | 31 | // if we got this far, the build succeeded. 32 | console.log(chalkSuccess('Your app is compiled in production mode in /dist. It\'s ready to roll!')); 33 | 34 | return 0; 35 | }); 36 | -------------------------------------------------------------------------------- /tools/chalkConfig.js: -------------------------------------------------------------------------------- 1 | // Centralized configuration for chalk, which is used to add color to console.log statements. 2 | import chalk from 'chalk'; 3 | export const chalkError = chalk.red; 4 | export const chalkSuccess = chalk.green; 5 | export const chalkWarning = chalk.yellow; 6 | export const chalkProcessing = chalk.blue; 7 | -------------------------------------------------------------------------------- /tools/distServer.js: -------------------------------------------------------------------------------- 1 | // This file configures a web server for testing the production build 2 | // on your local machine. 3 | 4 | import browserSync from 'browser-sync'; 5 | import historyApiFallback from 'connect-history-api-fallback'; 6 | import {chalkProcessing} from './chalkConfig'; 7 | 8 | /* eslint-disable no-console */ 9 | 10 | console.log(chalkProcessing('Opening production build...')); 11 | 12 | // Run Browsersync 13 | browserSync({ 14 | port: 3000, 15 | ui: { 16 | port: 3001 17 | }, 18 | server: { 19 | baseDir: 'dist' 20 | }, 21 | 22 | files: [ 23 | 'src/*.html' 24 | ], 25 | 26 | middleware: [historyApiFallback()] 27 | }); 28 | -------------------------------------------------------------------------------- /tools/nodeVersionCheck.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | var exec = require('child_process').exec; 3 | exec('node -v', function (err, stdout, stderr) { 4 | if (err) throw err; 5 | if (parseFloat(stdout) < 4) { 6 | throw new Error('ERROR: React Slingshot requires node 4.0 or greater.'); 7 | process.exit(1); 8 | } 9 | }); 10 | -------------------------------------------------------------------------------- /tools/removeDemo.js: -------------------------------------------------------------------------------- 1 | // This script removes demo app files 2 | import rimraf from 'rimraf'; 3 | import fs from 'fs'; 4 | import {chalkSuccess} from './chalkConfig'; 5 | 6 | /* eslint-disable no-console */ 7 | 8 | const pathsToRemove = [ 9 | './src/actions/*', 10 | './src/utils', 11 | './src/components/*', 12 | './src/constants/*', 13 | './src/containers/*', 14 | './src/images', 15 | './src/reducers/*', 16 | './src/store/store.spec.js', 17 | './src/styles', 18 | './src/routes.js', 19 | './src/index.js' 20 | ]; 21 | 22 | const filesToCreate = [ 23 | { 24 | path: './src/components/emptyTest.spec.js', 25 | content: '// Must have at least one test file in this directory or Mocha will throw an error.' 26 | }, 27 | { 28 | path: './src/index.js', 29 | content: '// Set up your application entry point here...' 30 | }, 31 | { 32 | path: './src/reducers/index.js', 33 | content: '// Set up your root reducer here...\n import { combineReducers } from \'redux\';\n export default combineReducers;' 34 | } 35 | ]; 36 | 37 | function removePath(path, callback) { 38 | rimraf(path, error => { 39 | if (error) throw new Error(error); 40 | callback(); 41 | }); 42 | } 43 | 44 | function createFile(file) { 45 | fs.writeFile(file.path, file.content, error => { 46 | if (error) throw new Error(error); 47 | }); 48 | } 49 | 50 | let numPathsRemoved = 0; 51 | pathsToRemove.map(path => { 52 | removePath(path, () => { 53 | numPathsRemoved++; 54 | if (numPathsRemoved === pathsToRemove.length) { // All paths have been processed 55 | // Now we can create files since we're done deleting. 56 | filesToCreate.map(file => createFile(file)); 57 | } 58 | }); 59 | }); 60 | 61 | console.log(chalkSuccess('Demo app removed.')); 62 | -------------------------------------------------------------------------------- /tools/setup/setup.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-var */ 2 | var rimraf = require('rimraf'); 3 | var chalk = require('chalk'); 4 | var replace = require("replace"); 5 | var prompt = require("prompt"); 6 | var prompts = require('./setupPrompts'); 7 | 8 | var chalkSuccess = chalk.green; 9 | var chalkProcessing = chalk.blue; 10 | 11 | /* eslint-disable no-console */ 12 | 13 | console.log(chalkSuccess('Dependencies installed.')); 14 | 15 | // remove the original git repository 16 | rimraf('.git', error => { 17 | if (error) throw new Error(error); 18 | }); 19 | console.log(chalkSuccess('Original Git repository removed.\n')); 20 | 21 | // prompt the user for updates to package.json 22 | console.log(chalkProcessing('Updating package.json settings:')); 23 | prompt.start(); 24 | prompt.get(prompts, function(err, result) { 25 | // parse user responses 26 | // default values provided for fields that will cause npm to complain if left empty 27 | const responses = [ 28 | { 29 | key: 'name', 30 | value: result.projectName || 'new-project' 31 | }, 32 | { 33 | key: 'version', 34 | value: result.version || '0.1.0' 35 | }, 36 | { 37 | key: 'author', 38 | value: result.author 39 | }, 40 | { 41 | key: 'license', 42 | value: result.license || 'MIT' 43 | }, 44 | { 45 | key: 'description', 46 | value: result.description 47 | }, 48 | // simply use an empty URL here to clear the existing repo URL 49 | { 50 | key: 'url', 51 | value: '' 52 | } 53 | ]; 54 | 55 | // update package.json with the user's values 56 | responses.forEach(res => { 57 | replace({ 58 | regex: `("${res.key}"): "(.*?)"`, 59 | replacement: `$1: "${res.value}"`, 60 | paths: ['package.json'], 61 | recursive: false, 62 | silent: true 63 | }); 64 | }); 65 | 66 | // reset package.json 'keywords' field to empty state 67 | replace({ 68 | regex: /"keywords": \[[\s\S]+\]/, 69 | replacement: `"keywords": []`, 70 | paths: ['package.json'], 71 | recursive: false, 72 | silent: true 73 | }); 74 | 75 | // remove all setup scripts from the 'tools' folder 76 | console.log(chalkSuccess('\nSetup complete! Cleaning up...\n')); 77 | rimraf('./tools/setup', error => { 78 | if (error) throw new Error(error); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /tools/setup/setupMessage.js: -------------------------------------------------------------------------------- 1 | // This script displays an intro message for the setup script 2 | /* eslint-disable no-console */ 3 | console.log('==========================='); 4 | console.log('= React Slingshot Setup ='); 5 | console.log('===========================\n'); 6 | console.log('Installing dependencies. Please wait...'); 7 | -------------------------------------------------------------------------------- /tools/setup/setupPrompts.js: -------------------------------------------------------------------------------- 1 | // Define prompts for use with npm 'prompt' module in setup script 2 | module.exports = [ 3 | { 4 | name: 'projectName', 5 | description: 'Project name (default: new-project)', 6 | pattern: /^[^._][a-z0-9\.\-_~]+$/, 7 | message: 'Limited to: lowercase letters, numbers, period, hyphen, ' + 8 | 'underscore, and tilde; cannot begin with period or underscore.' 9 | }, 10 | { 11 | name: 'version', 12 | description: 'Version (default: 0.1.0)' 13 | }, 14 | { 15 | name: 'author', 16 | description: 'Author' 17 | }, 18 | { 19 | name: 'license', 20 | description: 'License (default: MIT)' 21 | }, 22 | { 23 | name: 'description', 24 | description: 'Project description' 25 | } 26 | ]; 27 | -------------------------------------------------------------------------------- /tools/srcServer.js: -------------------------------------------------------------------------------- 1 | // This file configures the development web server 2 | // which supports hot reloading and synchronized testing. 3 | 4 | // Require Browsersync along with webpack and middleware for it 5 | import browserSync from 'browser-sync'; 6 | // Required for react-router browserHistory 7 | // see https://github.com/BrowserSync/browser-sync/issues/204#issuecomment-102623643 8 | import historyApiFallback from 'connect-history-api-fallback'; 9 | import webpack from 'webpack'; 10 | import webpackDevMiddleware from 'webpack-dev-middleware'; 11 | import webpackHotMiddleware from 'webpack-hot-middleware'; 12 | import config from '../webpack.config.dev'; 13 | 14 | const bundler = webpack(config); 15 | 16 | // Run Browsersync and use middleware for Hot Module Replacement 17 | browserSync({ 18 | port: 3000, 19 | ui: { 20 | port: 3001 21 | }, 22 | server: { 23 | baseDir: 'src', 24 | 25 | middleware: [ 26 | historyApiFallback(), 27 | 28 | webpackDevMiddleware(bundler, { 29 | // Dev middleware can't access config, so we provide publicPath 30 | publicPath: config.output.publicPath, 31 | 32 | // These settings suppress noisy webpack output so only errors are displayed to the console. 33 | noInfo: false, 34 | quiet: false, 35 | stats: { 36 | assets: false, 37 | colors: true, 38 | version: false, 39 | hash: false, 40 | timings: false, 41 | chunks: false, 42 | chunkModules: false 43 | }, 44 | 45 | // for other settings see 46 | // http://webpack.github.io/docs/webpack-dev-middleware.html 47 | }), 48 | 49 | // bundler should be the same as above 50 | webpackHotMiddleware(bundler) 51 | ] 52 | }, 53 | 54 | // no need to watch '*.js' here, webpack will take care of it for us, 55 | // including full page reloads if HMR won't work 56 | files: [ 57 | 'src/*.html' 58 | ] 59 | }); 60 | -------------------------------------------------------------------------------- /tools/startMessage.js: -------------------------------------------------------------------------------- 1 | import {chalkSuccess} from './chalkConfig'; 2 | 3 | /* eslint-disable no-console */ 4 | 5 | console.log(chalkSuccess('Starting app in dev mode...')); 6 | -------------------------------------------------------------------------------- /tools/testSetup.js: -------------------------------------------------------------------------------- 1 | // Tests are placed alongside files under test. 2 | // This file does the following: 3 | // 1. Sets the environment to 'test' so that 4 | // dev-specific babel config in .babelrc doesn't run. 5 | // 2. Disables Webpack-specific features that Mocha doesn't understand. 6 | // 3. Registers babel for transpiling our code for testing. 7 | 8 | // This assures the .babelrc dev config (which includes 9 | // hot module reloading code) doesn't apply for tests. 10 | // Setting NODE_ENV to test instead of production because setting it to production will suppress error messaging 11 | // and propType validation warnings. 12 | process.env.NODE_ENV = 'test'; 13 | 14 | // Disable webpack-specific features for tests since 15 | // Mocha doesn't know what to do with them. 16 | ['.css', '.scss', '.png', '.jpg'].forEach(ext => { 17 | require.extensions[ext] = () => null; 18 | }); 19 | 20 | // Register babel so that it will transpile ES6 to ES5 21 | // before our tests run. 22 | require('babel-register')(); 23 | -------------------------------------------------------------------------------- /webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | import webpack from 'webpack'; 2 | import path from 'path'; 3 | import HtmlWebpackPlugin from 'html-webpack-plugin'; 4 | import autoprefixer from 'autoprefixer'; 5 | 6 | export default { 7 | debug: true, 8 | devtool: 'cheap-module-eval-source-map', // more info:https://webpack.github.io/docs/build-performance.html#sourcemaps and https://webpack.github.io/docs/configuration.html#devtool 9 | noInfo: true, // set to false to see a list of every file being bundled. 10 | entry: [ 11 | // must be first entry to properly set public path 12 | './src/webpack-public-path', 13 | 'webpack-hot-middleware/client?reload=true', 14 | './src/index' 15 | ], 16 | target: 'web', // necessary per https://webpack.github.io/docs/testing.html#compile-and-test 17 | output: { 18 | path: `${__dirname}/src`, // Note: Physical files are only output by the production build task `npm run build`. 19 | filename: 'bundle.js' 20 | }, 21 | plugins: [ 22 | new webpack.DefinePlugin({ 23 | 'process.env.NODE_ENV': JSON.stringify('development'), // Tells React to build in either dev or prod modes. https://facebook.github.io/react/downloads.html (See bottom) 24 | __DEV__: true 25 | }), 26 | new webpack.HotModuleReplacementPlugin(), 27 | new webpack.NoErrorsPlugin(), 28 | new HtmlWebpackPlugin({ // Create HTML file that includes references to bundled CSS and JS. 29 | template: 'src/index.ejs', 30 | minify: { 31 | removeComments: true, 32 | collapseWhitespace: true 33 | }, 34 | inject: true 35 | }) 36 | ], 37 | module: { 38 | loaders: [ 39 | {test: /\.js$/, include: path.join(__dirname, 'src'), loaders: ['babel']}, 40 | {test: /\.eot(\?v=\d+.\d+.\d+)?$/, loader: 'file'}, 41 | {test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/, loader: "url?limit=10000&mimetype=application/font-woff"}, 42 | {test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, loader: 'url?limit=10000&mimetype=application/octet-stream'}, 43 | {test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, loader: 'url?limit=10000&mimetype=image/svg+xml'}, 44 | {test: /\.(jpe?g|png|gif)$/i, loaders: ['file']}, 45 | {test: /\.ico$/, loader: 'file?name=[name].[ext]'}, 46 | {test: /(\.css|\.scss)$/, loaders: ['style', 'css?sourceMap', 'postcss', 'sass?sourceMap']} 47 | ] 48 | }, 49 | postcss: ()=> [autoprefixer] 50 | }; 51 | -------------------------------------------------------------------------------- /webpack.config.prod.js: -------------------------------------------------------------------------------- 1 | // For info about this file refer to webpack and webpack-hot-middleware documentation 2 | // For info on how we're generating bundles with hashed filenames for cache busting: https://medium.com/@okonetchnikov/long-term-caching-of-static-assets-with-webpack-1ecb139adb95#.w99i89nsz 3 | import webpack from 'webpack'; 4 | import path from 'path'; 5 | import ExtractTextPlugin from 'extract-text-webpack-plugin'; 6 | import WebpackMd5Hash from 'webpack-md5-hash'; 7 | import HtmlWebpackPlugin from 'html-webpack-plugin'; 8 | import autoprefixer from 'autoprefixer'; 9 | 10 | const GLOBALS = { 11 | 'process.env.NODE_ENV': JSON.stringify('production'), 12 | __DEV__: false 13 | }; 14 | 15 | export default { 16 | debug: true, 17 | devtool: 'source-map', // more info:https://webpack.github.io/docs/build-performance.html#sourcemaps and https://webpack.github.io/docs/configuration.html#devtool 18 | noInfo: true, // set to false to see a list of every file being bundled. 19 | entry: './src/index', 20 | target: 'web', // necessary per https://webpack.github.io/docs/testing.html#compile-and-test 21 | output: { 22 | path: `${__dirname}/dist`, 23 | publicPath: '/', 24 | filename: '[name].[chunkhash].js' 25 | }, 26 | plugins: [ 27 | // Hash the files using MD5 so that their names change when the content changes. 28 | new WebpackMd5Hash(), 29 | 30 | // Optimize the order that items are bundled. This assures the hash is deterministic. 31 | new webpack.optimize.OccurenceOrderPlugin(), 32 | 33 | // Tells React to build in prod mode. https://facebook.github.io/react/downloads.html 34 | new webpack.DefinePlugin(GLOBALS), 35 | 36 | // Generate an external css file with a hash in the filename 37 | new ExtractTextPlugin('[name].[contenthash].css'), 38 | 39 | // Generate HTML file that contains references to generated bundles. See here for how this works: https://github.com/ampedandwired/html-webpack-plugin#basic-usage 40 | new HtmlWebpackPlugin({ 41 | template: 'src/index.ejs', 42 | minify: { 43 | removeComments: true, 44 | collapseWhitespace: true, 45 | removeRedundantAttributes: true, 46 | useShortDoctype: true, 47 | removeEmptyAttributes: true, 48 | removeStyleLinkTypeAttributes: true, 49 | keepClosingSlash: true, 50 | minifyJS: true, 51 | minifyCSS: true, 52 | minifyURLs: true 53 | }, 54 | inject: true, 55 | // Note that you can add custom options here if you need to handle other custom logic in index.html 56 | // To track JavaScript errors via TrackJS, sign up for a free trial at TrackJS.com and enter your token below. 57 | trackJSToken: '' 58 | }), 59 | 60 | // Eliminate duplicate packages when generating bundle 61 | new webpack.optimize.DedupePlugin(), 62 | 63 | // Minify JS 64 | new webpack.optimize.UglifyJsPlugin() 65 | ], 66 | module: { 67 | loaders: [ 68 | {test: /\.js$/, include: path.join(__dirname, 'src'), loaders: ['babel']}, 69 | {test: /\.eot(\?v=\d+.\d+.\d+)?$/, loader: 'file'}, 70 | {test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/, loader: "url?limit=10000&mimetype=application/font-woff"}, 71 | {test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, loader: 'url?limit=10000&mimetype=application/octet-stream'}, 72 | {test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, loader: 'url?limit=10000&mimetype=image/svg+xml'}, 73 | {test: /\.(jpe?g|png|gif)$/i, loaders: ['file']}, 74 | {test: /\.ico$/, loader: 'file?name=[name].[ext]'}, 75 | { 76 | test: /(\.css|\.scss)$/, 77 | loader: ExtractTextPlugin.extract('css?sourceMap!postcss!sass?sourceMap') 78 | } 79 | ] 80 | }, 81 | postcss: ()=> [autoprefixer] 82 | }; 83 | --------------------------------------------------------------------------------