├── .eslintignore
├── .travis.yml
├── src
├── app
│ ├── views
│ │ ├── layouts
│ │ │ ├── index.js
│ │ │ ├── css.js
│ │ │ └── app.js
│ │ ├── propTypes
│ │ │ ├── index.js
│ │ │ └── productShape.js
│ │ ├── pages
│ │ │ ├── myAccount.js
│ │ │ ├── home.js
│ │ │ ├── index.js
│ │ │ ├── login.js
│ │ │ ├── cart.js
│ │ │ ├── productList.js
│ │ │ └── productDetails.js
│ │ └── enhancers
│ │ │ ├── index.js
│ │ │ ├── fetchBefore.js
│ │ │ ├── lazyLoad.js
│ │ │ └── withAuthentication.js
│ ├── utilities
│ │ ├── index.js
│ │ └── dictionary.js
│ ├── state
│ │ ├── ducks
│ │ │ ├── busy
│ │ │ │ ├── index.js
│ │ │ │ ├── utils.js
│ │ │ │ ├── reducers.js
│ │ │ │ └── tests.js
│ │ │ ├── cart
│ │ │ │ ├── selectors.js
│ │ │ │ ├── types.js
│ │ │ │ ├── operations.js
│ │ │ │ ├── index.js
│ │ │ │ ├── utils.js
│ │ │ │ ├── actions.js
│ │ │ │ ├── reducers.js
│ │ │ │ └── tests.js
│ │ │ ├── product
│ │ │ │ ├── operations.js
│ │ │ │ ├── index.js
│ │ │ │ ├── types.js
│ │ │ │ ├── actions.js
│ │ │ │ ├── reducers.js
│ │ │ │ └── tests.js
│ │ │ ├── index.js
│ │ │ └── session
│ │ │ │ ├── index.js
│ │ │ │ ├── operations.js
│ │ │ │ ├── types.js
│ │ │ │ ├── actions.js
│ │ │ │ ├── reducers.js
│ │ │ │ └── tests.js
│ │ ├── utils
│ │ │ ├── index.js
│ │ │ ├── createReducer.js
│ │ │ └── fetch.js
│ │ ├── middlewares
│ │ │ ├── index.js
│ │ │ ├── apiService.js
│ │ │ └── logger.js
│ │ └── store.js
│ └── routes
│ │ └── index.js
├── client
│ └── index.js
└── server
│ ├── apiRoutes.js
│ ├── apiData.json
│ └── index.js
├── .gitignore
├── dist
└── favicon.ico
├── mochaSetup.js
├── .babelrc
├── nodemon.json
├── .eslintrc
├── runner.js
├── README.md
├── package.json
└── webpack.config.js
/.eslintignore:
--------------------------------------------------------------------------------
1 | dist/
2 | node_modules/
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - "6"
4 | script: npm run ci
--------------------------------------------------------------------------------
/src/app/views/layouts/index.js:
--------------------------------------------------------------------------------
1 | export { default as App } from "./app";
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist/*.js
3 | dist/*.js.map
4 | dist/*.css
5 | dist/*.css.map
--------------------------------------------------------------------------------
/src/app/utilities/index.js:
--------------------------------------------------------------------------------
1 | export { default as dictionary } from "./dictionary";
2 |
--------------------------------------------------------------------------------
/src/app/views/propTypes/index.js:
--------------------------------------------------------------------------------
1 | export { default as productShape } from "./productShape";
2 |
--------------------------------------------------------------------------------
/src/app/state/ducks/busy/index.js:
--------------------------------------------------------------------------------
1 | import reducer from "./reducers";
2 |
3 | export default reducer;
4 |
--------------------------------------------------------------------------------
/dist/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FortechRomania/react-redux-complete-example/HEAD/dist/favicon.ico
--------------------------------------------------------------------------------
/src/app/utilities/dictionary.js:
--------------------------------------------------------------------------------
1 | export default {
2 | title: "Welcome to the e-commerce example project",
3 | };
4 |
--------------------------------------------------------------------------------
/src/app/views/pages/myAccount.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export default ( ) => (
My Account Page
);
4 |
--------------------------------------------------------------------------------
/mochaSetup.js:
--------------------------------------------------------------------------------
1 | require( "babel-register" )( {
2 | presets: [ "env" ],
3 | plugins: [ "dynamic-import-node" ],
4 | } );
5 |
--------------------------------------------------------------------------------
/src/app/state/utils/index.js:
--------------------------------------------------------------------------------
1 | export { default as createReducer } from "./createReducer";
2 | export { default as fetch } from "./fetch";
3 |
--------------------------------------------------------------------------------
/src/app/state/middlewares/index.js:
--------------------------------------------------------------------------------
1 | export { default as apiService } from "./apiService";
2 | export { default as createLogger } from "./logger";
3 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [ "env", { "modules": false } ],
4 | "react"
5 | ],
6 | "plugins": [ "syntax-dynamic-import" ]
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/state/ducks/cart/selectors.js:
--------------------------------------------------------------------------------
1 | export function getCartItemQuantity( cart, id ) {
2 | return cart.find( item => item.product.id === id ).quantity;
3 | }
4 |
--------------------------------------------------------------------------------
/src/app/state/ducks/product/operations.js:
--------------------------------------------------------------------------------
1 | import { fetchDetails, fetchList } from "./actions";
2 |
3 | export {
4 | fetchDetails,
5 | fetchList,
6 | };
7 |
--------------------------------------------------------------------------------
/src/app/views/pages/home.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { dictionary } from "../../utilities";
3 |
4 | export default ( ) => ( { dictionary.title }
);
5 |
--------------------------------------------------------------------------------
/src/app/views/enhancers/index.js:
--------------------------------------------------------------------------------
1 | export { default as fetchBefore } from "./fetchBefore";
2 | export { default as lazyLoad } from "./lazyLoad";
3 | export { default as withAuthentication } from "./withAuthentication";
4 |
--------------------------------------------------------------------------------
/src/app/state/ducks/index.js:
--------------------------------------------------------------------------------
1 | export { default as busy } from "./busy";
2 | export { default as cart } from "./cart";
3 | export { default as product } from "./product";
4 | export { default as session } from "./session";
5 |
--------------------------------------------------------------------------------
/src/app/state/ducks/product/index.js:
--------------------------------------------------------------------------------
1 | import reducer from "./reducers";
2 |
3 | import * as productOperations from "./operations";
4 |
5 | export {
6 | productOperations,
7 | };
8 |
9 | export default reducer;
10 |
--------------------------------------------------------------------------------
/src/app/state/ducks/session/index.js:
--------------------------------------------------------------------------------
1 | import reducer from "./reducers";
2 |
3 | import * as sessionOperations from "./operations";
4 |
5 | export {
6 | sessionOperations,
7 | };
8 |
9 | export default reducer;
10 |
--------------------------------------------------------------------------------
/src/app/state/ducks/cart/types.js:
--------------------------------------------------------------------------------
1 | export const SET_CART = "cart/SET";
2 | export const ADD = "cart/ADD";
3 | export const CHANGE_QUANTITY = "cart/CHANGE_QUANTITY";
4 | export const REMOVE = "cart/REMOVE";
5 | export const CLEAR = "cart/CLEAR";
6 |
--------------------------------------------------------------------------------
/src/app/state/ducks/session/operations.js:
--------------------------------------------------------------------------------
1 | import { login, logout, initializeSession, setRedirectAfterLogin } from "./actions";
2 |
3 | export {
4 | login,
5 | logout,
6 | initializeSession,
7 | setRedirectAfterLogin,
8 | };
9 |
--------------------------------------------------------------------------------
/src/app/state/utils/createReducer.js:
--------------------------------------------------------------------------------
1 | export default ( initialState ) => ( reducerMap ) => ( state = initialState, action ) => {
2 | const reducer = reducerMap[ action.type ];
3 | return reducer ? reducer( state, action ) : state;
4 | };
5 |
--------------------------------------------------------------------------------
/src/app/views/pages/index.js:
--------------------------------------------------------------------------------
1 | export { default as Home } from "./home";
2 | export { default as Login } from "./login";
3 | export { default as ProductDetails } from "./productDetails";
4 | export { default as ProductList } from "./productList";
5 |
--------------------------------------------------------------------------------
/src/app/state/ducks/cart/operations.js:
--------------------------------------------------------------------------------
1 | import { addToCart, changeQuantity, removeFromCart, clearCart, setCart } from "./actions";
2 |
3 | export {
4 | addToCart,
5 | changeQuantity,
6 | removeFromCart,
7 | clearCart,
8 | setCart,
9 | };
10 |
--------------------------------------------------------------------------------
/src/app/state/ducks/busy/utils.js:
--------------------------------------------------------------------------------
1 | export function actionShouldBlock( meta ) {
2 | return !meta || !meta.async || !meta.blocking;
3 | }
4 |
5 | export function actionFinished ( type ) {
6 | return type.includes( "_COMPLETED" ) || type.includes( "_FAILED" );
7 | }
8 |
--------------------------------------------------------------------------------
/nodemon.json:
--------------------------------------------------------------------------------
1 | {
2 | "restartable": "rs",
3 | "ignore": [
4 | ".git",
5 | "node_modules/**/node_modules"
6 | ],
7 | "verbose": true,
8 | "watch": [
9 | "src"
10 | ],
11 | "env": {
12 | "NODE_ENV": "development"
13 | },
14 | "ext": "js json"
15 | }
--------------------------------------------------------------------------------
/src/app/views/enhancers/fetchBefore.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const fetchBefore = ( dispatch, Component ) => ( matchProps ) => {
4 | dispatch( Component.prefetch( ) );
5 | return ( );
6 | };
7 |
8 | export default fetchBefore;
9 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "fortech-react",
3 | "env": {
4 | "browser": true,
5 | "mocha": true
6 | },
7 | "rules": {
8 | "prefer-promise-reject-errors": 1,
9 | "react/forbid-prop-types": 1,
10 | "react/no-typos": 0
11 | }
12 | }
--------------------------------------------------------------------------------
/src/app/state/ducks/cart/index.js:
--------------------------------------------------------------------------------
1 | import reducer from "./reducers";
2 |
3 | import * as cartOperations from "./operations";
4 | import * as cartSelectors from "./selectors";
5 |
6 | export {
7 | cartOperations,
8 | cartSelectors,
9 | };
10 |
11 | export default reducer;
12 |
--------------------------------------------------------------------------------
/src/app/state/ducks/cart/utils.js:
--------------------------------------------------------------------------------
1 | export function productPositionInCart( cart, product ) {
2 | return cart.map( item => item.product.id ).indexOf( product.id );
3 | }
4 |
5 | export function newCartItem( product, quantity ) {
6 | return {
7 | product,
8 | quantity,
9 | };
10 | }
11 |
--------------------------------------------------------------------------------
/src/app/views/propTypes/productShape.js:
--------------------------------------------------------------------------------
1 | import PropTypes from "prop-types";
2 |
3 | const { number, shape, string } = PropTypes;
4 |
5 | export default shape( {
6 | id: number.isRequired,
7 | name: string.isRequired,
8 | permalink: string.isRequired,
9 | price: number.isRequired,
10 | stock: number.isRequired,
11 | } );
12 |
--------------------------------------------------------------------------------
/src/app/state/ducks/session/types.js:
--------------------------------------------------------------------------------
1 | export const LOGIN = "session/LOGIN";
2 | export const LOGIN_COMPLETED = "session/LOGIN_COMPLETED";
3 | export const LOGIN_FAILED = "session/LOGIN_FAILED";
4 | export const LOGOUT = "session/LOGOUT";
5 | export const INITIALIZE = "session/INITIALIZE_SESSION";
6 | export const SET_REDIRECT_AFTER_LOGIN = "session/SET_REDIRECT_AFTER_LOGIN";
7 |
--------------------------------------------------------------------------------
/src/app/state/ducks/busy/reducers.js:
--------------------------------------------------------------------------------
1 | import * as utils from "./utils";
2 |
3 | const busyReducer = ( state = 0, action ) => {
4 | if ( utils.actionShouldBlock( action.meta ) ) {
5 | return state;
6 | }
7 | if ( utils.actionFinished( action.type ) ) {
8 | return state - 1;
9 | }
10 | return state + 1;
11 | };
12 |
13 | export default busyReducer;
14 |
--------------------------------------------------------------------------------
/src/app/state/ducks/product/types.js:
--------------------------------------------------------------------------------
1 | export const FETCH_DETAILS = "product/FETCH_DETAILS";
2 | export const FETCH_DETAILS_COMPLETED = "product/FETCH_DETAILS_COMPLETED";
3 | export const FETCH_DETAILS_FAILED = "product/FETCH_DETAILS_FAILED";
4 |
5 | export const FETCH_LIST = "product/FETCH_LIST";
6 | export const FETCH_LIST_COMPLETED = "product/FETCH_LIST_COMPLETED";
7 | export const FETCH_LIST_FAILED = "product/FETCH_LIST_FAILED";
8 |
--------------------------------------------------------------------------------
/src/app/state/ducks/session/actions.js:
--------------------------------------------------------------------------------
1 | import * as types from "./types";
2 |
3 | export const login = ( ) => ( {
4 | type: types.LOGIN,
5 | } );
6 |
7 | export const logout = ( ) => ( {
8 | type: types.LOGOUT,
9 | } );
10 |
11 | export const initializeSession = ( ) => ( {
12 | type: types.INITIALIZE,
13 | } );
14 |
15 | export const setRedirectAfterLogin = ( ) => ( {
16 | type: types.SET_REDIRECT_AFTER_LOGIN,
17 | } );
18 |
--------------------------------------------------------------------------------
/runner.js:
--------------------------------------------------------------------------------
1 | require( "babel-register" )( {
2 | presets: [ "env" ],
3 | plugins: [
4 | "dynamic-import-node",
5 | [
6 | "styled-components",
7 | {
8 | ssr: true,
9 | displayName: true,
10 | preprocess: false,
11 | },
12 | ],
13 | ],
14 | } );
15 |
16 | const path = process.argv[ 2 ];
17 |
18 | require( path ); // eslint-disable-line import/no-dynamic-require
19 |
--------------------------------------------------------------------------------
/src/app/views/pages/login.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export default ( ) => (
4 |
5 |
Login
6 |
10 |
11 |
15 |
16 |
17 | );
18 |
--------------------------------------------------------------------------------
/src/app/state/ducks/product/actions.js:
--------------------------------------------------------------------------------
1 | import * as types from "./types";
2 |
3 | export const fetchDetails = ( permalink ) => ( {
4 | type: types.FETCH_DETAILS,
5 | meta: {
6 | async: true,
7 | blocking: true,
8 | path: `/products/${ permalink }`,
9 | method: "GET",
10 | },
11 | } );
12 |
13 | export const fetchList = ( ) => ( {
14 | type: types.FETCH_LIST,
15 | meta: {
16 | async: true,
17 | blocking: true,
18 | path: "/products",
19 | method: "GET",
20 | },
21 | } );
22 |
--------------------------------------------------------------------------------
/src/app/state/store.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware, combineReducers } from "redux";
2 | import thunkMiddleware from "redux-thunk";
3 | import * as reducers from "./ducks";
4 | import { apiService, createLogger } from "./middlewares";
5 |
6 | export default function configureStore( initialState ) {
7 | const rootReducer = combineReducers( reducers );
8 |
9 | return createStore(
10 | rootReducer,
11 | initialState,
12 | applyMiddleware(
13 | apiService,
14 | thunkMiddleware,
15 | createLogger( true ),
16 | ),
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/app/views/layouts/css.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-expressions */
2 | import { injectGlobal } from "styled-components";
3 |
4 | export default () => {
5 | injectGlobal`
6 | body {
7 | background-color: levander;
8 | color: navy-blue;
9 | div {
10 | padding: 4px;
11 | }
12 |
13 | a {
14 | padding: 0 10px;
15 | text-decoration: none;
16 | }
17 |
18 | a:hover {
19 | color: blue;
20 | }
21 | }
22 | `;
23 |
24 | return null;
25 | };
26 |
--------------------------------------------------------------------------------
/src/app/views/enhancers/lazyLoad.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import { object } from "prop-types";
3 |
4 | class LazyLoad extends Component {
5 | componentWillMount() {
6 | this.props.load.then( comp => {
7 | this.comp = comp;
8 | this.forceUpdate();
9 | } );
10 | }
11 | render() {
12 | return this.comp ? : null;
13 | }
14 | }
15 |
16 | LazyLoad.propTypes = {
17 | load: object, // eslint-disable-line
18 | };
19 |
20 | export default ( dynamicImport ) => ( ) => (
21 |
22 | );
23 |
--------------------------------------------------------------------------------
/src/client/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { render } from "react-dom";
4 | import { BrowserRouter as Router } from "react-router-dom";
5 | import { Provider as ReduxProvider } from "react-redux";
6 |
7 | import App from "../app/views/layouts/app";
8 | import configureStore from "../app/state/store";
9 |
10 | const reduxStore = configureStore( window.REDUX_INITIAL_DATA );
11 |
12 | const RootHtml = ( ) => (
13 |
14 |
15 |
16 |
17 |
18 | );
19 |
20 | render( , document.getElementById( "react-root" ) );
21 |
--------------------------------------------------------------------------------
/src/app/state/ducks/product/reducers.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from "redux";
2 | import * as types from "./types";
3 | import { createReducer } from "../../utils";
4 |
5 | /* State shape
6 | {
7 | details: product,
8 | list: [ product ],
9 | }
10 | */
11 |
12 | const detailsReducer = createReducer( null )( {
13 | [ types.FETCH_DETAILS_COMPLETED ]: ( state, action ) => action.payload.product,
14 | } );
15 |
16 | const listReducer = createReducer( [ ] )( {
17 | [ types.FETCH_LIST_COMPLETED ]: ( state, action ) => action.payload.products,
18 | } );
19 |
20 | export default combineReducers( {
21 | details: detailsReducer,
22 | list: listReducer,
23 | } );
24 |
--------------------------------------------------------------------------------
/src/app/state/ducks/session/reducers.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from "redux";
2 | import * as types from "./types";
3 | import { createReducer } from "../../utils";
4 |
5 | /* State shape
6 | {
7 | isAuthenticated: bool,
8 | redirectAfterLogin: string
9 | }
10 | */
11 |
12 | const authReducer = createReducer( false )( {
13 | [ types.LOGIN ]: ( ) => true,
14 | [ types.LOGOUT ]: ( ) => false,
15 | } );
16 |
17 | const redirectAfterLoginReducer = createReducer( null )( {
18 | [ types.SET_REDIRECT_AFTER_LOGIN ]: ( state, action ) => action.payload.redirectUrl,
19 | } );
20 |
21 | export default combineReducers( {
22 | isAuthenticated: authReducer,
23 | redirectAfterLogin: redirectAfterLoginReducer,
24 | } );
25 |
--------------------------------------------------------------------------------
/src/app/views/layouts/app.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Link, Route } from "react-router-dom";
3 | import routes from "../../routes";
4 | import Styles from "./css";
5 |
6 | const App = ( ) => (
7 |
8 |
9 | Home
10 | Products
11 | Cart
12 | My Account
13 |
14 |
15 | { routes.map( route => (
16 |
17 | ) ) }
18 |
19 |
22 |
23 |
24 |
25 | );
26 |
27 | export default App;
28 |
--------------------------------------------------------------------------------
/src/app/state/ducks/cart/actions.js:
--------------------------------------------------------------------------------
1 | import * as types from "./types";
2 |
3 | export const addToCart = ( product, quantity ) => ( {
4 | type: types.ADD,
5 | payload: {
6 | product,
7 | quantity,
8 | },
9 | } );
10 |
11 | export const changeQuantity = ( product, quantity ) => ( {
12 | type: types.CHANGE_QUANTITY,
13 | payload: {
14 | product,
15 | quantity,
16 | },
17 | } );
18 |
19 | export const removeFromCart = ( index ) => ( {
20 | type: types.REMOVE,
21 | payload: {
22 | index,
23 | },
24 | } );
25 |
26 | export const clearCart = ( ) => ( {
27 | type: types.CLEAR,
28 | } );
29 |
30 | export const setCart = ( cart ) => ( {
31 | type: types.SET_CART,
32 | payload: {
33 | cart,
34 | },
35 | } );
36 |
--------------------------------------------------------------------------------
/src/app/views/enhancers/withAuthentication.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 | import { connect } from "react-redux";
4 | import { Redirect } from "react-router-dom";
5 |
6 | export default function withAuthentication( WrappedComponent ) {
7 | const WithAuthentication = ( props ) => {
8 | if ( !props.isAuthenticated ) {
9 | return ;
10 | }
11 |
12 | return ( );
13 | };
14 |
15 | const { bool } = PropTypes;
16 | WithAuthentication.propTypes = {
17 | isAuthenticated: bool.isRequired,
18 | };
19 |
20 | const mapStateToProps = ( state ) => ( {
21 | isAuthenticated: state.session.isAuthenticated,
22 | } );
23 |
24 | return connect( mapStateToProps )( WithAuthentication );
25 | }
26 |
--------------------------------------------------------------------------------
/src/app/state/utils/fetch.js:
--------------------------------------------------------------------------------
1 | import isomorphicFetch from "isomorphic-fetch";
2 |
3 | export default ( url, method, body ) => {
4 | const options = {
5 | method,
6 | headers: requestHeaders( ),
7 | body: method !== "GET" ? JSON.stringify( body ) : null,
8 | };
9 |
10 | return isomorphicFetch( url, options )
11 | .then( res => parseStatus( res.status, res.json() ) );
12 | };
13 |
14 | function parseStatus( status, res ) {
15 | return new Promise( ( resolve, reject ) => {
16 | if ( status >= 200 && status < 300 ) {
17 | res.then( response => resolve( response ) );
18 | } else {
19 | res.then( response => reject( { status, response } ) );
20 | }
21 | } );
22 | }
23 |
24 | function requestHeaders( ) {
25 | return {
26 | Accept: "application/json",
27 | "Content-Type": "application/json",
28 | };
29 | }
30 |
--------------------------------------------------------------------------------
/src/app/state/ducks/session/tests.js:
--------------------------------------------------------------------------------
1 | import expect from "expect.js";
2 | import reducer from "./reducers";
3 | import * as types from "./types";
4 |
5 | /* eslint-disable func-names */
6 | describe( "session reducer", function( ) {
7 | describe( "login", function( ) {
8 | const action = {
9 | type: types.LOGIN,
10 | };
11 |
12 | const initialState = {
13 | isAuthenticated: false,
14 | redirectAfterLogin: "/products",
15 | };
16 |
17 | const result = reducer( initialState, action );
18 |
19 | it( "should authenticate the user", function( ) {
20 | expect( result.isAuthenticated ).to.be( true );
21 | } );
22 |
23 | it( "should not change the redirect after login url", function( ) {
24 | expect( result.redirectAfterLogin ).to.be( initialState.redirectAfterLogin );
25 | } );
26 | } );
27 | } );
28 |
--------------------------------------------------------------------------------
/src/app/routes/index.js:
--------------------------------------------------------------------------------
1 | import { Home, Login, ProductDetails, ProductList } from "../views/pages";
2 | import { withAuthentication, lazyLoad } from "../views/enhancers";
3 |
4 | const routes = [
5 | {
6 | path: "/",
7 | component: Home,
8 | exact: true,
9 | },
10 | {
11 | path: "/products",
12 | component: ProductList,
13 | exact: true,
14 | },
15 | {
16 | path: "/products/:permalink",
17 | example: "/products/apple",
18 | component: ProductDetails,
19 | exact: true,
20 | },
21 | {
22 | path: "/cart",
23 | component: lazyLoad( ( ) => import( "../views/pages/cart" ) ),
24 | exact: true,
25 | },
26 | {
27 | path: "/myaccount",
28 | component: withAuthentication( lazyLoad( ( ) => import( "../views/pages/myAccount" ) ) ),
29 | exact: true,
30 | },
31 | {
32 | path: "/login",
33 | component: Login,
34 | exact: true,
35 | },
36 | ];
37 |
38 | export default routes;
39 |
--------------------------------------------------------------------------------
/src/app/state/ducks/product/tests.js:
--------------------------------------------------------------------------------
1 | import expect from "expect.js";
2 | import reducer from "./reducers";
3 | import * as types from "./types";
4 |
5 | /* eslint-disable func-names */
6 | describe( "product reducer", function( ) {
7 | describe( "fetch product", function( ) {
8 | const action = {
9 | type: types.FETCH_DETAILS_COMPLETED,
10 | payload: {
11 | product: {
12 | id: 1,
13 | name: "Test",
14 | permalink: "test",
15 | },
16 | },
17 | };
18 |
19 | const initialState = {
20 | list: [ ],
21 | details: null,
22 | };
23 |
24 | const result = reducer( initialState, action );
25 |
26 | it( "should set the product in the state", function( ) {
27 | expect( result.details.id ).to.be( 1 );
28 | expect( result.details.name ).to.be( "Test" );
29 | expect( result.details.permalink ).to.be( "test" );
30 | } );
31 | } );
32 | } );
33 |
--------------------------------------------------------------------------------
/src/app/views/pages/cart.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { connect } from "react-redux";
3 | import PropTypes from "prop-types";
4 | import styled from "styled-components";
5 |
6 | const CartItem = styled.div`
7 | margin: 10px;
8 |
9 | &:hover {
10 | background-color: grey;
11 | }
12 | `;
13 |
14 | const Cart = ( { cartItems } ) => {
15 | if ( cartItems.length === 0 ) {
16 | return ( You have no items in the cart
);
17 | }
18 | const items = cartItems.map( item => (
19 |
20 | { item.product.name } - { item.quantity } { item.quantity > 1 ? "items" : "item" }
21 | ) );
22 | return (
23 |
24 | { items }
25 |
26 | );
27 | };
28 |
29 | const { arrayOf, object } = PropTypes;
30 |
31 | Cart.propTypes = {
32 | cartItems: arrayOf( object ),
33 | };
34 |
35 | Cart.defaultProps = {
36 | cartItems: [ ],
37 | };
38 |
39 | const mapStateToProps = ( state ) => ( {
40 | cartItems: state.cart,
41 | } );
42 |
43 | export default connect( mapStateToProps, null )( Cart );
44 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # WIP - Complete react-redux example project
2 | An example project based on the folder structure proposed here: https://github.com/alexnm/re-ducks
3 |
4 | [](https://travis-ci.org/FortechRomania/react-redux-complete-example)
5 |
6 | ## Usage
7 |
8 | **Clone the repo**
9 | ```
10 | git clone git@github.com:FortechRomania/react-redux-complete-example.git
11 | ```
12 |
13 | **Install dependencies**
14 | ```
15 | npm i
16 | ```
17 | or with [yarn](https://yarnpkg.com/), which I highly recommend
18 | ```
19 | yarn
20 | ```
21 |
22 | **Run project**
23 | ```
24 | npm run compile
25 | npm run dev-server
26 | ```
27 | or both tasks in parallel in a single terminal
28 | ```
29 | npm start
30 | ```
31 |
32 | Access `localhost:7777` to see the magic.
33 |
34 | Running the tests
35 | ```
36 | npm run test
37 | ```
38 |
39 | Running eslint
40 | ```
41 | npm run linter
42 | ```
43 |
44 | ## Todos
45 | - [x] Ducks modular approach
46 | - [x] Server side rendering with prefetching
47 | - [x] Redux Dev Tools / HMR
48 | - [x] Styling Setup
49 | - [x] Codesplitting
50 |
--------------------------------------------------------------------------------
/src/server/apiRoutes.js:
--------------------------------------------------------------------------------
1 | import { Router } from "express";
2 | import data from "./apiData.json";
3 |
4 | const router = new Router();
5 |
6 | router.get( "/products", ( req, res ) => {
7 | setTimeout( ( ) => res.json( { products: data.products.map( productOverview ) } ), 500 );
8 | } );
9 |
10 | router.get( "/products/:permalink", ( req, res ) => {
11 | const product = data.products.find( p => p.permalink === req.params.permalink );
12 | setTimeout( ( ) => res.json( { product } ), 500 );
13 | } );
14 |
15 | router.post( "/login", ( req, res ) => {
16 | setTimeout( ( ) => {
17 | if ( req.body.username === "user42" && req.body.password === "secret" ) {
18 | return res.json( { success: true, token: "1111-2222-3333-4444" } );
19 | }
20 |
21 | return res.status( 401 ).json( { success: false, error: "Invalid credentials" } );
22 | }, 500 );
23 | } );
24 |
25 | function productOverview( product ) {
26 | return {
27 | id: product.id,
28 | name: product.name,
29 | permalink: product.permalink,
30 | price: product.price,
31 | imageUrl: product.imageUrl,
32 | stock: product.stock,
33 | };
34 | }
35 |
36 | export default router;
37 |
--------------------------------------------------------------------------------
/src/app/state/middlewares/apiService.js:
--------------------------------------------------------------------------------
1 | import { fetch } from "../utils";
2 |
3 | const baseUrl = typeof document === "undefined" ? "http://localhost:7777/api" : "/api";
4 |
5 | const apiService = ( ) => ( next ) => ( action ) => {
6 | const result = next( action );
7 | if ( !action.meta || !action.meta.async ) {
8 | return result;
9 | }
10 |
11 | const { path, method = "GET", body } = action.meta;
12 |
13 | if ( !path ) {
14 | throw new Error( `'path' not specified for async action ${ action.type }` );
15 | }
16 |
17 | const url = `${ baseUrl }${ path }`;
18 |
19 | return fetch( url, method, body ).then(
20 | res => handleResponse( res, action, next ),
21 | err => handleErrors( err, action, next ),
22 | );
23 | };
24 |
25 | export default apiService;
26 |
27 | function handleErrors( err, action, next ) {
28 | next( {
29 | type: `${ action.type }_FAILED`,
30 | payload: err,
31 | meta: action.meta,
32 | } );
33 |
34 | return Promise.reject( err );
35 | }
36 |
37 | function handleResponse( res, action, next ) {
38 | next( {
39 | type: `${ action.type }_COMPLETED`,
40 | payload: res,
41 | meta: action.meta,
42 | } );
43 |
44 | return res;
45 | }
46 |
--------------------------------------------------------------------------------
/src/app/views/pages/productList.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 | import { Link } from "react-router-dom";
4 | import { connect } from "react-redux";
5 | import { productOperations } from "../../state/ducks/product";
6 | import { productShape } from "../propTypes";
7 |
8 | class ProductList extends Component {
9 | componentDidMount( ) {
10 | if ( this.props.products.length === 0 ) {
11 | this.props.fetchList( );
12 | }
13 | }
14 |
15 | render( ) {
16 | const productList = this.props.products
17 | .map( p => { p.name } );
18 |
19 | return (
20 |
21 | { productList }
22 |
23 | );
24 | }
25 | }
26 |
27 | const { arrayOf, func } = PropTypes;
28 |
29 | ProductList.propTypes = {
30 | products: arrayOf( productShape ),
31 | fetchList: func.isRequired,
32 | };
33 |
34 | ProductList.defaultProps = {
35 | products: [ ],
36 | };
37 |
38 | ProductList.prefetch = productOperations.fetchList;
39 |
40 | const mapStateToProps = ( state ) => ( {
41 | products: state.product.list,
42 | } );
43 |
44 | const mapDispatchToProps = {
45 | fetchList: productOperations.fetchList,
46 | };
47 |
48 | export default connect( mapStateToProps, mapDispatchToProps )( ProductList );
49 |
--------------------------------------------------------------------------------
/src/server/apiData.json:
--------------------------------------------------------------------------------
1 | {
2 | "products": [
3 | {
4 | "id": 1,
5 | "name": "Banana",
6 | "permalink": "banana",
7 | "price": 12.99,
8 | "description": "Banana!",
9 | "stock": 100,
10 | "imageUrl": "/dist/images/banana.jpg"
11 | },
12 | {
13 | "id": 2,
14 | "name": "Apple",
15 | "permalink": "apple",
16 | "price": 9.99,
17 | "description": "Apple!",
18 | "stock": 100,
19 | "imageUrl": "/dist/images/apple.jpg"
20 | },
21 | {
22 | "id": 3,
23 | "name": "Orange",
24 | "permalink": "orange",
25 | "price": 2.99,
26 | "description": "Orange!",
27 | "stock": 100,
28 | "imageUrl": "/dist/images/orange.jpg"
29 | },
30 | {
31 | "id": 4,
32 | "name": "Watermelon",
33 | "permalink": "watermelon",
34 | "price": 10.49,
35 | "description": "Watermelon!",
36 | "stock": 100,
37 | "imageUrl": "/dist/images/watermelon.jpg"
38 | },
39 | {
40 | "id": 5,
41 | "name": "Plum",
42 | "permalink": "plum",
43 | "price": 12.99,
44 | "description": "Plum!",
45 | "stock": 100,
46 | "imageUrl": "/dist/images/plum.png"
47 | }
48 | ]
49 | }
--------------------------------------------------------------------------------
/src/app/state/ducks/cart/reducers.js:
--------------------------------------------------------------------------------
1 | import * as types from "./types";
2 | import * as utils from "./utils";
3 | import { createReducer } from "../../utils";
4 |
5 | /* State shape
6 | [
7 | {
8 | product,
9 | quantity,
10 | }
11 | ]
12 | */
13 |
14 | const initialState = [ ];
15 |
16 | const cartReducer = createReducer( initialState )( {
17 | [ types.ADD ]: ( state, action ) => {
18 | const { product, quantity } = action.payload;
19 | const index = utils.productPositionInCart( state, product );
20 | if ( index === -1 ) {
21 | return [ utils.newCartItem( product, quantity ), ...state ];
22 | }
23 |
24 | const currentItem = state[ index ];
25 | const updatedItem = Object.assign( { }, currentItem, { quantity: currentItem.quantity + quantity } );
26 | return [
27 | ...state.slice( 0, index ),
28 | updatedItem,
29 | ...state.slice( index + 1 ),
30 | ];
31 | },
32 | [ types.CHANGE_QUANTITY ]: ( state, action ) => {
33 | const { product, quantity } = action.payload;
34 | const index = utils.productPositionInCart( state, product );
35 |
36 | const updatedItem = Object.assign( { }, state[ index ], { quantity } );
37 | return [
38 | ...state.slice( 0, index ),
39 | updatedItem,
40 | ...state.slice( index + 1 ),
41 | ];
42 | },
43 | [ types.REMOVE ]: ( state, action ) => {
44 | const { product } = action.payload;
45 | const index = utils.productPositionInCart( state, product );
46 | return [
47 | ...state.slice( 0, index ),
48 | ...state.slice( index + 1 ),
49 | ];
50 | },
51 | [ types.CLEAR ]: ( ) => [ ],
52 | } );
53 |
54 | export default cartReducer;
55 |
--------------------------------------------------------------------------------
/src/app/state/middlewares/logger.js:
--------------------------------------------------------------------------------
1 | const REGULAR = [
2 | "background: blue",
3 | "color: white",
4 | ].join( ";" );
5 |
6 | const SUCCESS = [
7 | "background: green",
8 | "color: white",
9 | ].join( ";" );
10 |
11 | const STARTED = [
12 | "background: darkorange",
13 | "color: white",
14 | ].join( ";" );
15 |
16 | const FAILURE = [
17 | "background: red",
18 | "color: white",
19 | ].join( ";" );
20 |
21 | const createLogger = ( active = true ) => ( store ) => ( next ) => ( action ) => {
22 | if ( !active ) {
23 | return next( action );
24 | }
25 |
26 | const prevState = store.getState( );
27 | const result = next( action );
28 | const nextState = store.getState( );
29 | logGroupCollapsed( `%c ${ action.type } `, determineStyle( action ) );
30 | logInfo( "%cprev state", "color: darkorange", prevState );
31 | logInfo( "%caction payload", "color: blue", action.payload );
32 | logInfo( "%cnext state", "color: darkgreen", nextState );
33 | logGroupEnd( );
34 | return result;
35 | };
36 |
37 | export default createLogger;
38 |
39 | function logGroupCollapsed( ...args ) {
40 | const logFunction = typeof console.groupCollapsed === "function" ? console.groupCollapsed : console.info;
41 | logFunction( ...args );
42 | }
43 |
44 | function logGroupEnd( ...args ) {
45 | const logFunction = typeof console.groupEnd === "function" ? console.groupEnd : console.info;
46 | logFunction( ...args );
47 | }
48 |
49 | function logInfo( ...args ) {
50 | console.info( ...args );
51 | }
52 |
53 | function determineStyle( action ) {
54 | if ( !action.meta || !action.meta.async ) {
55 | return REGULAR;
56 | }
57 |
58 | if ( action.type.indexOf( "_COMPLETED" ) > -1 ) {
59 | return SUCCESS;
60 | }
61 |
62 | if ( action.type.indexOf( "_FAILED" ) > -1 ) {
63 | return FAILURE;
64 | }
65 |
66 | return STARTED;
67 | }
68 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-redux-complete-example",
3 | "version": "1.0.0",
4 | "description": "A react-redux example project based on the folder structure proposed here: https://github.com/alexnm/re-ducks",
5 | "main": "index.js",
6 | "repository": {
7 | "url": "https://github.com/FortechRomania/react-redux-complete-example",
8 | "type": "git"
9 | },
10 | "author": "alexnm ",
11 | "license": "MIT",
12 | "scripts": {
13 | "dev-server": "nodemon runner.js ./src/server",
14 | "compile": "webpack --progress --colors --watch",
15 | "start": "npm run dev-server & npm run compile",
16 | "build": "NODE_ENV=production webpack",
17 | "linter": "eslint src --quiet",
18 | "linter-with-warnings": "eslint src",
19 | "test": "mocha --require mochaSetup src/app --recursive",
20 | "ci": "npm run test & npm run linter"
21 | },
22 | "dependencies": {
23 | "isomorphic-fetch": "^2.2.1",
24 | "preact": "^8.1.0",
25 | "preact-compat": "^3.16.0",
26 | "prop-types": "^15.5.10",
27 | "react": "^16.0.0",
28 | "react-dom": "^16.0.1",
29 | "react-helmet": "^5.1.3",
30 | "react-redux": "^5.0.5",
31 | "react-router-dom": "^4.1.2",
32 | "redux": "^3.7.2",
33 | "redux-thunk": "^2.2.0",
34 | "styled-components": "^2.1.1"
35 | },
36 | "devDependencies": {
37 | "babel": "^6.5.2",
38 | "babel-core": "^6.25.0",
39 | "babel-eslint": "^7.1.1",
40 | "babel-loader": "^7.1.1",
41 | "babel-plugin-dynamic-import-node": "^1.0.2",
42 | "babel-plugin-styled-components": "^1.1.7",
43 | "babel-plugin-syntax-dynamic-import": "^6.18.0",
44 | "babel-preset-env": "^1.6.0",
45 | "babel-preset-react": "^6.16.0",
46 | "babel-register": "^6.18.0",
47 | "body-parser": "^1.17.2",
48 | "cookie-parser": "^1.4.3",
49 | "eslint": "^4.8.0",
50 | "eslint-config-fortech-react": "^1.0.1",
51 | "eslint-loader": "^1.9.0",
52 | "eslint-plugin-import": "^2.7.0",
53 | "eslint-plugin-jsx-a11y": "^6.0.2",
54 | "eslint-plugin-react": "^7.4.0",
55 | "expect.js": "^0.3.1",
56 | "express": "^4.15.3",
57 | "extract-text-webpack-plugin": "^3.0.0",
58 | "isomorphic-fetch": "^2.2.1",
59 | "mocha": "^3.4.2",
60 | "nodemon": "^1.11.0",
61 | "webpack": "^3.4.1",
62 | "webpack-bundle-analyzer": "^2.8.3"
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/app/views/pages/productDetails.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 | import { connect } from "react-redux";
4 | import { productOperations } from "../../state/ducks/product";
5 | import { cartOperations } from "../../state/ducks/cart";
6 | import { productShape } from "../propTypes";
7 |
8 | class ProductDetails extends Component {
9 | componentDidMount( ) {
10 | const { product, match, fetchProduct } = this.props;
11 | const loadedProductPermalink = product ? product.permalink : "";
12 | if ( match.params.permalink !== loadedProductPermalink ) {
13 | fetchProduct( match.params.permalink );
14 | }
15 | }
16 |
17 | componentWillReceiveProps( nextProps ) {
18 | if ( this.props.match.params.permalink !== nextProps.match.params.permalink ) {
19 | this.props.fetchProduct( nextProps.match.params.permalink );
20 | }
21 | }
22 |
23 | render( ) {
24 | const { product } = this.props;
25 | if ( !product ) {
26 | return false;
27 | }
28 |
29 | return (
30 |
31 |
{ product.name }
32 |
Price: ${ product.price }
33 |
Description: { product.description }
34 |
35 |
42 |
43 |
44 | );
45 | }
46 | }
47 |
48 | const { object, func } = PropTypes;
49 |
50 | ProductDetails.propTypes = {
51 | addToCart: func.isRequired,
52 | product: productShape,
53 | fetchProduct: func.isRequired,
54 | match: object.isRequired,
55 | };
56 |
57 | ProductDetails.prefetch = ( { params } ) => productOperations.fetchDetails( params.permalink );
58 |
59 | ProductDetails.defaultProps = {
60 | product: null,
61 | };
62 |
63 | const mapStateToProps = ( state ) => ( {
64 | product: state.product.details,
65 | } );
66 |
67 | const mapDispatchToProps = {
68 | fetchProduct: productOperations.fetchDetails,
69 | addToCart: cartOperations.addToCart,
70 | };
71 |
72 | export default connect( mapStateToProps, mapDispatchToProps )( ProductDetails );
73 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require( "path" );
2 | const webpack = require( "webpack" );
3 | const ExtractTextPlugin = require( "extract-text-webpack-plugin" );
4 | const BundleAnalyzerPlugin = require( "webpack-bundle-analyzer" ).BundleAnalyzerPlugin;
5 |
6 | const productionEnv = process.env.NODE_ENV === "production";
7 |
8 | const plugins = [
9 | new webpack.optimize.CommonsChunkPlugin( {
10 | name: "lib",
11 | minChunks: Infinity,
12 | filename: "lib.bundle.js",
13 | } ),
14 | new ExtractTextPlugin( {
15 | filename: "[name].bundle.css",
16 | allChunks: true,
17 | } ),
18 | new webpack.DefinePlugin( { "process.env.NODE_ENV": JSON.stringify( process.env.NODE_ENV ) } ),
19 | new BundleAnalyzerPlugin( ),
20 | ];
21 |
22 | if ( productionEnv ) {
23 | plugins.push(
24 | new webpack.optimize.OccurrenceOrderPlugin(),
25 | new webpack.LoaderOptionsPlugin( { minimize: true, debug: false } ),
26 | new webpack.optimize.UglifyJsPlugin( { sourcemap: true } ) );
27 | }
28 |
29 | module.exports = {
30 | context: path.resolve( __dirname, "src" ),
31 |
32 | devtool: productionEnv ? "source-map" : "cheap-module-source-map",
33 |
34 | entry: {
35 | app: "./client/index.js",
36 | lib: [ "react", "react-dom" ],
37 | },
38 |
39 | output: {
40 | path: path.resolve( __dirname, "dist" ),
41 | filename: "[name].bundle.js",
42 | },
43 |
44 | resolve: {
45 | alias: {
46 | // react: "preact-compat",
47 | // "react-dom": "preact-compat",
48 | },
49 | modules: [
50 | path.resolve( "./src" ),
51 | "node_modules",
52 | ],
53 | },
54 |
55 | module: {
56 | rules: [
57 | {
58 | test: /(\.jsx|\.js)$/,
59 | enforce: "pre",
60 | exclude: /node_modules/,
61 | use: [
62 | {
63 | loader: "eslint-loader",
64 | options: {
65 | failOnWarning: false,
66 | failOnError: true,
67 | quiet: true,
68 | },
69 | },
70 | ],
71 | },
72 | {
73 | test: /(\.jsx|\.js)$/,
74 | exclude: /node_modules/,
75 | use: "babel-loader",
76 | },
77 | ],
78 | },
79 |
80 | plugins,
81 | };
82 |
--------------------------------------------------------------------------------
/src/app/state/ducks/cart/tests.js:
--------------------------------------------------------------------------------
1 | import expect from "expect.js";
2 | import reducer from "./reducers";
3 | import * as types from "./types";
4 |
5 | const product = {
6 | id: 1,
7 | name: "Test",
8 | permalink: "test",
9 | };
10 |
11 | /* eslint-disable func-names */
12 | describe( "cart reducer", function( ) {
13 | describe( "add to cart", function( ) {
14 | const action = {
15 | type: types.ADD,
16 | payload: {
17 | product,
18 | quantity: 10,
19 | },
20 | };
21 |
22 | context( "empty cart", function( ) {
23 | const initialState = [ ];
24 |
25 | const result = reducer( initialState, action );
26 |
27 | it( "should add the product in the cart", function( ) {
28 | expect( result.length ).to.be( 1 );
29 | expect( result[ 0 ].product.id ).to.be( product.id );
30 | expect( result[ 0 ].quantity ).to.be( 10 );
31 | } );
32 | } );
33 |
34 | context( "cart has one item", function( ) {
35 | const initialState = [ {
36 | product: {
37 | id: 2,
38 | name: "Existing product",
39 | },
40 | quantity: 4,
41 | } ];
42 |
43 | const result = reducer( initialState, action );
44 |
45 | it( "should add the product in the cart", function( ) {
46 | expect( result.length ).to.be( 2 );
47 | } );
48 |
49 | it( "should add the product in the first position", function( ) {
50 | expect( result[ 0 ].product.id ).to.be( product.id );
51 | expect( result[ 0 ].quantity ).to.be( 10 );
52 | } );
53 | } );
54 |
55 | context( "cart has the same product already", function( ) {
56 | const initialState = [ {
57 | product: {
58 | id: 1,
59 | name: "Test",
60 | },
61 | quantity: 10,
62 | } ];
63 |
64 | const result = reducer( initialState, action );
65 |
66 | it( "should not add the same product in the cart", function( ) {
67 | expect( result.length ).to.be( 1 );
68 | } );
69 |
70 | it( "should increase the quantity", function( ) {
71 | expect( result[ 0 ].product.id ).to.be( product.id );
72 | expect( result[ 0 ].quantity ).to.be( 20 );
73 | } );
74 | } );
75 | } );
76 | } );
77 |
--------------------------------------------------------------------------------
/src/server/index.js:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import path from "path";
3 | import bodyParser from "body-parser";
4 | import cookieParser from "cookie-parser";
5 |
6 | import React from "react";
7 | import { renderToString } from "react-dom/server";
8 | import { ServerStyleSheet } from "styled-components";
9 | import { matchPath, StaticRouter } from "react-router-dom";
10 | import Helmet from "react-helmet";
11 | import { Provider as ReduxProvider } from "react-redux";
12 |
13 | import App from "../app/views/layouts/app";
14 | import apiRoutes from "./apiRoutes";
15 | import configureStore from "../app/state/store";
16 | import routes from "../app/routes";
17 |
18 | const app = express( );
19 |
20 | const DEFAULT_PORT = 7777;
21 | app.use( bodyParser.json( ) );
22 | app.use( cookieParser( ) );
23 | app.use( express.static( path.resolve( __dirname, "../../dist" ) ) );
24 | app.use( "/api", apiRoutes );
25 |
26 | app.use( ( req, res ) => {
27 | const reduxStore = configureStore( );
28 | const sheet = new ServerStyleSheet();
29 |
30 | reduxStore.dispatch( { type: "SERVER_READY" } ); // will be replaced later with a init session
31 |
32 | prefetchData( req.url, reduxStore.dispatch ).then( ( ) => {
33 | const head = Helmet.rewind( );
34 | const reduxState = reduxStore.getState( );
35 | const context = { };
36 | const jsx = (
37 |
38 |
42 |
43 |
44 |
45 | );
46 | const reactDom = renderToString( sheet.collectStyles( jsx ) );
47 |
48 | const styles = sheet.getStyleTags();
49 |
50 | res.writeHead( 200, { "Content-Type": "text/html" } );
51 | res.end( templateHtml( head, reactDom, reduxState, styles ) );
52 | } ).catch( err => console.log( err ) );
53 | } );
54 |
55 | function prefetchData( url, dispatch ) {
56 | const promises =
57 | routes
58 | .map( ( route ) => ( { route, match: matchPath( url, route ) } ) )
59 | .filter( ( { route, match } ) => match && route.component.prefetch )
60 | .map( ( { route, match } ) => dispatch( route.component.prefetch( match ) ) );
61 |
62 | return Promise.all( promises );
63 | }
64 |
65 | function templateHtml( head, reactDom, reduxState, styles ) {
66 | return `
67 |
68 |
69 |
70 | ${ head.title.toString( ) }
71 | ${ head.meta.toString( ) }
72 | ${ head.link.toString( ) }
73 | ${ styles }
74 |
75 |
76 |
77 |
78 | ${ reactDom }
79 |
80 |
83 |
84 |
85 |
86 |
87 |
88 | `;
89 | }
90 |
91 | app.listen( process.env.NODE_PORT || DEFAULT_PORT );
92 |
--------------------------------------------------------------------------------
/src/app/state/ducks/busy/tests.js:
--------------------------------------------------------------------------------
1 | import expect from "expect.js";
2 | import busyReducer from "./reducers";
3 |
4 | const noCallInProgress = 0;
5 | const oneCallInProgress = 1;
6 | const twoCallsInProgress = 2;
7 |
8 | const blocking = {
9 | type: "TEST",
10 | meta: {
11 | async: true,
12 | blocking: true,
13 | },
14 | };
15 |
16 | const blockingCompleted = {
17 | type: "TEST_COMPLETED",
18 | meta: {
19 | async: true,
20 | blocking: true,
21 | },
22 | };
23 |
24 | const blockingFailed = {
25 | type: "TEST_FAILED",
26 | meta: {
27 | async: true,
28 | blocking: true,
29 | },
30 | };
31 |
32 | const nonBlocking = {
33 | type: "TEST",
34 | meta: {
35 | async: true,
36 | blocking: false,
37 | },
38 | };
39 |
40 | const nonBlockingCompleted = {
41 | type: "TEST_COMPLETED",
42 | meta: {
43 | async: true,
44 | blocking: false,
45 | },
46 | };
47 |
48 | const nonBlockingFailed = {
49 | type: "TEST_FAILED",
50 | meta: {
51 | async: true,
52 | blocking: false,
53 | },
54 | };
55 |
56 | /* eslint-disable func-names */
57 | describe( "busy reducer", function( ) {
58 | describe( "initial action", function( ) {
59 | context( "on general action", function( ) {
60 | context( "no api call running", function( ) {
61 | const result = busyReducer( noCallInProgress, blocking );
62 |
63 | it( "should increment the busy state", function( ) {
64 | expect( result ).to.be( 1 );
65 | } );
66 | } );
67 |
68 | context( "another api call running", function( ) {
69 | const result = busyReducer( oneCallInProgress, blocking );
70 |
71 | it( "should increment the busy state", function( ) {
72 | expect( result ).to.be( 2 );
73 | } );
74 | } );
75 | } );
76 |
77 | context( "on non blocking action", function( ) {
78 | context( "no api call running", function( ) {
79 | const result = busyReducer( noCallInProgress, nonBlocking );
80 |
81 | it( "should not increment the busy state", function( ) {
82 | expect( result ).to.be( 0 );
83 | } );
84 | } );
85 |
86 | context( "another api call running", function( ) {
87 | const result = busyReducer( oneCallInProgress, nonBlocking );
88 |
89 | it( "should not increment the busy state", function( ) {
90 | expect( result ).to.be( 1 );
91 | } );
92 | } );
93 | } );
94 | } );
95 |
96 | describe( "completed action", function( ) {
97 | context( "on general action", function( ) {
98 | context( "no api call running", function( ) {
99 | const result = busyReducer( oneCallInProgress, blockingCompleted );
100 |
101 | it( "should increment the busy state", function( ) {
102 | expect( result ).to.be( 0 );
103 | } );
104 | } );
105 |
106 | context( "another api call running", function( ) {
107 | const result = busyReducer( twoCallsInProgress, blockingCompleted );
108 |
109 | it( "should increment the busy state", function( ) {
110 | expect( result ).to.be( 1 );
111 | } );
112 | } );
113 | } );
114 |
115 | context( "on general blocking action", function( ) {
116 | context( "no api call running", function( ) {
117 | const result = busyReducer( noCallInProgress, nonBlockingCompleted );
118 |
119 | it( "should not increment the busy state", function( ) {
120 | expect( result ).to.be( 0 );
121 | } );
122 | } );
123 |
124 | context( "another api call running", function( ) {
125 | const result = busyReducer( oneCallInProgress, nonBlockingCompleted );
126 |
127 | it( "should not increment the busy state", function( ) {
128 | expect( result ).to.be( 1 );
129 | } );
130 | } );
131 | } );
132 | } );
133 |
134 | describe( "failed action", function( ) {
135 | context( "on general action", function( ) {
136 | context( "no api call running", function( ) {
137 | const result = busyReducer( oneCallInProgress, blockingFailed );
138 |
139 | it( "should increment the busy state", function( ) {
140 | expect( result ).to.be( 0 );
141 | } );
142 | } );
143 |
144 | context( "another api call running", function( ) {
145 | const result = busyReducer( twoCallsInProgress, blockingFailed );
146 |
147 | it( "should increment the busy state", function( ) {
148 | expect( result ).to.be( 1 );
149 | } );
150 | } );
151 | } );
152 |
153 | context( "on non blocking action", function( ) {
154 | context( "no api call running", function( ) {
155 | const result = busyReducer( noCallInProgress, nonBlockingFailed );
156 |
157 | it( "should not increment the busy state", function( ) {
158 | expect( result ).to.be( 0 );
159 | } );
160 | } );
161 |
162 | context( "another api call running", function( ) {
163 | const result = busyReducer( oneCallInProgress, nonBlockingFailed );
164 |
165 | it( "should not increment the busy state", function( ) {
166 | expect( result ).to.be( 1 );
167 | } );
168 | } );
169 | } );
170 | } );
171 | } );
172 |
--------------------------------------------------------------------------------