├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── README.md ├── data ├── cart.json ├── products.json └── user.json ├── karma.conf.js ├── package.json ├── src ├── app.es6 ├── assets │ ├── cart-item-placeholder.jpg │ └── style.css ├── components │ ├── app.jsx │ ├── banner.jsx │ ├── cart.jsx │ ├── detail.jsx │ ├── html.jsx │ ├── item.jsx │ ├── login.jsx │ ├── payment.jsx │ ├── products.jsx │ └── profile.jsx ├── index.html ├── main.jsx ├── middleware │ └── renderView.jsx ├── server.js └── shared │ ├── cart-action-creators.es6 │ ├── cart-reducer.es6 │ ├── init-redux.es6 │ ├── products-action-creators.es6 │ ├── products-reducer.es6 │ └── sharedRoutes.jsx ├── test ├── .eslintrc.js ├── components │ ├── app.spec.jsx │ ├── cart.spec.jsx │ └── item.spec.jsx └── integration │ └── app.spec.jsx └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react"], 3 | "plugins": [ 4 | "transform-es2015-destructuring", 5 | "transform-es2015-parameters", 6 | "transform-object-rest-spread" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | indent_style = space 11 | indent_size = 2 12 | 13 | # Matches multiple files with brace expansion notation 14 | # Set default charset 15 | [*.{js}] 16 | charset = utf-8 17 | 18 | # Tab indentation (no size specified) 19 | [Makefile] 20 | indent_style = tab 21 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | webpack.config.js 2 | package.json 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": "airbnb", 3 | "plugins": [ 4 | "react", 5 | "jsx-a11y", 6 | "import" 7 | ], 8 | "rules": { 9 | "arrow-body-style": [0, "as-needed", { 10 | requireReturnForObjectLiteral: false, 11 | }], 12 | "comma-dangle": ["error", "never"], 13 | "no-underscore-dangle": "off", 14 | "jsx-a11y/no-static-element-interactions": "off", 15 | "react/no-did-mount-set-state": "off" 16 | }, 17 | "globals": { 18 | "window": true, 19 | "document": true 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | dist/ 4 | npm-debug.log 5 | browser.* 6 | .idea 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Isomorphic App Example 2 | 3 | ## Get the Book 4 | 5 | This example is used in [Isomorphic Development with JavaScript](http://bit.ly/isomorphicdevwithjs-github) 6 | 7 | ## All things Westies 8 | 9 | ### Setup 10 | ``` 11 | npm i 12 | npm start 13 | ``` 14 | 15 | Runs at [localhost:3000](localhost:3000) 16 | 17 | ### Slides from ForwardJS 18 | 19 | https://www.slideshare.net/ElyseKolkerGordon/building-universal-web-apps-with-react-72715124 20 | 21 | ### Slides from ReactJS meetup 22 | 23 | https://docs.google.com/presentation/d/1zxF2wvvOxctqqt78ho5D2lCKkU8R2X0wcY_O8TIbVGA/pub?start=false&loop=false&delayms=10000 24 | -------------------------------------------------------------------------------- /data/cart.json: -------------------------------------------------------------------------------- 1 | { 2 | "items": [ 3 | { 4 | "name": "Mug", 5 | "price": 5, 6 | "thumbnail": "http://localhost:3000/assets/cart-item-placeholder.jpg" 7 | }, 8 | { 9 | "name": "Socks", 10 | "price": 10, 11 | "thumbnail": "http://localhost:3000/assets/cart-item-placeholder.jpg" 12 | }, 13 | { 14 | "name": "Dog Collar", 15 | "price": 15, 16 | "thumbnail": "http://localhost:3000/assets/cart-item-placeholder.jpg" 17 | }, 18 | { 19 | "name": "Treats", 20 | "price": 15, 21 | "thumbnail": "http://localhost:3000/assets/cart-item-placeholder.jpg" 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /data/products.json: -------------------------------------------------------------------------------- 1 | { 2 | "mugs": { 3 | "items": [], 4 | "iconImage": "", 5 | "label": "", 6 | "description": "" 7 | }, 8 | "socks": { 9 | "items": [], 10 | "iconImage": "", 11 | "label": "", 12 | "description": "" 13 | }, 14 | "collars": { 15 | "items": [], 16 | "iconImage": "", 17 | "label": "", 18 | "description": "" 19 | }, 20 | "leashes": { 21 | "items": [], 22 | "iconImage": "", 23 | "label": "", 24 | "description": "" 25 | }, 26 | "treats": { 27 | "items": [], 28 | "iconImage": "", 29 | "label": "", 30 | "description": "" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /data/user.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "12x4fg4" 3 | } 4 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | var webpackConfig = require('./webpack.config.js'); 2 | 3 | Object.assign(webpackConfig, { 4 | externals: { 5 | 'react/addons': true, 6 | 'react/lib/ExecutionEnvironment': true, 7 | 'react/lib/ReactContext': 'window' 8 | } 9 | }); 10 | 11 | module.exports = (config) => { 12 | config.set({ 13 | frameworks: ['mocha', 'chai-sinon'], 14 | 15 | files: [ 16 | 'node_modules/babel-polyfill/dist/polyfill.js', 17 | 'test/**/*.+(es6|jsx)' 18 | ], 19 | 20 | exclude: [ 21 | 'src/test/middleware/**/*', 22 | 'src/middleware/**/*', 23 | 'src/test/**/*.server.spec.*' 24 | ], 25 | 26 | preprocessors: { 27 | 'test/**/*.+(js|jsx|es6)': ['webpack', 'sourcemap'] 28 | }, 29 | 30 | browsers: ['Chrome'], 31 | 32 | autoWatch: true, 33 | 34 | plugins: [ 35 | 'karma-*' 36 | ], 37 | 38 | webpack: require('./webpack.config.js'), 39 | 40 | webpackMiddleware: { 41 | noInfo: false, 42 | stats: { 43 | chunks: false, 44 | colors: true, 45 | hash: false, 46 | version: false, 47 | timings: false, 48 | assets: false, 49 | modules: false, 50 | reasons: false, 51 | children: false, 52 | source: false, 53 | errors: true, 54 | errorDetails: true, 55 | warnings: false, 56 | publicPath: false 57 | } 58 | } 59 | }); 60 | }; 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "overview-app", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "server.js", 6 | "scripts": { 7 | "build:browser": "node_modules/.bin/webpack", 8 | "prestart": "npm run build:browser", 9 | "start": "node src/server.js", 10 | "examples": "node examples-server.js", 11 | "test:browser": "node_modules/.bin/karma start", 12 | "test:server": "node_modules/.bin/mocha test/components/* --compilers js:babel-register --watch" 13 | }, 14 | "author": "elyseko", 15 | "license": "ISC", 16 | "dependencies": { 17 | "babel-register": "^6.24.1", 18 | "classnames": "^2.2.5", 19 | "express": "^4.15.3", 20 | "isomorphic-fetch": "^2.2.1", 21 | "react": "^15.6.1", 22 | "react-dom": "^15.6.1", 23 | "react-redux": "^5.0.5", 24 | "react-router": "^3.0.5", 25 | "redux": "^3.7.2", 26 | "redux-logger": "^3.0.6", 27 | "redux-thunk": "^2.2.0" 28 | }, 29 | "devDependencies": { 30 | "babel-cli": "^6.24.1", 31 | "babel-core": "^6.25.0", 32 | "babel-loader": "^7.1.1", 33 | "babel-plugin-transform-es2015-destructuring": "^6.23.0", 34 | "babel-plugin-transform-es2015-parameters": "^6.24.1", 35 | "babel-plugin-transform-object-rest-spread": "^6.23.0", 36 | "babel-preset-es2015": "^6.24.1", 37 | "babel-preset-react": "^6.24.1", 38 | "chai": "^4.1.0", 39 | "css-loader": "^0.28.4", 40 | "enzyme": "^2.9.1", 41 | "eslint": "^4.3.0", 42 | "eslint-config-airbnb": "^15.1.0", 43 | "eslint-plugin-import": "^2.7.0", 44 | "eslint-plugin-jsx-a11y": "^6.0.2", 45 | "eslint-plugin-mocha": "^4.11.0", 46 | "eslint-plugin-react": "^7.1.0", 47 | "karma": "^1.7.0", 48 | "karma-chai-sinon": "^0.1.5", 49 | "karma-chrome-launcher": "^2.2.0", 50 | "karma-mocha": "^1.3.0", 51 | "karma-sourcemap-loader": "^0.3.7", 52 | "karma-webpack": "^2.0.4", 53 | "mocha": "^3.4.2", 54 | "react-addons-test-utils": "^15.6.0", 55 | "react-test-renderer": "^16.0.0", 56 | "sinon": "^2.4.1", 57 | "sinon-chai": "^2.12.0", 58 | "style-loader": "^0.18.2", 59 | "webpack": "^3.4.1" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/app.es6: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import fs from 'fs'; 3 | import renderViewMiddleware from './middleware/renderView'; 4 | 5 | const app = express(); 6 | 7 | app.get('/api/user/cart', (req, res) => { 8 | fs.readFile('./data/cart.json', 'utf8', (err, data) => { 9 | if (err) { 10 | return res.status(404).send; 11 | } 12 | return res.send(JSON.parse(data)); 13 | }); 14 | }); 15 | 16 | app.get('/api/products/:type', (req, res) => { 17 | fs.readFile('./data/products.json', 'utf8', (err, data) => { 18 | if (err) { 19 | return res.status(404).send; 20 | } 21 | const products = JSON.parse(data); 22 | return res.send(products[req.params.type].items); 23 | }); 24 | }); 25 | 26 | app.get('/api/products', (req, res) => { 27 | fs.readFile('./data/products.json', 'utf8', (err, data) => { 28 | if (err) { 29 | return res.status(404).send; 30 | } 31 | return res.send(JSON.parse(data)); 32 | }); 33 | }); 34 | 35 | app.get('/api/blog', (req, res) => { 36 | fs.readFile('./data/blog.json', 'utf8', (err, data) => { 37 | if (err) { 38 | return res.status(404).send; 39 | } 40 | return res.send(JSON.parse(data)); 41 | }); 42 | }); 43 | 44 | app.get('/test', (req, res) => { 45 | res.send('Test route success!'); 46 | }); 47 | 48 | app.get('/*', renderViewMiddleware); 49 | 50 | // setup static files to load css 51 | app.use(express.static(__dirname)); 52 | 53 | app.listen(3000, () => { 54 | console.log('App listening on port: 3000'); 55 | }); 56 | -------------------------------------------------------------------------------- /src/assets/cart-item-placeholder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isomorphic-dev-js/complete-isomorphic-example/953b32b2a699e5c9b9e7e096b1d47ec9034030de/src/assets/cart-item-placeholder.jpg -------------------------------------------------------------------------------- /src/assets/style.css: -------------------------------------------------------------------------------- 1 | /* 2 | Overriding semantic UI fonts to prevent font performance issues 3 | which makes examples clearer. 4 | */ 5 | #react-content *{ 6 | font-family: Arial; 7 | } 8 | 9 | #react-content .icon { 10 | font-family: 'Icons'; 11 | } 12 | 13 | #react-content .products .icon { 14 | font-size: 2em; 15 | } 16 | 17 | #react-content .products .category-title { 18 | margin-top: 10px; 19 | } 20 | 21 | #react-content .products { 22 | text-align: center; 23 | } 24 | 25 | #react-content .icon.search { 26 | margin-left: -40px; 27 | margin-top: 7px; 28 | font-size: 1.2em; 29 | } 30 | 31 | .main.container { 32 | margin-top: 7em; 33 | } 34 | 35 | .ui.positive.basic.button { 36 | display: block; 37 | margin-top: 10px; 38 | } 39 | 40 | .cart .right.aligned.content { 41 | text-align: right; 42 | } 43 | 44 | .banner { 45 | position: fixed; 46 | bottom: 0; 47 | width: 100%; 48 | background-color: #CCC; 49 | display: none; 50 | } 51 | 52 | .banner.show { 53 | display: block; 54 | } 55 | 56 | .banner .content { 57 | margin: 10px; 58 | text-align: center; 59 | } 60 | 61 | .banner .dismiss { 62 | position: absolute; 63 | right: 10px; 64 | top: 5px; 65 | } 66 | 67 | .btn-reset { 68 | background: none; 69 | border: 0; 70 | color: inherit; 71 | /* cursor: default; */ 72 | font: inherit; 73 | line-height: normal; 74 | overflow: visible; 75 | padding: 0; 76 | box-sizing: content-box; 77 | } 78 | 79 | .products .tooltip { 80 | margin-top: 10px; 81 | max-width: 200px; 82 | } 83 | -------------------------------------------------------------------------------- /src/components/app.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router'; 3 | import Banner from './banner'; 4 | 5 | const App = (props) => { 6 | return ( 7 |
8 |
9 |

All Things Westies

10 | Products 11 | Cart 12 | Profile 13 |
14 | 15 |

Check out the semi-annual sale! Up to 75% off select Items

16 |
17 |
18 | { 19 | React.Children.map( 20 | props.children, 21 | (child) => { 22 | return React.cloneElement( 23 | child, 24 | { router: props.router } 25 | ); 26 | } 27 | ) 28 | } 29 |
30 |
31 | ); 32 | }; 33 | 34 | App.propTypes = { 35 | children: React.PropTypes.element, 36 | router: React.PropTypes.shape({ 37 | push: React.PropTypes.function 38 | }) 39 | }; 40 | 41 | export default App; 42 | -------------------------------------------------------------------------------- /src/components/banner.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classnames from 'classnames'; 3 | 4 | class Banner extends React.Component { 5 | 6 | constructor(props) { 7 | super(props); 8 | this.state = { 9 | show: false 10 | }; 11 | this.handleDismissBanner = this.handleDismissBanner.bind(this); 12 | } 13 | 14 | componentDidMount() { 15 | const cookies = document.cookie; 16 | const hideBanner = cookies.match('showBanner=false'); 17 | if (!hideBanner) { 18 | this.setState({ 19 | show: true 20 | }); 21 | } 22 | } 23 | 24 | handleDismissBanner() { 25 | document.cookie = 'showBanner=false'; 26 | this.setState({ 27 | show: false 28 | }); 29 | } 30 | 31 | render() { 32 | const bannerClasses = classnames({ show: this.state.show }, 'banner'); 33 | return ( 34 |
35 |
36 | 42 |
43 |
44 | {this.props.children} 45 |
46 |
47 | ); 48 | } 49 | } 50 | 51 | Banner.propTypes = { 52 | children: React.PropTypes.element 53 | }; 54 | 55 | export default Banner; 56 | -------------------------------------------------------------------------------- /src/components/cart.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { bindActionCreators } from 'redux'; 3 | import { connect } from 'react-redux'; 4 | import Item from './item'; 5 | import cartActions from '../shared/cart-action-creators.es6'; 6 | 7 | export class CartComponent extends Component { 8 | 9 | static loadData() { 10 | return [ 11 | cartActions.getCartItems 12 | ]; 13 | } 14 | 15 | constructor(props) { 16 | super(props); 17 | this.proceedToCheckout = this.proceedToCheckout.bind(this); 18 | } 19 | 20 | getTotal() { 21 | let total = 0; 22 | if (this.props.items) { 23 | total = this.props.items.reduce((prev, current) => { 24 | return prev + current.price; 25 | }, total); 26 | } 27 | return total; 28 | } 29 | 30 | proceedToCheckout() { 31 | this.props.router.push('/cart/payment'); 32 | } 33 | 34 | renderItems() { 35 | const items = []; 36 | if (this.props.items) { 37 | this.props.items.forEach((item, index) => { 38 | items.push(); 39 | }); 40 | } 41 | return items; 42 | } 43 | 44 | render() { 45 | return ( 46 |
47 |
48 | {this.renderItems()} 49 |
50 |
51 |
52 | Total: ${this.getTotal()} 53 | 59 |
60 |
61 |
62 | ); 63 | } 64 | } 65 | 66 | CartComponent.propTypes = { 67 | items: React.PropTypes.arrayOf(React.PropTypes.shape({ 68 | name: React.PropTypes.string, 69 | price: React.PropTypes.number, 70 | thumbnail: React.PropTypes.string 71 | })), 72 | router: React.PropTypes.shape({ 73 | push: React.PropTypes.function 74 | }) 75 | }; 76 | 77 | function mapStateToProps(state) { 78 | const { items } = state.cart; 79 | return { 80 | items 81 | }; 82 | } 83 | 84 | function mapDispatchToProps(dispatch) { 85 | return { 86 | cartActions: bindActionCreators(cartActions, dispatch) 87 | }; 88 | } 89 | 90 | export default connect(mapStateToProps, mapDispatchToProps)(CartComponent); 91 | -------------------------------------------------------------------------------- /src/components/detail.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Detail = () => { 4 | return ( 5 |
Detail!
6 | ); 7 | }; 8 | 9 | export default Detail; 10 | -------------------------------------------------------------------------------- /src/components/html.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const HTML = (props) => { 4 | return ( 5 | 6 | 7 | All Things Westies 8 | 12 | 13 | 14 | 15 |
19 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import { browserHistory, Router } from 'react-router'; 5 | import initRedux from './shared/init-redux.es6'; 6 | import sharedRoutes from './shared/sharedRoutes'; 7 | 8 | const initialState = JSON.parse(window.__SERIALIZED_STATE__); 9 | console.log(initialState); 10 | 11 | const store = initRedux(initialState); 12 | 13 | function init() { 14 | ReactDOM.render( 15 | 16 | 17 | , document.getElementById('react-content')); 18 | } 19 | 20 | init(); 21 | -------------------------------------------------------------------------------- /src/middleware/renderView.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { renderToString } from 'react-dom/server'; 3 | import { Provider } from 'react-redux'; 4 | import { match, RouterContext } from 'react-router'; 5 | import { routes } from '../shared/sharedRoutes'; 6 | import initRedux from '../shared/init-redux.es6'; 7 | import HTML from '../components/html'; 8 | 9 | export default function renderView(req, res, next) { 10 | const matchOpts = { 11 | routes: routes(), 12 | location: req.url 13 | }; 14 | const handleMatchResult = (error, redirectLocation, renderProps) => { 15 | if (!error && !redirectLocation && renderProps) { 16 | const store = initRedux(); 17 | let actions = renderProps.components.map((component) => { 18 | if (component) { 19 | if (component.displayName && 20 | component.displayName.toLowerCase().indexOf('connect') > -1 21 | ) { 22 | if (component.WrappedComponent.loadData) { 23 | return component.WrappedComponent.loadData(); 24 | } 25 | } else if (component.loadData) { 26 | return component.loadData(); 27 | } 28 | } 29 | return []; 30 | }); 31 | 32 | actions = actions.reduce((flat, toFlatten) => { 33 | return flat.concat(toFlatten); 34 | }, []); 35 | 36 | const promises = actions.map((initialAction) => { 37 | return store.dispatch(initialAction()); 38 | }); 39 | Promise.all(promises).then(() => { 40 | const serverState = store.getState(); 41 | const stringifiedServerState = JSON.stringify(serverState); 42 | const app = renderToString( 43 | 44 | 45 | 46 | ); 47 | const html = renderToString( 48 | 49 | ); 50 | return res.send(`${html}`); 51 | }).catch(() => { 52 | return next(); 53 | }); 54 | } else { 55 | next(); 56 | } 57 | }; 58 | match(matchOpts, handleMatchResult); 59 | } 60 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | require('babel-register'); 2 | require('./app.es6'); 3 | -------------------------------------------------------------------------------- /src/shared/cart-action-creators.es6: -------------------------------------------------------------------------------- 1 | import fetch from 'isomorphic-fetch'; 2 | 3 | export const GET_CART_ITEMS = 'GET_CART_ITEMS'; 4 | 5 | export function getCartItems() { 6 | return (dispatch) => { 7 | return fetch('http://localhost:3000/api/user/cart', { 8 | method: 'GET' 9 | }).then((response) => { 10 | return response.json().then((data) => { 11 | return dispatch({ 12 | type: GET_CART_ITEMS, 13 | data: data.items 14 | }); 15 | }); 16 | }).catch(() => { 17 | return dispatch({ type: `${GET_CART_ITEMS}_ERROR` }); 18 | }); 19 | }; 20 | } 21 | 22 | export default { 23 | getCartItems 24 | }; 25 | -------------------------------------------------------------------------------- /src/shared/cart-reducer.es6: -------------------------------------------------------------------------------- 1 | import { GET_CART_ITEMS } from './cart-action-creators.es6'; 2 | 3 | export default function cart(state = {}, action) { 4 | switch (action.type) { 5 | case GET_CART_ITEMS: 6 | return { 7 | ...state, 8 | items: action.data 9 | }; 10 | case `${GET_CART_ITEMS}_ERROR`: 11 | return state; 12 | default: 13 | return state; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/shared/init-redux.es6: -------------------------------------------------------------------------------- 1 | import { 2 | createStore, 3 | combineReducers, 4 | applyMiddleware, 5 | compose } from 'redux'; 6 | import thunkMiddleware from 'redux-thunk'; 7 | import loggerMiddleware from 'redux-logger'; 8 | import products from './products-reducer.es6'; 9 | import cart from './cart-reducer.es6'; 10 | 11 | export default function (initialStore = {}) { 12 | const reducer = combineReducers({ 13 | products, 14 | cart 15 | }); 16 | const middleware = [thunkMiddleware, loggerMiddleware]; 17 | let newCompose; 18 | if (typeof window !== 'undefined') { 19 | newCompose = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__; 20 | } 21 | const composeEnhancers = newCompose || compose; 22 | return composeEnhancers( 23 | applyMiddleware(...middleware) 24 | )(createStore)(reducer, initialStore); 25 | } 26 | -------------------------------------------------------------------------------- /src/shared/products-action-creators.es6: -------------------------------------------------------------------------------- 1 | import { fetch, Headers } from 'isomorphic-fetch'; 2 | 3 | export const GET_PRODUCTS = 'GET_PRODUCTS'; 4 | 5 | export function getProducts() { 6 | const headers = new Headers({ 7 | 'Content-Type': 'application/json' 8 | }); 9 | 10 | return (dispatch) => { 11 | return fetch('http://localhost:3000/api/products', { 12 | method: 'GET', 13 | headers 14 | }).then((response) => { 15 | return response.json().then((data) => { 16 | return dispatch({ 17 | type: GET_PRODUCTS, 18 | notifications: data 19 | }); 20 | }); 21 | }).catch(() => { 22 | return dispatch({ type: `${GET_PRODUCTS}_ERROR` }); 23 | }); 24 | }; 25 | } 26 | 27 | export default { 28 | getProducts 29 | }; 30 | -------------------------------------------------------------------------------- /src/shared/products-reducer.es6: -------------------------------------------------------------------------------- 1 | import { GET_PRODUCTS } from './products-action-creators.es6'; 2 | 3 | export default function products(state = {}, action) { 4 | switch (action.type) { 5 | case GET_PRODUCTS: 6 | return { 7 | ...state 8 | }; 9 | case `${GET_PRODUCTS}_ERROR`: 10 | return { 11 | ...state 12 | }; 13 | default: 14 | return state; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/shared/sharedRoutes.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route, IndexRoute } from 'react-router'; 3 | import App from '../components/app'; 4 | import Cart from '../components/cart'; 5 | import Payment from '../components/payment'; 6 | import Products from '../components/products'; 7 | import Profile from '../components/profile'; 8 | import Login from '../components/login'; 9 | 10 | let beforeRouteRender = (dispatch, prevState, nextState) => { 11 | const { routes } = nextState; 12 | routes.map((route) => { 13 | const { component } = route; 14 | if (component) { 15 | if (component.displayName && 16 | component.displayName.toLowerCase().indexOf('connect') > -1 17 | ) { 18 | if (component.WrappedComponent.loadData) { 19 | return component.WrappedComponent.loadData(); 20 | } 21 | } else if (component.loadData) { 22 | return component.loadData(); 23 | } 24 | } 25 | return []; 26 | }).reduce((flat, toFlatten) => { 27 | return flat.concat(toFlatten); 28 | }, []).map((initialAction) => { 29 | return dispatch(initialAction()); 30 | }); 31 | }; 32 | 33 | export const routes = (onChange = () => {}) => { 34 | return ( 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | ); 44 | }; 45 | 46 | 47 | const createSharedRoutes = ({ dispatch }) => { 48 | beforeRouteRender = beforeRouteRender.bind(this, dispatch); 49 | return routes(beforeRouteRender); 50 | }; 51 | 52 | export default createSharedRoutes; 53 | -------------------------------------------------------------------------------- /test/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "plugins": [ 3 | "mocha" 4 | ], 5 | "rules": { 6 | "no-unused-expressions": [0] 7 | }, 8 | "globals": { 9 | "describe": true, 10 | "it": true, 11 | "beforeEach": true, 12 | "afterEach": true 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /test/components/app.spec.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { expect } from 'chai'; 3 | import { shallow } from 'enzyme'; 4 | import { Link } from 'react-router'; 5 | import App from '../../src/components/app'; 6 | 7 | describe('App Component', () => { 8 | let wrappedComponent; 9 | 10 | beforeEach(() => { 11 | wrappedComponent = shallow(); 12 | }); 13 | 14 | afterEach(() => { 15 | wrappedComponent = null; 16 | }); 17 | 18 | it('Uses Link Components', () => { 19 | expect(wrappedComponent.find(Link).length).to.eq(3); 20 | }); 21 | 22 | it('Links to /products, /cart and /profile pages', () => { 23 | expect(wrappedComponent.find({ to: '/products' }).length).to.eq(1); 24 | expect(wrappedComponent.find({ to: '/cart' }).length).to.eq(1); 25 | expect(wrappedComponent.find({ to: '/profile' }).length).to.eq(1); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /test/components/cart.spec.jsx: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { shallow } from 'enzyme'; 3 | import React from 'react'; 4 | import sinon from 'sinon'; 5 | import { CartComponent } from '../../src/components/cart'; 6 | 7 | describe('Cart Component', () => { 8 | let testComponent; 9 | let props; 10 | 11 | beforeEach(() => { 12 | props = { 13 | items: [ 14 | { 15 | thumbnail: 'http://image.png', 16 | name: 'Test Name', 17 | price: 10 18 | } 19 | ], 20 | router: { 21 | push: sinon.spy() 22 | } 23 | }; 24 | testComponent = shallow(); 25 | }); 26 | 27 | afterEach(() => { 28 | testComponent = null; 29 | props = null; 30 | }); 31 | 32 | it('When checkout is clicked, the router push method is triggered', () => { 33 | testComponent.find('.button').simulate('click'); 34 | expect(props.router.push.called).to.be.true; 35 | expect(props.router.push.calledWith('/cart/payment')).to.be.true; 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /test/components/item.spec.jsx: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { shallow } from 'enzyme'; 3 | import React from 'react'; 4 | import Item from '../../src/components/item'; 5 | 6 | describe('Item Component', () => { 7 | let testComponent; 8 | let props; 9 | 10 | beforeEach(() => { 11 | props = { 12 | thumbnail: 'http://image.png', 13 | name: 'Test Name', 14 | price: 10 15 | }; 16 | testComponent = shallow(); 17 | }); 18 | 19 | afterEach(() => { 20 | testComponent = null; 21 | props = null; 22 | }); 23 | 24 | it('Displays a thumbnail based on its props', () => { 25 | expect(testComponent.find({ src: props.thumbnail }).length).to.eq(1); 26 | }); 27 | 28 | it('Displays a name based on its props', () => { 29 | expect(testComponent.find('.middle.aligned.content').text()).to.eq(props.name); 30 | }); 31 | 32 | it('Displays a price based on its props', () => { 33 | expect(testComponent.find('.right.aligned.content').text()).to.eq(`$${props.price}`); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /test/integration/app.spec.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Router, createMemoryHistory } from 'react-router'; 3 | import { expect } from 'chai'; 4 | import { mount } from 'enzyme'; 5 | import { routes } from '../../src/shared/sharedRoutes'; 6 | 7 | describe('App Component', () => { 8 | it('Uses Link Components', () => { 9 | const renderedComponent = mount( 10 | ); 14 | expect(renderedComponent.find('.search').length).to.be.above(1); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | entry: "./src/main.jsx", 3 | devtool: "source-map", 4 | output: { 5 | path: __dirname + '/src/', 6 | filename: "browser.js" 7 | }, 8 | module: { 9 | rules: [ 10 | { 11 | test: /\.(jsx|es6)$/, 12 | exclude: /node_modules|examples/, 13 | loader: "babel-loader" 14 | }, 15 | { 16 | test: /\.css$/, 17 | loaders: ['style-loader', 'css-loader'] 18 | } 19 | ] 20 | }, 21 | resolve: { 22 | extensions: ['.js', '.jsx', '.css', '.es6', '.json'] 23 | } 24 | }; 25 | --------------------------------------------------------------------------------