├── .nvmrc ├── src ├── containers │ ├── MainLayout │ │ ├── styles.css │ │ └── index.js │ ├── root.prod.js │ ├── AuthLayout │ │ ├── styles.css │ │ └── index.js │ ├── NotFound │ │ ├── styles.css │ │ └── index.js │ ├── root.js │ ├── Home │ │ ├── styles.css │ │ └── index.js │ ├── Login │ │ ├── styles.css │ │ └── index.js │ ├── root.dev.js │ ├── DevTools.js │ ├── ProtectedRoute.js │ ├── routes.js │ └── ForgotPassword │ │ └── index.js ├── global.css ├── components │ ├── TextInput │ │ ├── styles.css │ │ ├── index.js │ │ └── test │ │ │ └── index-test.js │ ├── PrimaryTextInput │ │ ├── styles.css │ │ ├── index.js │ │ └── test │ │ │ └── index-test.js │ ├── InlineMessage │ │ ├── styles.css │ │ ├── index.js │ │ └── test │ │ │ └── index-test.js │ ├── Button │ │ ├── styles.css │ │ ├── index.js │ │ └── test │ │ │ └── index-test.js │ ├── InlineLink │ │ ├── styles.css │ │ ├── test │ │ │ └── index-test.js │ │ └── index.js │ └── PrimaryButton │ │ ├── styles.css │ │ ├── index.js │ │ └── test │ │ └── index-test.js ├── store │ ├── index.js │ ├── configureStore.prod.js │ └── configureStore.dev.js ├── colors.js ├── index.html ├── reducers │ ├── counter.js │ ├── index.js │ ├── test │ │ ├── counter_spec.js │ │ └── auth_spec.js │ └── auth.js ├── index.js ├── utils │ ├── index.js │ └── ApiClient.js └── actions │ └── auth.js ├── .gitignore ├── server.js ├── .travis.yml ├── tests.webpack.js ├── .eslintrc ├── server.prod.js ├── .babelrc ├── server.dev.js ├── README.md ├── webpack.config.js ├── karma.config.js └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | 4 2 | -------------------------------------------------------------------------------- /src/containers/MainLayout/styles.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/global.css: -------------------------------------------------------------------------------- 1 | :global(body) { 2 | font-family: helvetica; 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | npm-debug.log 4 | .DS_Store 5 | *.orig -------------------------------------------------------------------------------- /src/containers/root.prod.js: -------------------------------------------------------------------------------- 1 | import Routes from './routes'; 2 | 3 | export default Routes; 4 | -------------------------------------------------------------------------------- /src/containers/AuthLayout/styles.css: -------------------------------------------------------------------------------- 1 | .login { 2 | text-align: center; 3 | margin-top: 100px; 4 | } 5 | 6 | .content { 7 | margin-top: 50px; 8 | } -------------------------------------------------------------------------------- /src/containers/NotFound/styles.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 10px; 3 | } 4 | 5 | .container h1 { 6 | color: $dainTree; 7 | font-weight: bold; 8 | } -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | require('babel-core/register'); 2 | if (process.env.NODE_ENV === 'production') { 3 | require('./server.prod'); 4 | } else { 5 | require('./server.dev'); 6 | } 7 | -------------------------------------------------------------------------------- /src/containers/root.js: -------------------------------------------------------------------------------- 1 | if (process.env.NODE_ENV === 'production') { 2 | module.exports = require('./root.prod'); 3 | } else { 4 | module.exports = require('./root.dev'); 5 | } 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '4.2' 4 | before_install: 5 | - npm i -g npm@^3.0.0 6 | script: 7 | - npm run lint 8 | - npm run test 9 | - npm run build 10 | -------------------------------------------------------------------------------- /src/components/TextInput/styles.css: -------------------------------------------------------------------------------- 1 | .input { 2 | color: $white; 3 | padding: 18px 20px; 4 | border-radius: 8px; 5 | border: none; 6 | &:placeholder { 7 | color: $white; 8 | } 9 | } -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | if (process.env.NODE_ENV === 'production') { 2 | module.exports = require('./configureStore.prod'); 3 | } else { 4 | module.exports = require('./configureStore.dev'); 5 | } 6 | -------------------------------------------------------------------------------- /tests.webpack.js: -------------------------------------------------------------------------------- 1 | require('babel-polyfill'); 2 | var context = require.context('./src', true, /-test\.js$/); // make sure you have your directory and regex test set correctly! 3 | context.keys().forEach(context); -------------------------------------------------------------------------------- /src/colors.js: -------------------------------------------------------------------------------- 1 | const white = '#FFF'; 2 | const rosso = '#D80B09'; 3 | const mercury = '#E5E5E5'; 4 | const dainTree = '#02202A'; 5 | const wasabi = '#77971E'; 6 | 7 | module.exports = { 8 | white, 9 | rosso, 10 | mercury, 11 | dainTree, 12 | wasabi 13 | }; 14 | -------------------------------------------------------------------------------- /src/containers/Home/styles.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 10px; 3 | width: 100%; 4 | } 5 | 6 | .container h1 { 7 | color: $dainTree; 8 | font-weight: bold; 9 | text-align: left; 10 | } 11 | 12 | .search-wrapper { 13 | width: 80%; 14 | margin: 30px auto; 15 | } -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Redux React Boilerplate 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /src/components/PrimaryTextInput/styles.css: -------------------------------------------------------------------------------- 1 | .input { 2 | color: $dainTree; 3 | background: $mercury; 4 | padding: 18px 20px; 5 | border-radius: 4px; 6 | border: none; 7 | display: block; 8 | margin: 10px auto; 9 | width: 75%; 10 | &:placeholder { 11 | color: $dainTree; 12 | } 13 | } -------------------------------------------------------------------------------- /src/reducers/counter.js: -------------------------------------------------------------------------------- 1 | import {createReducer} from '../utils'; 2 | 3 | const initialState = {count: 0}; 4 | 5 | export default createReducer(initialState, { 6 | ['INCREMENT']: (state) => ({ 7 | count: state.count + 1 8 | }), 9 | ['DECREMENT']: (state) => ({ 10 | count: state.count - 1 11 | }) 12 | }); 13 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | /** 2 | * 0 = disabled, 1 = warn, 2 = error 3 | * For info on specific rules, see: http://eslint.org/docs/rules/ 4 | */ 5 | { 6 | "extends": "pebblecode", 7 | "rules": { 8 | "no-use-before-define": 0, 9 | "new-cap": 0 //We don't like this rule, Immutable and React in general has caps 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/components/InlineMessage/styles.css: -------------------------------------------------------------------------------- 1 | .error, .success { 2 | font-size: 10pt; 3 | display: block; 4 | text-align: center; 5 | width: 100%; 6 | padding: 4px; 7 | } 8 | 9 | .error span, .success span { 10 | width: 100%; 11 | } 12 | 13 | .error { 14 | color: $rosso; 15 | } 16 | 17 | .success { 18 | color: $wasabi; 19 | } -------------------------------------------------------------------------------- /src/containers/Login/styles.css: -------------------------------------------------------------------------------- 1 | .container { 2 | margin: 0 auto; 3 | } 4 | 5 | .form { 6 | width: 400px; 7 | margin: 0 auto; 8 | } 9 | 10 | .form input { 11 | width: 360px; 12 | } 13 | 14 | .actions { 15 | margin-top: 30px; 16 | } 17 | 18 | .actions button { 19 | float: right; 20 | } 21 | 22 | .actions a { 23 | float: left; 24 | } -------------------------------------------------------------------------------- /src/components/Button/styles.css: -------------------------------------------------------------------------------- 1 | .shrink { 2 | transition:all 0.5s ease-out; 3 | } 4 | 5 | .shrink:hover { 6 | transform:scale(0.9); 7 | opacity: 0.8; 8 | } 9 | 10 | .grow { 11 | transition:all 0.5s ease-out; 12 | } 13 | 14 | .grow:hover { 15 | transform:scale(1.1); 16 | opacity: 0.8; 17 | } 18 | 19 | .button { 20 | background: $mercury; 21 | } -------------------------------------------------------------------------------- /src/containers/root.dev.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import DevTools from './DevTools'; 3 | import Routes from './routes'; 4 | 5 | class Root extends Component { 6 | render() { 7 | return ( 8 |
9 | 10 | 11 |
12 | ); 13 | } 14 | } 15 | 16 | export default Root; 17 | -------------------------------------------------------------------------------- /src/components/TextInput/index.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import CssModules from 'react-css-modules'; 3 | 4 | import styles from './styles.css'; 5 | 6 | class TextInput extends Component { 7 | render() { 8 | return ; 9 | } 10 | } 11 | 12 | export default CssModules(TextInput, styles); 13 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import {combineReducers} from 'redux'; 2 | import {routeReducer} from 'react-router-redux'; 3 | import {reducer as formReducer} from 'redux-form'; 4 | 5 | import counter from './counter'; 6 | import auth from './auth'; 7 | 8 | export default combineReducers({ 9 | auth, 10 | counter, 11 | routing: routeReducer, 12 | form: formReducer 13 | }); 14 | -------------------------------------------------------------------------------- /src/components/PrimaryTextInput/index.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import TextInput from '../TextInput'; 3 | import CssModules from 'react-css-modules'; 4 | 5 | import styles from './styles.css'; 6 | 7 | class PrimaryInput extends Component { 8 | render() { 9 | return ; 10 | } 11 | } 12 | 13 | export default CssModules(PrimaryInput, styles); 14 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | import React from 'react'; 3 | import { Provider } from 'react-redux'; 4 | import { render } from 'react-dom'; 5 | import Root from './containers/root'; 6 | import configureStore from './store'; 7 | 8 | const store = configureStore(); 9 | import './global.css'; 10 | 11 | render( 12 | 13 | , document.getElementById('root')); 14 | -------------------------------------------------------------------------------- /src/components/InlineLink/styles.css: -------------------------------------------------------------------------------- 1 | .link, .default{ 2 | font-size: 9pt; 3 | cursor: pointer; 4 | margin: {top: 10px; bottom: 10px} 5 | cursor: pointer; 6 | } 7 | 8 | .default{ 9 | color: $dainTree; 10 | } 11 | 12 | .white { 13 | color: $white; 14 | } 15 | 16 | .default:link, .white:link { 17 | text-decoration: none; 18 | } 19 | 20 | .default:hover, .white:hover { 21 | text-decoration: underline; 22 | } 23 | 24 | -------------------------------------------------------------------------------- /src/containers/DevTools.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {createDevTools} from 'redux-devtools'; 3 | import LogMonitor from 'redux-devtools-log-monitor'; 4 | import DockMonitor from 'redux-devtools-dock-monitor'; 5 | 6 | export default createDevTools( 7 | 10 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility method to create a reducer from a map, 3 | * with initial state 4 | * @param initialState 5 | * @param reducerMap 6 | * @returns {Function} 7 | */ 8 | export function createReducer(initialState, reducerMap) { 9 | return (state = initialState, action) => { 10 | const reducer = reducerMap[action.type]; 11 | return reducer 12 | ? reducer(state, action.payload) 13 | : state; 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /src/components/PrimaryButton/styles.css: -------------------------------------------------------------------------------- 1 | .button { 2 | text-transform: capitalize; 3 | color: $white; 4 | background: $dainTree; 5 | padding: 8px 15px; 6 | font-size: 10pt; 7 | border-radius: 3px; 8 | border: none; 9 | cursor: pointer; 10 | composes: grow from '../Button/styles.css'; 11 | } 12 | 13 | .button span { 14 | padding: 5px; 15 | } 16 | 17 | .button img { 18 | padding-top: 5px; 19 | } 20 | 21 | .icon, .button span { 22 | float: left; 23 | } 24 | -------------------------------------------------------------------------------- /src/containers/NotFound/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | 3 | import styles from './styles.css'; 4 | 5 | class NotFound extends Component { 6 | 7 | render() { 8 | return ( 9 |
10 |
11 |

404 Page Not Found

12 |
13 |
14 | ); 15 | } 16 | } 17 | 18 | NotFound.propTypes = { 19 | children: PropTypes.node 20 | }; 21 | 22 | export default NotFound; 23 | -------------------------------------------------------------------------------- /src/containers/AuthLayout/index.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import CssModules from 'react-css-modules'; 3 | 4 | import styles from './styles.css'; 5 | class AuthLayout extends Component { 6 | render() { 7 | return (
8 |
9 | {this.props.children} 10 |
11 |
); 12 | } 13 | } 14 | 15 | AuthLayout.propTypes = { 16 | children: React.PropTypes.node 17 | }; 18 | 19 | export default CssModules(AuthLayout, styles); 20 | -------------------------------------------------------------------------------- /src/components/Button/index.js: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from 'react'; 2 | import CssModules from 'react-css-modules'; 3 | 4 | import styles from './styles.css'; 5 | 6 | class Button extends Component { 7 | render() { 8 | const {children} = this.props; 9 | 10 | return (); 11 | } 12 | } 13 | 14 | Button.propTypes = { 15 | children: PropTypes.string.isRequired 16 | }; 17 | 18 | export default CssModules(Button, styles); 19 | -------------------------------------------------------------------------------- /src/components/InlineMessage/index.js: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from 'react'; 2 | import CssModules from 'react-css-modules'; 3 | 4 | import styles from './styles.css'; 5 | 6 | class InlineErrorMessage extends Component { 7 | render() { 8 | const {type, message} = this.props; 9 | return {message} ; 10 | } 11 | } 12 | 13 | InlineErrorMessage.propTypes = { 14 | message: PropTypes.string.isRequired, 15 | type: PropTypes.string.isRequired 16 | }; 17 | 18 | export default CssModules(InlineErrorMessage, styles); 19 | -------------------------------------------------------------------------------- /src/components/PrimaryButton/index.js: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from 'react'; 2 | import Button from '../Button'; 3 | import CssModules from 'react-css-modules'; 4 | 5 | import styles from './styles.css'; 6 | 7 | class PrimaryButton extends Component { 8 | render() { 9 | const {children} = this.props; 10 | 11 | return (); 12 | } 13 | } 14 | 15 | PrimaryButton.propTypes = { 16 | children: PropTypes.string.isRequired, 17 | loading: PropTypes.bool 18 | }; 19 | 20 | export default CssModules(PrimaryButton, styles); 21 | -------------------------------------------------------------------------------- /server.prod.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | import express from 'express'; 3 | import path from 'path'; 4 | 5 | const app = express(); 6 | 7 | const port = process.env.PORT || 3000; 8 | 9 | app.use(express.static(path.join(__dirname, 'dist'))); 10 | 11 | app.get('*', (request, response) => { 12 | response.sendFile(path.join(__dirname, 'dist/index.html')); 13 | }); 14 | 15 | app.listen(port, (error) => { 16 | if (error) { 17 | console.error(error); 18 | process.exit(1); 19 | } 20 | 21 | console.log('Production Server'); 22 | return console.info(`🌎 Listening on port ${port}`); 23 | }); 24 | -------------------------------------------------------------------------------- /src/utils/ApiClient.js: -------------------------------------------------------------------------------- 1 | 2 | export const login = (username, password) => { // eslint-disable-line 3 | 4 | return new Promise((resolve) => { 5 | setTimeout(() => { 6 | resolve({token: '123', username}); 7 | }, 100); 8 | }); 9 | }; 10 | 11 | export const forgotPassword = (username) => { // eslint-disable-line 12 | 13 | return new Promise((resolve) => { 14 | setTimeout(() => { 15 | resolve(); 16 | }, 100); 17 | }); 18 | }; 19 | 20 | export const logout = () => { 21 | return new Promise((resolve) => { 22 | setTimeout(() => { 23 | resolve(); 24 | }, 100); 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /src/reducers/test/counter_spec.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai'; 2 | import reducer from '../counter'; 3 | 4 | describe('Counter Reducer', () => { 5 | it('Should handle INCREMENT', () => { 6 | const initialState = {count: 0}; 7 | 8 | const newState = reducer(initialState, {type: 'INCREMENT'}); 9 | 10 | expect(newState).to.eql({ 11 | count: 1 12 | }); 13 | }); 14 | 15 | it('Should handle DECREMENT', () => { 16 | const initialState = {count: 1}; 17 | 18 | const newState = reducer(initialState, {type: 'DECREMENT'}); 19 | 20 | expect(newState).to.eql({ 21 | count: 0 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0", "react"], 3 | "env": { 4 | // only enable it when process.env.NODE_ENV is 'development' or undefined 5 | "development": { 6 | "plugins": [["react-transform", { 7 | "transforms": [{ 8 | "transform": "react-transform-hmr", 9 | // if you use React Native, pass "react-native" instead: 10 | "imports": ["react"], 11 | // this is important for Webpack HMR: 12 | "locals": ["module"] 13 | }] 14 | // note: you can put more transforms into array 15 | // this is just one of them! 16 | }]] 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /src/components/TextInput/test/index-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | import {expect} from 'chai'; 4 | import sinon from 'sinon'; 5 | 6 | import PrimaryTextInput from '../'; 7 | // related: https://github.com/airbnb/enzyme/issues/76 8 | 9 | describe('', () => { 10 | it('Should handleChange', () =>{ 11 | const handleChange = sinon.spy(); 12 | const wrapper = shallow(); 13 | 14 | wrapper.find('input').simulate('change', {target: {value: 'My new value'}}); 15 | 16 | expect(handleChange.calledOnce).to.equal(true); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/components/Button/test/index-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | import {expect} from 'chai'; 4 | import sinon from 'sinon'; 5 | 6 | import Button from '../'; 7 | 8 | describe('); 12 | 13 | wrapper.find('button').simulate('click', {preventDefault: () => {}}); 14 | 15 | expect(handleClick.calledOnce).to.equal(true); 16 | }); 17 | 18 | it('Should render inner text', () => { 19 | const wrapper = shallow(); 20 | expect(wrapper.text()).to.contain('hi'); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/components/PrimaryTextInput/test/index-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | import {expect} from 'chai'; 4 | import sinon from 'sinon'; 5 | 6 | import PrimaryTextInput from '../'; 7 | import TextInput from '../../TextInput'; 8 | // related: https://github.com/airbnb/enzyme/issues/76 9 | 10 | describe('', () => { 11 | it('Should handleChange', () =>{ 12 | const handleChange = sinon.spy(); 13 | const wrapper = shallow(); 14 | 15 | wrapper.find(TextInput).simulate('change', {target: {value: 'My new value'}}); 16 | 17 | expect(handleChange.calledOnce).to.equal(true); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/components/InlineLink/test/index-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | import {expect} from 'chai'; 4 | import sinon from 'sinon'; 5 | 6 | import InlineLink from '../'; 7 | 8 | describe('', () => { 9 | it('Should handleClick', () =>{ 10 | const handleClick = sinon.spy(); 11 | const wrapper = shallow( hi! ); 12 | 13 | wrapper.find('a').simulate('click', {preventDefault: () => {}}); 14 | 15 | expect(handleClick.calledOnce).to.equal(true); 16 | }); 17 | 18 | it('Should render inner text', () => { 19 | const wrapper = shallow( hi ); 20 | expect(wrapper.text()).to.contain('hi'); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/components/PrimaryButton/test/index-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | import {expect} from 'chai'; 4 | import sinon from 'sinon'; 5 | 6 | import PrimaryButton from '../'; 7 | import Button from '../../Button'; 8 | 9 | describe('', () => { 10 | it('Should handleClick', () =>{ 11 | const handleClick = sinon.spy(); 12 | const wrapper = shallow( hi! ); 13 | 14 | wrapper.find(Button).simulate('click', {preventDefault: () => {}}); 15 | 16 | expect(handleClick.calledOnce).to.equal(true); 17 | }); 18 | 19 | it('Should render inner text', () => { 20 | const wrapper = shallow(); 21 | expect(wrapper.text()).to.contain('hi'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/containers/Home/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import CssModules from 'react-css-modules'; 4 | 5 | import styles from './styles.css'; 6 | 7 | class Home extends Component { 8 | 9 | render() { 10 | const {auth: {username}} = this.props; 11 | 12 | return ( 13 |
14 |
15 |

Hi {username}

16 |
17 |
18 | ); 19 | } 20 | } 21 | 22 | Home.propTypes = { 23 | children: PropTypes.node, 24 | auth: PropTypes.shape({ 25 | username: PropTypes.string 26 | }) 27 | }; 28 | 29 | const mapStateToProps = ({auth}) => ({ 30 | auth 31 | }); 32 | 33 | export default connect(mapStateToProps)(CssModules(Home, styles)); 34 | -------------------------------------------------------------------------------- /src/components/InlineLink/index.js: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from 'react'; 2 | import CssModules from 'react-css-modules'; 3 | 4 | import styles from './styles.css'; 5 | 6 | class InlineLink extends Component { 7 | _handleClick(e) { 8 | e.preventDefault(); 9 | this.props.onClick(); 10 | } 11 | 12 | render() { 13 | const {color} = this.props; 14 | return {this.props.children} ; 15 | } 16 | } 17 | 18 | InlineLink.propTypes = { 19 | children: PropTypes.string.isRequired, 20 | color: PropTypes.string, 21 | onClick: PropTypes.func 22 | }; 23 | 24 | InlineLink.defaultProps = { 25 | onClick: () => {}, 26 | color: 'default' 27 | }; 28 | 29 | export default CssModules(InlineLink, styles); 30 | -------------------------------------------------------------------------------- /src/components/InlineMessage/test/index-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | import {expect} from 'chai'; 4 | 5 | import InlineLink from '../'; 6 | 7 | describe('', () => { 8 | it('Should render success inner text', () => { 9 | const wrapper = shallow(); 10 | expect(wrapper.text()).to.contain('hi'); 11 | }); 12 | 13 | it('Should render error inner text', () => { 14 | const wrapper = shallow(); 15 | expect(wrapper.text()).to.contain('hi'); 16 | }); 17 | 18 | it('Should render success inner text', () => { 19 | const wrapper = shallow(); 20 | expect(wrapper.text()).to.contain('hi'); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /server.dev.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | import path from 'path'; 3 | import express from 'express'; 4 | import webpack from 'webpack'; 5 | import config from './webpack.config'; 6 | import opn from 'opn'; 7 | const app = express(); 8 | const compiler = webpack(config); 9 | const port = 3000; 10 | 11 | let opened = false; 12 | app.use(require('webpack-dev-middleware')(compiler, { 13 | noInfo: true, 14 | publicPath: config.output.publicPath 15 | })); 16 | 17 | app.use(require('webpack-hot-middleware')(compiler)); 18 | 19 | app.get('*', (req, res) => { 20 | res.sendFile(path.join(__dirname, 'src/index.html')); 21 | }); 22 | 23 | app.listen(port, (err) => { 24 | if (err) { 25 | console.log(err); 26 | return; 27 | } 28 | 29 | if (!opened) { 30 | opn(`http://localhost:${port}`); 31 | opened = true; 32 | } 33 | console.log('Dev Server'); 34 | 35 | console.log(`🌎 Listening on port ${port}`); 36 | }); 37 | -------------------------------------------------------------------------------- /src/store/configureStore.prod.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from 'redux'; 2 | import { syncHistory } from 'react-router-redux'; 3 | import thunk from 'redux-thunk'; 4 | import {hashHistory} from 'react-router'; 5 | import createLogger from 'redux-logger'; 6 | 7 | import rootReducer from '../reducers'; 8 | /** 9 | * Method to create stores based on a set of passed 10 | * reducers 11 | * @param initialState 12 | * @returns {*} 13 | */ 14 | export default function configureStore(initialState) { 15 | 16 | const logger = createLogger(); 17 | const reduxRouterMiddleware = syncHistory(hashHistory); 18 | const middleware = applyMiddleware(thunk, logger, reduxRouterMiddleware); 19 | 20 | const createStoreWithMiddleware = compose( 21 | middleware 22 | ); 23 | 24 | const store = createStoreWithMiddleware(createStore)(rootReducer, initialState); 25 | 26 | reduxRouterMiddleware.listenForReplays(store); 27 | 28 | return store; 29 | } 30 | -------------------------------------------------------------------------------- /src/containers/ProtectedRoute.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { routeActions } from 'react-router-redux'; 3 | import { connect } from 'react-redux'; 4 | 5 | const mapStateToProps = ({auth}) => { 6 | return { 7 | auth 8 | }; 9 | }; 10 | 11 | export default (Component) => { 12 | class ProtectedRoute extends React.Component { 13 | componentWillMount() { 14 | const {auth, dispatch} = this.props; 15 | 16 | if (!auth.token) { 17 | const redirectAfterLogin = this.props.location.pathname; 18 | dispatch(routeActions.push(`/login?next=${redirectAfterLogin}`)); 19 | } 20 | } 21 | 22 | render() { 23 | return (); 24 | } 25 | 26 | } 27 | 28 | ProtectedRoute.propTypes = { 29 | dispatch: PropTypes.func.isRequired, 30 | auth: PropTypes.object.isRequired, 31 | location: PropTypes.shape({ 32 | pathname: PropTypes.string.isRequired 33 | }) 34 | }; 35 | 36 | return connect(mapStateToProps)(ProtectedRoute); 37 | }; 38 | -------------------------------------------------------------------------------- /src/containers/MainLayout/index.js: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from 'react'; 2 | import {connect} from 'react-redux'; 3 | import {bindActionCreators} from 'redux'; 4 | 5 | import * as actionCreators from '../../actions/auth'; 6 | 7 | class MainLayout extends Component { 8 | render() { 9 | const {auth: {username}, actions: {logout}} = this.props; 10 | return (
11 | logout()}> Logout? ({username}) 12 |
13 | {this.props.children} 14 |
15 |
); 16 | } 17 | } 18 | 19 | MainLayout.propTypes = { 20 | children: PropTypes.node, 21 | auth: PropTypes.shape({ 22 | username: PropTypes.string 23 | }), 24 | actions: PropTypes.shape({ 25 | logout: PropTypes.func.isRequired 26 | }) 27 | }; 28 | 29 | 30 | const mapStateToProps = ({auth}) => ({auth}); 31 | 32 | const mapDispatchToProps = (dispatch) => ({ 33 | actions: bindActionCreators(actionCreators, dispatch) 34 | }); 35 | 36 | export default connect(mapStateToProps, mapDispatchToProps)(MainLayout); 37 | -------------------------------------------------------------------------------- /src/containers/routes.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Router, Route, IndexRoute, hashHistory} from 'react-router'; 3 | 4 | import ProtectedRoute from './ProtectedRoute'; 5 | import AuthLayout from './AuthLayout'; 6 | import MainLayout from './MainLayout'; 7 | import Login from './Login'; 8 | import ForgotPassword from './ForgotPassword'; 9 | import Home from './Home'; 10 | import NotFound from './NotFound'; 11 | 12 | class Root extends Component { 13 | render() { 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | } 28 | } 29 | 30 | export default Root; 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Redux Boilerplate 2 | ![Build Status](https://travis-ci.org/export-mike/react-redux-boilerplate.svg) 3 | ![I Care](https://icarebadge.com/ICARE-black.png) 4 | [![Dependency Status](https://david-dm.org/export-mike/react-redux-boilerplate.svg?style=flat)](https://david-dm.org/export-mike/react-redux-boilerplate) 5 | 6 | A react, redux, webpack, css modules, postcss, karma and mocha boilerplate. Complete with super simple authentication flow, tests on reducers and components. Related to [blogpost](http://pebblecode.com/blog/react-redux-unit-testing/). Complete with redux dev tools. ctrl-h and ctrl-q shortcuts :) 7 | 8 | ## Installation 9 | #### npm3 and node v4+ required 10 | ``` git clone git@github.com:export-mike/react-redux-boilerplate.git``` 11 | 12 | ```npm i ``` 13 | 14 | ``` npm start ``` 15 | 16 | new shell: 17 | ``` npm run test:watch ``` 18 | 19 | ### other commands: 20 | test mocha only: 21 | ``` npm run mocha:watch ``` 22 | 23 | test karma only: 24 | ``` npm run karma:watch ``` 25 | 26 | for CI use ```npm test ``` 27 | 28 | Build: 29 | ``` npm run build ``` 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/store/configureStore.dev.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from 'redux'; 2 | import { syncHistory } from 'react-router-redux'; 3 | import thunk from 'redux-thunk'; 4 | import {hashHistory} from 'react-router'; 5 | import { persistState } from 'redux-devtools'; 6 | 7 | import rootReducer from '../reducers'; 8 | import DevTools from '../containers/DevTools'; 9 | /** 10 | * Method to create stores based on a set of passed 11 | * reducers 12 | * @param initialState 13 | * @returns {*} 14 | */ 15 | export default function configureStore(initialState) { 16 | 17 | const reduxRouterMiddleware = syncHistory(hashHistory); 18 | const middleware = applyMiddleware(thunk, reduxRouterMiddleware); 19 | 20 | const createStoreWithMiddleware = compose( 21 | middleware, 22 | // Required! Enable Redux DevTools with the monitors you chose 23 | DevTools.instrument(), 24 | // Optional. Lets you write ?debug_session= in address bar to persist debug sessions 25 | persistState(getDebugSessionKey()) 26 | ); 27 | 28 | const store = createStoreWithMiddleware(createStore)(rootReducer, initialState); 29 | 30 | reduxRouterMiddleware.listenForReplays(store); 31 | 32 | if (module.hot) { 33 | module.hot.accept('../reducers', () => { 34 | const nextRootReducer = require('../reducers').default; 35 | store.replaceReducer(nextRootReducer); 36 | }); 37 | } 38 | 39 | return store; 40 | } 41 | 42 | 43 | function getDebugSessionKey() { 44 | // You can write custom logic here! 45 | // By default we try to read the key from ?debug_session= in the address bar 46 | const matches = window.location.href.match(/[?&]debug_session=([^&]+)\b/); 47 | return (matches && matches.length > 0) ? matches[1] : null; 48 | } 49 | -------------------------------------------------------------------------------- /src/reducers/auth.js: -------------------------------------------------------------------------------- 1 | import {createReducer} from '../utils'; 2 | import { 3 | AUTH, AUTH_SUCCESS, AUTH_FAIL, 4 | AUTH_LOGOUT, AUTH_LOGOUT_SUCCESS, AUTH_LOGOUT_FAIL, 5 | AUTH_FORGOT, AUTH_FORGOT_FAIL, AUTH_FORGOT_SUCCESS 6 | } from '../actions/auth'; 7 | 8 | export default createReducer({ 9 | token: null, 10 | username: '', 11 | tokenExpires: null, 12 | authFetch: false, 13 | authFailed: false, 14 | errorMessage: null, 15 | error: null, 16 | logoutFetch: false, 17 | forgot: { 18 | authForgetFetch: false, 19 | error: null, 20 | errorMessage: null, 21 | success: false 22 | } 23 | }, { 24 | [AUTH]: (state, {username}) => { 25 | return Object.assign({}, state, { 26 | authFetch: true, 27 | username 28 | }); 29 | }, 30 | [AUTH_SUCCESS]: (state, {token, expires}) => { 31 | return Object.assign({}, state, { 32 | token, 33 | tokenExpires: expires, 34 | authFetch: false 35 | }); 36 | }, 37 | [AUTH_FAIL]: (state, {ErrorMessage, error}) => { 38 | return Object.assign({}, state, { 39 | errorMessage: ErrorMessage, // ensure it works this is the format of the phone app api. 40 | error, 41 | authFetch: false 42 | }); 43 | }, 44 | [AUTH_LOGOUT]: (state) => { 45 | return Object.assign({}, state, { 46 | logoutFetch: true 47 | }); 48 | }, 49 | [AUTH_LOGOUT_SUCCESS]: (state) => { 50 | return Object.assign({}, state, { 51 | logoutFetch: false, 52 | token: null, 53 | username: null 54 | }); 55 | }, 56 | [AUTH_LOGOUT_FAIL]: (state) => { 57 | return Object.assign({}, state, { 58 | logoutFetchFail: true 59 | }); 60 | }, 61 | [AUTH_FORGOT]: (state) => { 62 | return Object.assign({}, state, { 63 | forgot: { 64 | authForgetFetch: true, 65 | error: null, 66 | errorMessage: null, 67 | success: false 68 | } 69 | }); 70 | }, 71 | [AUTH_FORGOT_SUCCESS]: (state) => { 72 | return Object.assign({}, state, { 73 | forgot: { 74 | authForgetFetch: false, 75 | success: true, 76 | error: null, 77 | errorMessage: null 78 | } 79 | }); 80 | }, 81 | [AUTH_FORGOT_FAIL]: (state, {error, message}) => { 82 | return Object.assign({}, state, { 83 | forgot: { 84 | authForgetFetch: false, 85 | error, 86 | errorMessage: message, 87 | success: false 88 | } 89 | }); 90 | } 91 | }); 92 | -------------------------------------------------------------------------------- /src/actions/auth.js: -------------------------------------------------------------------------------- 1 | import { routeActions } from 'react-router-redux'; 2 | import * as Api from '../utils/ApiClient'; 3 | export const AUTH = 'AUTH'; 4 | export const AUTH_SUCCESS = 'AUTH_SUCCESS'; 5 | export const AUTH_FAIL = 'AUTH_FAIL'; 6 | export const AUTH_FORGOT = 'AAUTH_FORGOT'; 7 | export const AUTH_FORGOT_FAIL = 'AUTH_FORGOT_FAIL'; 8 | export const AUTH_FORGOT_SUCCESS = 'AUTH_FORGOT_SUCCESS'; 9 | export const AUTH_LOGOUT = 'AUTH_LOGOUT'; 10 | export const AUTH_LOGOUT_SUCCESS = 'AUTH_LOGOUT_SUCCESS'; 11 | export const AUTH_LOGOUT_FAIL = 'AUTH_LOGOUT_FAIL'; 12 | 13 | const auth = (username) => ({type: AUTH, payload: {username}}); 14 | 15 | const loggedIn = (data) => ({ 16 | type: AUTH_SUCCESS, 17 | payload: data 18 | }); 19 | 20 | const failedLogin = (data) => ({ 21 | type: AUTH_FAIL, 22 | payload: data 23 | }); 24 | 25 | 26 | const _logout = () => ({ type: AUTH_LOGOUT }); 27 | 28 | const _logoutFail = (err) => ({ 29 | type: AUTH_LOGOUT_FAIL, 30 | payload: err 31 | }); 32 | 33 | const _logoutSuccess = () => ({ 34 | type: AUTH_LOGOUT_SUCCESS 35 | }); 36 | 37 | const forgot = (data) => ({ 38 | type: AUTH_FORGOT, 39 | payload: data 40 | }); 41 | 42 | const failedForgotPassword = (data) => ({ 43 | type: AUTH_FORGOT_FAIL, 44 | payload: data 45 | }); 46 | 47 | const successForgotPassword = (data) => ({ 48 | type: AUTH_FORGOT_SUCCESS, 49 | payload: data 50 | }); 51 | 52 | export const login = ({username, password}) => { 53 | 54 | return (dispatch) => { 55 | dispatch(auth(username)); 56 | 57 | Api.login(username, password) 58 | .then((data) => { 59 | dispatch(loggedIn(data)); 60 | sessionStorage.setItem('token', data.token); 61 | dispatch(routeActions.push('/')); 62 | }) 63 | .catch( (error) => dispatch(failedLogin(error))); 64 | }; 65 | 66 | }; 67 | 68 | export const logout = () => (dispatch) => { 69 | dispatch(_logout()); 70 | Api.logout() 71 | .then(() => { 72 | dispatch(_logoutSuccess()); 73 | dispatch(routeActions.replace('/login')); 74 | sessionStorage.removeItem('token'); 75 | }) 76 | .catch((err) => dispatch(_logoutFail(err))); 77 | }; 78 | 79 | export const loginCheckToken = () => { 80 | const token = sessionStorage.getItem('token'); 81 | 82 | return (dispatch) => { 83 | dispatch(loggedIn({token})); 84 | }; 85 | }; 86 | 87 | export const forgotPassword = ({username}) => { 88 | return (dispatch) => { 89 | dispatch(forgot()); 90 | 91 | Api.forgotPassword(username) 92 | .then((data) => { 93 | dispatch(successForgotPassword(data)); 94 | }) 95 | .catch( error => dispatch(failedForgotPassword(error))); 96 | }; 97 | }; 98 | -------------------------------------------------------------------------------- /src/containers/ForgotPassword/index.js: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from 'react'; 2 | import {connect} from 'react-redux'; 3 | import {bindActionCreators} from 'redux'; 4 | import {reduxForm} from 'redux-form'; 5 | 6 | import * as actionCreators from '../../actions/auth'; 7 | import PrimaryTextInput from '../../components/PrimaryTextInput'; 8 | import PrimaryButton from '../../components/PrimaryButton'; 9 | import InlineMessage from '../../components/InlineMessage'; 10 | 11 | import styles from '../Login/styles.css'; 12 | 13 | const mapStateToProps = ({auth}) => { 14 | return { 15 | auth 16 | }; 17 | }; 18 | 19 | const mapDispatchToProps = (dispatch) => ({ 20 | actions: bindActionCreators(actionCreators, dispatch) 21 | }); 22 | 23 | const validate = ({username}) => { 24 | const errors = {}; 25 | if (!username) { 26 | errors.username = 'Required'; 27 | } else if (username.length <= 6) { 28 | errors.username = 'Username must be 6 characters or more'; 29 | } 30 | 31 | return errors; 32 | }; 33 | 34 | class ForgotPassword extends Component { 35 | 36 | render() { 37 | const { 38 | fields: { 39 | username 40 | }, 41 | auth: {forgot: {authForgetFetch, errorMessage, success}}, 42 | handleSubmit, 43 | actions: { 44 | forgotPassword 45 | } 46 | } = this.props; 47 | 48 | return ( 49 |
forgotPassword(data))}> 50 |

FORGOT Password

51 | {!success && errorMessage && } 52 | {success && } 53 | {username.touched && username.error && } 54 | 55 |
56 | forgotPassword(data))} loading={authForgetFetch}> Send 57 |
58 | 59 | ); 60 | } 61 | } 62 | 63 | ForgotPassword.propTypes = { 64 | dispatch: PropTypes.func.isRequired, 65 | fields: PropTypes.shape({ 66 | username: PropTypes.object 67 | }), 68 | handleSubmit: PropTypes.func, 69 | submitting: PropTypes.bool, 70 | 71 | // Our defined Props 72 | auth: PropTypes.shape({ 73 | forgot: PropTypes.shape({ 74 | authForgetFetch: PropTypes.bool.isRequired, 75 | errorMessage: PropTypes.string, 76 | success: PropTypes.bool.isRequired 77 | }) 78 | }), 79 | actions: PropTypes.object 80 | }; 81 | 82 | 83 | export default connect(mapStateToProps, mapDispatchToProps)(reduxForm({ 84 | form: 'forgot-password', 85 | fields: ['username'], 86 | validate 87 | })(ForgotPassword)); 88 | -------------------------------------------------------------------------------- /src/containers/Login/index.js: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from 'react'; 2 | import {connect} from 'react-redux'; 3 | import {bindActionCreators} from 'redux'; 4 | import {routeActions} from 'react-router-redux'; 5 | import {reduxForm} from 'redux-form'; 6 | import CssModules from 'react-css-modules'; 7 | 8 | import * as actionCreators from '../../actions/auth'; 9 | import PrimaryTextInput from '../../components/PrimaryTextInput'; 10 | import PrimaryButton from '../../components/PrimaryButton'; 11 | import InlineMessage from '../../components/InlineMessage'; 12 | import InlineLink from '../../components/InlineLink'; 13 | 14 | import styles from './styles.css'; 15 | 16 | const mapStateToProps = ({auth}) => ({auth}); 17 | 18 | const mapDispatchToProps = (dispatch) => ({ 19 | actions: bindActionCreators(actionCreators, dispatch) 20 | }); 21 | 22 | const validate = ({username, password}) => { 23 | const errors = {}; 24 | if (!username) { 25 | errors.username = 'Required'; 26 | } else if (username.length <= 6) { 27 | errors.username = 'Username must be 6 characters or more'; 28 | } 29 | 30 | if (!password) { 31 | errors.password = 'Required'; 32 | } else if (password.length <= 8) { 33 | errors.password = 'password must be 8 characters or more'; 34 | } 35 | 36 | return errors; 37 | }; 38 | 39 | class Login extends Component { 40 | 41 | render() { 42 | const { 43 | fields: { 44 | username, 45 | password 46 | }, 47 | auth: {authFetch}, 48 | handleSubmit, 49 | dispatch, 50 | actions: { 51 | login 52 | } 53 | } = this.props; 54 | 55 | return ( 56 |
login(data))}> 57 |

Log in

58 | {username.touched && username.error && } 59 | 60 | {password.touched && password.error && } 61 | 62 | 63 |
64 | {dispatch(routeActions.push('/forgot'));}}>Forgot Password? 65 | login(data))} loading={authFetch}>LOG IN 66 |
67 | 68 | ); 69 | } 70 | } 71 | 72 | Login.propTypes = { 73 | dispatch: PropTypes.func.isRequired, 74 | fields: PropTypes.shape({ 75 | username: PropTypes.object, 76 | password: PropTypes.object 77 | }), 78 | handleSubmit: PropTypes.func, 79 | submitting: PropTypes.bool, 80 | 81 | // Our defined Props 82 | auth: PropTypes.shape({ 83 | errorMessage: PropTypes.string, 84 | authFetch: PropTypes.bool.isRequired, 85 | authFailed: PropTypes.bool.isRequired 86 | }), 87 | actions: PropTypes.object 88 | }; 89 | 90 | 91 | export default connect(mapStateToProps, mapDispatchToProps)(reduxForm({ 92 | form: 'login', 93 | fields: ['username', 'password'], 94 | validate 95 | })(CssModules(Login, styles))); 96 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const webpack = require('webpack'); 4 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 6 | const DEV = process.env.NODE_ENV !== 'production'; 7 | 8 | const config = { 9 | entry: ['./src/index.js'], 10 | debug: DEV, 11 | devtool: DEV ? 'source-map' : 'source-map', 12 | target: 'web', 13 | output: { 14 | path: __dirname + '/dist', 15 | publicPath: '/', 16 | filename: '[name].js' 17 | }, 18 | module: { 19 | loaders: [{ 20 | test: /\.jsx?$/, 21 | exclude: /(node_modules)/, 22 | loaders: ['babel'] 23 | }, { 24 | test: /\.jpe?g$|\.gif$|\.png$|\.ico$/, 25 | loader: 'url-loader?name=[path][name].[ext]&context=./src' 26 | }, { 27 | test: /\.html/, 28 | loader: 'file?name=[name].[ext]' 29 | }, { 30 | test: /\.css$/, 31 | // or ?sourceMap&modules&importLoaders=1!postcss-loader 32 | loader: DEV ? 'style!css?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]!postcss-loader' : ExtractTextPlugin.extract('style', 'css?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]!postcss-loader') 33 | // 'style-loader!css-loader?modules&importLoaders=1!postcss-loader' 34 | }, 35 | { test: /\.json/, loader: 'json'}, 36 | {test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, loader: 'url?mimetype=application/vnd.ms-fontobject'}, 37 | {test: /\.woff(2)?(\?v=\d+\.\d+\.\d+)?$/, loader: 'url?limit=10000&mimetype=application/font-woff' }, 38 | {test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, loader: 'url?limit=10000&mimetype=application/octet-stream' }, 39 | {test: /.svg(\?v=\d+\.\d+\.\d+)?$|.svg$/, loader: 'file?name=[path][name].[ext]&context=./src&mimetype=image/svg+xml'} 40 | ] 41 | }, 42 | plugins: [ 43 | // Output our index.html and inject the script tag 44 | new HtmlWebpackPlugin({ 45 | template: './src/index.html', 46 | inject: 'body' 47 | }), 48 | // Without this, Webpack would output styles inside JS - we prefer a separate CSS file 49 | new ExtractTextPlugin('styles.css'), 50 | 51 | new webpack.DefinePlugin({ 52 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) 53 | }) 54 | ], 55 | postcss: () => { 56 | return [ 57 | require('precss'), 58 | require('postcss-advanced-variables')({ 59 | variables: require('./src/colors') 60 | }), 61 | require('autoprefixer')({ browsers: ['last 2 versions'] }) 62 | ]; 63 | } 64 | }; 65 | 66 | if (DEV) { 67 | console.log('dev build'); 68 | config.entry.push('webpack-hot-middleware/client'); 69 | 70 | config.plugins.push( 71 | new webpack.HotModuleReplacementPlugin(), 72 | new webpack.NoErrorsPlugin() 73 | ); 74 | } else { 75 | console.log('production build'); 76 | // Minify JS for production 77 | config.plugins.push( 78 | new webpack.optimize.UglifyJsPlugin({ 79 | compress: { 80 | warnings: false, 81 | unused: true, 82 | dead_code: true 83 | } 84 | }), 85 | new webpack.DefinePlugin({ 86 | 'process.env': { 87 | 'NODE_ENV': JSON.stringify('"production"') 88 | } 89 | }) 90 | ); 91 | } 92 | 93 | module.exports = config; 94 | -------------------------------------------------------------------------------- /karma.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | // See issues for details on parts of this config. 3 | // https://github.com/airbnb/enzyme/issues/47 4 | // had issues loading sinon as its a dep of enzyme 5 | var argv = require('minimist')(process.argv.slice(2)); 6 | 7 | 8 | module.exports = (config) => { 9 | config.set({ 10 | browsers: [ 'PhantomJS' ], // run in Chrome 11 | singleRun: argv.watch ? false : true, // just run once by default 12 | frameworks: [ 'mocha' ], // use the mocha test framework 13 | files: [ 14 | 'tests.webpack.js' // just load this file 15 | ], 16 | preprocessors: { 17 | 'tests.webpack.js': [ 'webpack', 'sourcemap' ] // preprocess with webpack and our sourcemap loader 18 | }, 19 | reporters: [ 'dots' ], // report results in this format 20 | webpack: { // kind of a copy of your webpack config 21 | devtool: 'inline-source-map', // just do inline source maps instead of the default 22 | module: { 23 | preLoaders: [{ 24 | test: /\.(js|jsx)$/, 25 | include: /src/, 26 | exclude: /node_modules/, 27 | loader: 'isparta' 28 | }], 29 | loaders: [{ 30 | test: /\.jsx?$/, 31 | exclude: /(node_modules)/, 32 | loaders: ['babel'] 33 | }, { 34 | test: /\.jpe?g$|\.gif$|\.png$|\.ico$/, 35 | loader: 'url-loader?name=[path][name].[ext]&context=./src' 36 | }, { 37 | test: /\.html/, 38 | loader: 'file?name=[name].[ext]' 39 | }, { 40 | test: /\.css$/, 41 | // or ?sourceMap&modules&importLoaders=1!postcss-loader 42 | loader: 'style-loader!css-loader?modules&importLoaders=1!postcss-loader' 43 | }, { 44 | test: /\.json$/, 45 | loader: 'json' 46 | }, 47 | {test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, loader: 'file?mimetype=application/vnd.ms-fontobject'}, 48 | {test: /\.woff(2)?(\?v=\d+\.\d+\.\d+)?$/, loader: 'url?limit=10000&mimetype=application/font-woff' }, 49 | {test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, loader: 'url?limit=10000&mimetype=application/octet-stream' }, 50 | {test: /.svg(\?v=\d+\.\d+\.\d+)?$|.svg$/, loader: 'url?name=[path][name].[ext]&context=./src&mimetype=image/svg+xml'}, 51 | { 52 | test: /sinon\.js$/, 53 | loader: 'imports?define=>false,require=>false' 54 | } 55 | ] 56 | }, 57 | postcss: () => { 58 | return [ 59 | require('precss'), 60 | require('postcss-simple-vars')({ 61 | variables: () => { 62 | return require('./src/colors'); 63 | } 64 | }), 65 | require('autoprefixer')({ browsers: ['last 2 versions'] }) 66 | ]; 67 | }, 68 | isparta: { 69 | embedSource: true, 70 | noAutoWrap: true 71 | // these babel options will be passed only to isparta and not to babel-loader 72 | }, 73 | externals: { 74 | jsdom: 'window', 75 | cheerio: 'window', 76 | 'react/lib/ExecutionEnvironment': true, 77 | 'react/lib/ReactContext': 'window', 78 | 'text-encoding': 'window' 79 | }, 80 | resolve: { 81 | alias: { 82 | sinon: 'sinon/pkg/sinon' 83 | } 84 | } 85 | }, 86 | 87 | webpackServer: { 88 | noInfo: false // please don't spam the console when running in karma! 89 | } 90 | }); 91 | }; 92 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-redux-boilerplate", 3 | "version": "1.0.2", 4 | "description": "An upto date react redux boilerplate", 5 | "main": "server.js", 6 | "scripts": { 7 | "build": "NODE_ENV=production webpack --colors --progress", 8 | "lint": "eslint --ext .js --ext .jsx ./test ./src; exit 0", 9 | "start": "node server --colors", 10 | "test": "npm run lint && npm run mocha && npm run karma", 11 | "test:watch": "npm run mocha:watch & npm run karma:watch", 12 | "mocha": "mocha --compilers js:babel-core/register src/reducers --recursive", 13 | "mocha:watch": "npm run mocha -- --watch", 14 | "karma": "NODE_ENV=production karma start karma.config.js", 15 | "karma:watch": "npm run karma -- --watch" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/export-mike/react-redux-boilerplate.git" 20 | }, 21 | "author": "mike james ", 22 | "license": "UNLICENSED", 23 | "bugs": { 24 | "url": "https://github.com/export-mike/react-redux-boilerplate/issues" 25 | }, 26 | "homepage": "https://github.com/export-mike/react-redux-boilerplate#readme", 27 | "dependencies": { 28 | "autoprefixer": "^6.3.1", 29 | "babel-core": "^6.4.5", 30 | "babel-eslint": "^5.0.0-beta6", 31 | "babel-loader": "^6.2.1", 32 | "babel-polyfill": "^6.3.14", 33 | "css-loader": "^0.23.1", 34 | "eslint": "^1.10.3", 35 | "eslint-config-pebblecode": "^1.5.0", 36 | "eslint-plugin-react": "^3.15.0", 37 | "express": "^4.13.3", 38 | "extract-text-webpack-plugin": "^1.0.1", 39 | "file-loader": "^0.8.5", 40 | "fork-placeholders.js": "^4.0.1", 41 | "history": "^1.17.0", 42 | "html-webpack-plugin": "^1.7.0", 43 | "open-sans-fontface": "^1.4.0", 44 | "path": "^0.12.7", 45 | "postcss-loader": "^0.8.0", 46 | "precss": "^1.4.0", 47 | "react": "^0.14.6", 48 | "react-css-modules": "^3.6.8", 49 | "react-dom": "^0.14.6", 50 | "react-hot-loader": "^1.3.0", 51 | "react-redux": "^4.0.6", 52 | "react-router": "2.0.0-rc5", 53 | "react-router-redux": "^2.1.0", 54 | "redux": "^3.3.1", 55 | "redux-form": "^4.1.5", 56 | "redux-logger": "^2.3.2", 57 | "redux-thunk": "^1.0.3", 58 | "style-loader": "^0.13.0", 59 | "url-loader": "^0.5.7", 60 | "webpack": "^1.12.10" 61 | }, 62 | "devDependencies": { 63 | "babel-plugin-react-transform": "^2.0.0", 64 | "babel-polyfill": "^6.3.14", 65 | "babel-preset-es2015": "^6.3.13", 66 | "babel-preset-react": "^6.3.13", 67 | "babel-preset-react-hmre": "^1.0.0", 68 | "babel-preset-stage-0": "^6.3.13", 69 | "chai": "^3.4.1", 70 | "enzyme": "^1.4.1", 71 | "imports-loader": "^0.6.5", 72 | "isparta-loader": "^2.0.0", 73 | "json-loader": "^0.5.4", 74 | "karma": "^0.13.19", 75 | "karma-chrome-launcher": "^0.2.2", 76 | "karma-mocha": "^0.2.1", 77 | "karma-phantomjs-launcher": "^1.0.0", 78 | "karma-sourcemap-loader": "^0.3.7", 79 | "karma-webpack": "^1.7.0", 80 | "minimist": "^1.2.0", 81 | "mocha": "^2.3.4", 82 | "opn": "^3.0.3", 83 | "phantomjs-prebuilt": "^2.1.3", 84 | "pre-commit": "^1.1.2", 85 | "react-addons-test-utils": "^0.14.7", 86 | "react-transform-catch-errors": "^1.0.1", 87 | "react-transform-hmr": "^1.0.1", 88 | "redux-devtools": "^3.0.1", 89 | "redux-devtools-dock-monitor": "^1.0.1", 90 | "redux-devtools-log-monitor": "^1.0.2", 91 | "sinon": "^1.17.3", 92 | "webpack-dev-middleware": "^1.5.1", 93 | "webpack-dev-server": "^1.14.0", 94 | "webpack-hot-middleware": "^2.6.2", 95 | "webpack-hot-server": "^0.2.2", 96 | "webpack-merge": "^0.7.3" 97 | }, 98 | "pre-commit": [ 99 | "test" 100 | ] 101 | } 102 | -------------------------------------------------------------------------------- /src/reducers/test/auth_spec.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai'; 2 | import reducer from '../auth'; 3 | 4 | describe('auth reducer', () => { 5 | 6 | it('Handles AUTH', () => { 7 | const initialState = { 8 | token: null, 9 | tokenExpires: null, 10 | username: null, 11 | authFetch: false, 12 | authFailed: false, 13 | errorMessage: null, 14 | error: null, 15 | forgot: { 16 | authForgetFetch: false, 17 | error: null, 18 | errorMessage: null, 19 | success: false 20 | } 21 | }; 22 | 23 | const newState = reducer(initialState, {type: 'AUTH', payload: {username: 'admin'}}); 24 | 25 | expect(newState).to.eql({ 26 | token: null, 27 | tokenExpires: null, 28 | username: 'admin', 29 | authFetch: true, 30 | authFailed: false, 31 | errorMessage: null, 32 | error: null, 33 | forgot: { 34 | authForgetFetch: false, 35 | error: null, 36 | errorMessage: null, 37 | success: false 38 | } 39 | }); 40 | 41 | }); 42 | 43 | it('Handles AUTH_SUCCESS', () => { 44 | 45 | 46 | const initialState = { 47 | token: null, 48 | tokenExpires: null, 49 | username: 'admin', 50 | authFetch: true, 51 | authFailed: false, 52 | errorMessage: null, 53 | error: null, 54 | forgot: { 55 | authForgetFetch: false, 56 | error: null, 57 | errorMessage: null, 58 | success: false 59 | } 60 | }; 61 | 62 | const expireDate = new Date(); 63 | 64 | const newState = reducer(initialState, { 65 | type: 'AUTH_SUCCESS', 66 | payload: { 67 | token: '1234', 68 | expires: expireDate 69 | } 70 | }); 71 | 72 | expect(newState).to.eql({ 73 | token: '1234', 74 | tokenExpires: expireDate, 75 | username: 'admin', 76 | authFetch: false, 77 | authFailed: false, 78 | errorMessage: null, 79 | error: null, 80 | forgot: { 81 | authForgetFetch: false, 82 | error: null, 83 | errorMessage: null, 84 | success: false 85 | } 86 | }); 87 | 88 | }); 89 | 90 | it('Handles AUTH_FAIL', () => { 91 | 92 | const initialState = { 93 | token: null, 94 | tokenExpires: null, 95 | username: null, 96 | authFetch: true, 97 | authFailed: false, 98 | errorMessage: null, 99 | error: null, 100 | forgot: { 101 | authForgetFetch: false, 102 | error: null, 103 | errorMessage: null, 104 | success: false 105 | } 106 | }; 107 | 108 | const error = new Error(); 109 | 110 | const newState = reducer(initialState, { 111 | type: 'AUTH_FAIL', 112 | payload: { 113 | ErrorMessage: 'An Error Occurred', 114 | error 115 | } 116 | }); 117 | 118 | expect(newState).to.eql({ 119 | token: null, 120 | tokenExpires: null, 121 | username: null, 122 | authFetch: false, 123 | authFailed: false, 124 | errorMessage: 'An Error Occurred', 125 | error: error, 126 | forgot: { 127 | authForgetFetch: false, 128 | error: null, 129 | errorMessage: null, 130 | success: false 131 | } 132 | }); 133 | }); 134 | 135 | it('Handles AUTH_FORGOT', () => { 136 | 137 | const initialState = { 138 | token: null, 139 | tokenExpires: null, 140 | username: null, 141 | authFetch: true, 142 | authFailed: false, 143 | errorMessage: null, 144 | error: null, 145 | forgot: { 146 | authForgetFetch: false, 147 | error: null, 148 | errorMessage: null, 149 | success: false 150 | } 151 | }; 152 | 153 | const newState = reducer(initialState, {type: 'AUTH_FORGOT'}); 154 | 155 | expect(newState).to.eql({ 156 | token: null, 157 | tokenExpires: null, 158 | username: null, 159 | authFetch: true, 160 | authFailed: false, 161 | errorMessage: null, 162 | error: null, 163 | forgot: { 164 | authForgetFetch: false, 165 | error: null, 166 | errorMessage: null, 167 | success: false 168 | } 169 | }); 170 | 171 | }); 172 | 173 | it('Handles AUTH_FORGOT_SUCCESS', () => { 174 | 175 | const initialState = { 176 | token: null, 177 | tokenExpires: null, 178 | username: null, 179 | authFetch: true, 180 | authFailed: false, 181 | errorMessage: null, 182 | error: null, 183 | forgot: { 184 | authForgetFetch: false, 185 | error: null, 186 | errorMessage: null, 187 | success: false 188 | } 189 | }; 190 | 191 | const newState = reducer(initialState, {type: 'AUTH_FORGOT_SUCCESS'}); 192 | 193 | expect(newState).to.eql({ 194 | token: null, 195 | tokenExpires: null, 196 | username: null, 197 | authFetch: true, 198 | authFailed: false, 199 | errorMessage: null, 200 | error: null, 201 | forgot: { 202 | authForgetFetch: false, 203 | error: null, 204 | errorMessage: null, 205 | success: true 206 | } 207 | }); 208 | }); 209 | 210 | it('Handles AUTH_FORGOT_FAIL', () => { 211 | 212 | const initialState = { 213 | token: null, 214 | tokenExpires: null, 215 | username: null, 216 | authFetch: true, 217 | authFailed: false, 218 | errorMessage: null, 219 | error: null, 220 | forgot: { 221 | authForgetFetch: false, 222 | error: null, 223 | errorMessage: null, 224 | success: false 225 | } 226 | }; 227 | 228 | const error = new Error(); 229 | 230 | const newState = reducer(initialState, { 231 | type: 'AUTH_FORGOT_FAIL', 232 | payload: { 233 | message: 'Something Wrong Happened', 234 | error 235 | } 236 | }); 237 | 238 | expect(newState).to.eql({ 239 | token: null, 240 | tokenExpires: null, 241 | username: null, 242 | authFetch: true, 243 | authFailed: false, 244 | errorMessage: null, 245 | error: null, 246 | forgot: { 247 | authForgetFetch: false, 248 | error: error, 249 | errorMessage: 'Something Wrong Happened', 250 | success: false 251 | } 252 | }); 253 | }); 254 | 255 | it('Handles auth AUTH_LOGOUT', () => { 256 | const initialState = { 257 | token: '1234', 258 | tokenExpires: null, 259 | username: 'admin', 260 | authFetch: false, 261 | authFailed: false, 262 | errorMessage: null, 263 | error: null, 264 | logoutFetch: false, 265 | forgot: { 266 | authForgetFetch: false, 267 | error: null, 268 | errorMessage: null, 269 | success: false 270 | } 271 | }; 272 | 273 | const newState = reducer(initialState, { 274 | type: 'AUTH_LOGOUT' 275 | }); 276 | 277 | expect(newState).to.eql({ 278 | token: '1234', 279 | tokenExpires: null, 280 | username: 'admin', 281 | authFetch: false, 282 | authFailed: false, 283 | errorMessage: null, 284 | error: null, 285 | logoutFetch: true, 286 | forgot: { 287 | authForgetFetch: false, 288 | error: null, 289 | errorMessage: null, 290 | success: false 291 | } 292 | }); 293 | }); 294 | 295 | it('Handles auth AUTH_LOGOUT_SUCCESS', () => { 296 | const initialState = { 297 | token: '1234', 298 | tokenExpires: null, 299 | username: 'username', 300 | authFetch: false, 301 | authFailed: false, 302 | errorMessage: null, 303 | error: null, 304 | logoutFetch: true, 305 | forgot: { 306 | authForgetFetch: false, 307 | error: null, 308 | errorMessage: null, 309 | success: false 310 | } 311 | }; 312 | 313 | const newState = reducer(initialState, { 314 | type: 'AUTH_LOGOUT_SUCCESS' 315 | }); 316 | 317 | expect(newState).to.eql({ 318 | token: null, 319 | tokenExpires: null, 320 | username: null, 321 | authFetch: false, 322 | authFailed: false, 323 | errorMessage: null, 324 | error: null, 325 | logoutFetch: false, 326 | forgot: { 327 | authForgetFetch: false, 328 | error: null, 329 | errorMessage: null, 330 | success: false 331 | } 332 | }); 333 | }); 334 | 335 | it('Handles auth AUTH_LOGOUT_FAIL', () => { 336 | const initialState = { 337 | token: '1234', 338 | tokenExpires: null, 339 | username: 'admin', 340 | authFetch: false, 341 | authFailed: false, 342 | errorMessage: null, 343 | error: null, 344 | logoutFetch: false, 345 | logoutFetchFail: false, 346 | forgot: { 347 | authForgetFetch: false, 348 | error: null, 349 | errorMessage: null, 350 | success: false 351 | } 352 | }; 353 | 354 | const newState = reducer(initialState, { 355 | type: 'AUTH_LOGOUT_FAIL' 356 | }); 357 | 358 | expect(newState).to.eql({ 359 | token: '1234', 360 | tokenExpires: null, 361 | username: 'admin', 362 | authFetch: false, 363 | authFailed: false, 364 | errorMessage: null, 365 | error: null, 366 | logoutFetch: false, 367 | logoutFetchFail: true, 368 | forgot: { 369 | authForgetFetch: false, 370 | error: null, 371 | errorMessage: null, 372 | success: false 373 | } 374 | }); 375 | }); 376 | }); 377 | --------------------------------------------------------------------------------