├── .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 [](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 |
--------------------------------------------------------------------------------