├── .babelrc ├── .editorconfig ├── .gitignore ├── README.md ├── package.json └── src ├── app ├── actions │ └── index.js ├── components │ ├── App │ │ ├── App.css │ │ ├── App.jsx │ │ ├── __tests__ │ │ │ └── App.test.js │ │ ├── logo.svg │ │ └── package.json │ ├── Filter │ │ ├── Filter.css │ │ ├── Filter.jsx │ │ ├── FilterLink.jsx │ │ ├── __tests__ │ │ │ ├── FilterLink.test.js │ │ │ └── __snapshots__ │ │ │ │ └── FilterLink.test.js.snap │ │ └── package.json │ └── Todo │ │ ├── Todo.css │ │ ├── Todo.jsx │ │ ├── TodoList.jsx │ │ ├── __tests__ │ │ ├── Todo.test.js │ │ └── __snapshots__ │ │ │ └── Todo.test.js.snap │ │ └── package.json ├── containers │ ├── FilterActive.js │ ├── TodoAdd.js │ └── TodoVisible.js ├── main.js └── reducers │ ├── filters.js │ ├── index.js │ └── todos.js ├── config ├── jest │ └── mock.js └── webpack │ ├── webpack.config.dev.js │ ├── webpack.config.prod.js │ └── webpack.viewtags.js └── server ├── index.js ├── routes └── index.js └── views ├── error.hbs ├── index.hbs └── layouts └── main.hbs /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0", "react"] 3 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [**.{js,jsx,scss,css}] 2 | indent_style = tab 3 | indent_size = 4 4 | 5 | [**.yaml] 6 | index_style = space 7 | index_size = 2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | bower 4 | *.log 5 | *.zip 6 | .DS_Store 7 | lib 8 | coverage 9 | dist 10 | target 11 | build 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # React ES6 Boilerplate 3 | 4 | This boilerplate avoids making too many architectural decisions and instead focuses on providing tooling and build automation for development and production. 5 | 6 | 7 | ## Features 8 | 9 | - Babel v6x (support for all ES6 syntax client and server) 10 | - React v15x 11 | - Redux v3x 12 | - Jest v16x (inc. enzyme and react test renderer) 13 | - Express v4x (inc. hbs) 14 | - Webpack 15 | - PostCSS (with SASS support) 16 | 17 | 18 | ## Versions 19 | 20 | This has been tested and works with Node 4.3.0 and NPM 2.10.1 however, you will see a huge (buildtime) performance improvement using Node 6+ and NPM 3+ due to Babel module discovery improvements. 21 | 22 | 23 | ## Get started 24 | 25 | Install required dependencies via npm 26 | ``` 27 | npm install 28 | ``` 29 | Build application and start watching for changes 30 | ``` 31 | npm run build 32 | ``` 33 | Run tests (optional) - outputs reports to terminal and ./coverage 34 | ``` 35 | npm test 36 | ``` 37 | Run application (new terminal window) 38 | ``` 39 | npm start 40 | ``` 41 | 42 | Webpack will watch for changes and automatically recompile your javascript and css on the fly to your ./build folder 43 | 44 | 45 | ## Build and runtime pattern 46 | 47 | - Babel CLI takes care of transpilation and webpack ensures fast recompile 48 | - Avoided using hot reloader modules 49 | - Avoided using babel-polyfill - this attaches to global 50 | - Avoided babel-node - this is slow 51 | - PostCSS in favour of SASS - I have implemented precss and cssnext, enabling sass syntax as well as future selectors 52 | - Webpack dev/prod configs take care of babel and css transpilation 53 | - Processed files are moved to a /build folder and the application runs from there 54 | - When a standard build is run, webpack watch is used to track only files with changes and re-process them to the build folder 55 | - Refreshing the browser will show latest React or CSS changes. This results in a very fast dev experience. 56 | 57 | 58 | Dev time build - will build server and assets uncompressed to ./build and watch css/js for changes 59 | ``` 60 | npm run build 61 | ``` 62 | Production build - will build server and assets compressed to ./build 63 | ``` 64 | npm run build:prod 65 | ``` 66 | Distribution/deployment - will run build:prod and also package the server and assets to ./dist as separate zip files 67 | ``` 68 | npm run package 69 | ``` 70 | 71 | 72 | ## Express 73 | 74 | - A simple Express server is supplied with handlebars view engine 75 | - A virtual path is configured for assets in the ./build folder 76 | 77 | The only 'custom' setting is the attachment of script and style tags to app.locals 78 | 79 | ``` 80 | app.locals.scripts = tags.scripts; 81 | app.locals.styles = tags.styles; 82 | ``` 83 | This is done to allow handlebars views to seamlessly inject script or style tags. The 'viewtags' module is a simple function used by webpack to determine the filenames of js/css for the latest build. 84 | In production build (build:prod) this also enables easy injection of hashed filenames. 85 | 86 | The viewtags module currently creates tags for the {{{scripts}}} and {{{styles}}} hbs placeholders, more can be easily supported by editing the code in config/webpack/webpack.viewtags.js: 87 | 88 | ``` 89 | var assetTagConfig = { 90 | scripts: scripts, 91 | styles: styles, 92 | inlineStyles: '', 93 | deferredScripts: '', 94 | deferredStyles: '' 95 | } 96 | ``` 97 | 98 | 99 | ## Testing 100 | 101 | Jest was chosen as the test suite as it supports Jasmine syntax, full coverage reports, and is being actively developed by Facebook. 102 | 103 | All Jest configuration settings can be found in package.json, it has been configured to: 104 | 105 | - Find any file with a name containing 'test.js' 106 | - Ignore config, node_module, and other commonly unwanted areas 107 | - Mock css and images as objects to avoid require issues 108 | - By using the --coverage flag, a coverage report is shown in terminal and written to ./coverage 109 | - AirBnb's Enzyme is also included for shallow render support as required 110 | 111 | 112 | ## Load Testing 113 | 114 | ``` 115 | 3.1 GHz Intel Core i7 116 | 16 GB 1867 MHz DDR3 117 | 118 | 10 Test iterations X ab -k -c 20 -n 250 119 | 120 | Average: 121 | 122 | Concurrency Level: 20 123 | Time taken for tests: 0.059 seconds 124 | Requests per second: 4212.87 [#/sec] (mean) 125 | ``` 126 | 127 | ## TODO 128 | 129 | - Add disabled/commented server-side render example 130 | - Add performance profile tooling 131 | - Cross-platform shell commands for npm scripts -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-boilerplate", 3 | "version": "1.0.0", 4 | "engines": { 5 | "node": ">=6.3.1", 6 | "npm": "~3.10.3" 7 | }, 8 | "description": "React ES6 Boilerplate", 9 | "keywords": [ 10 | "react", 11 | "express", 12 | "redux", 13 | "boilerplate", 14 | "starterkit", 15 | "es6", 16 | "handlebars", 17 | "jest", 18 | "postCSS" 19 | ], 20 | "author": "Matt Davis", 21 | "license": "ISC", 22 | "scripts": { 23 | "start": "node ./build/server/index", 24 | "test": "jest -u --coverage", 25 | "build": "npm run build:clean && npm run build:server && npm run build:webpack:dev", 26 | "build:prod": "npm run build:clean && npm run build:server && npm run build:webpack:prod", 27 | "build:clean": "rm -rf build && mkdir -p build && mkdir -p build/server && mkdir -p build/assets", 28 | "build:server": "babel src/server --out-dir build/server --copy-files", 29 | "build:webpack:dev": "webpack --watch --progress --config src/config/webpack/webpack.config.dev.js", 30 | "build:webpack:prod": "webpack --progress --config src/config/webpack/webpack.config.prod.js", 31 | "package": "npm run build:prod && npm run package:server && npm run package:assets", 32 | "package:server": "mkdir -p dist/ && cd build/server && zip -r ../../dist/${npm_package_name}-${npm_package_version}-${BUILD_NUMBER:-0}.zip . -x .git\\* node_modules\\* target\\*", 33 | "package:assets": "cd build/assets && zip -r ../../dist/${npm_package_name}-assets-${npm_package_version}-${BUILD_NUMBER:-0}.zip ." 34 | }, 35 | "repository": { 36 | "type": "git", 37 | "url": "https://github.com/mpfdavis/react-es6-boilerplate.git" 38 | }, 39 | "dependencies": { 40 | "express": "^4.14.0", 41 | "express-handlebars": "^3.0.0", 42 | "outputcache": "^3.7.0", 43 | "react": "^15.3.2", 44 | "react-dom": "^15.3.2", 45 | "react-redux": "^4.4.5", 46 | "react-router": "^3.0.0", 47 | "react-router-redux": "^4.0.6", 48 | "redux": "^3.6.0", 49 | "redux-thunk": "^2.1.0" 50 | }, 51 | "devDependencies": { 52 | "babel-cli": "^6.18.0", 53 | "babel-core": "^6.18.0", 54 | "babel-jest": "^16.0.0", 55 | "babel-loader": "^6.2.7", 56 | "babel-plugin-react-require": "^3.0.0", 57 | "babel-plugin-transform-runtime": "^6.15.0", 58 | "babel-polyfill": "^6.16.0", 59 | "babel-preset-es2015": "^6.18.0", 60 | "babel-preset-react": "^6.16.0", 61 | "babel-preset-stage-0": "^6.16.0", 62 | "babel-register": "^6.18.0", 63 | "css-loader": "^0.25.0", 64 | "enzyme": "^2.5.1", 65 | "extract-text-webpack-plugin": "^1.0.1", 66 | "file-loader": "^0.9.0", 67 | "image-webpack-loader": "^3.0.0", 68 | "jest-cli": "^16.0.2", 69 | "json-loader": "^0.5.4", 70 | "node-sass": "^3.10.1", 71 | "postcss-cssnext": "^2.8.0", 72 | "postcss-import": "^8.1.2", 73 | "postcss-loader": "^1.1.0", 74 | "postcss-reporter": "^1.4.1", 75 | "precss": "^1.4.0", 76 | "react-addons-test-utils": "^15.3.2", 77 | "react-test-renderer": "^15.3.2", 78 | "style-loader": "^0.13.1", 79 | "webpack": "^1.13.3" 80 | }, 81 | "jest": { 82 | "moduleFileExtensions": [ 83 | "js", 84 | "jsx", 85 | "json" 86 | ], 87 | "testRegex": ".*.test.(js|jsx)$", 88 | "testPathIgnorePatterns": [ 89 | "/node_modules/", 90 | "/bower_components", 91 | "/build", 92 | "/src/config", 93 | "/coverage" 94 | ], 95 | "moduleDirectories": [ 96 | "node_modules", 97 | "bower_components" 98 | ], 99 | "moduleNameMapper": { 100 | "^.+\\.(css|less|scss|sass|gif|bmp|jpg|jpeg|png|ttf|eot|svg|woff)$": "src/config/jest/mock.js" 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/app/actions/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let _nextTodoId = 0; 4 | 5 | export const addTodo = (text) => { 6 | return { 7 | type: 'ADD_TODO', 8 | id: _nextTodoId++, 9 | text 10 | }; 11 | }; 12 | 13 | export const setFilter = (filter) => { 14 | return { 15 | type: 'SET_FILTER', 16 | filter 17 | }; 18 | }; 19 | 20 | export const toggleTodo = (id) => { 21 | return { 22 | type: 'TOGGLE_TODO', 23 | id 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /src/app/components/App/App.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: #484848; 3 | } 4 | 5 | .app { 6 | font-family: proxima-nova,"Helvetica Neue",Helvetica,Roboto,Arial,sans-serif; 7 | text-align: center; 8 | } 9 | 10 | .app__logo { 11 | animation: app__logo--spin infinite 10s linear; 12 | height: 140px; 13 | } 14 | 15 | @keyframes app__logo--spin { 16 | from { transform: rotate(0deg); } 17 | to { transform: rotate(360deg); } 18 | } 19 | 20 | .app__header { 21 | padding: 20px 0 0 0; 22 | color: rgb(97, 218, 251); 23 | } 24 | 25 | .app__intro { 26 | padding-bottom:20px; 27 | font-size: 16px; 28 | color: white; 29 | } 30 | 31 | .app__example { 32 | max-width: 500px; 33 | margin:50px auto 0 auto; 34 | border: 10px solid white; 35 | border-radius:3px; 36 | background: white; 37 | } 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /src/app/components/App/App.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import React from 'react'; 3 | import Filter from '../Filter'; 4 | import TodoAdd from '../../containers/TodoAdd'; 5 | import TodoVisible from '../../containers/TodoVisible'; 6 | import logo from './logo.svg'; 7 | import './App.css'; 8 | 9 | const App = () => ( 10 |
11 |
12 |
13 | logo 14 |

Welcome to React

15 |
16 |

17 | To get started, edit src/app and save to reload. 18 |

19 |
20 |
21 |
22 |
23 |

Todo list

24 |
25 | 26 | 27 |
28 | 29 |
30 |
31 |
32 |
33 | ); 34 | 35 | export default App; 36 | -------------------------------------------------------------------------------- /src/app/components/App/__tests__/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | import App from '../App'; 4 | 5 | const app = shallow( 6 | 7 | ) 8 | 9 | describe('App component', () => { 10 | 11 | it('should render a h1', () => { 12 | expect(app.find('h1')).toBeDefined(); 13 | }); 14 | 15 | it('should render an img', () => { 16 | expect(app.find('img')).toBeDefined(); 17 | }); 18 | 19 | }); 20 | -------------------------------------------------------------------------------- /src/app/components/App/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/app/components/App/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "App", 3 | "version": "0.0.0", 4 | "private": true, 5 | "main": "./App.jsx" 6 | } -------------------------------------------------------------------------------- /src/app/components/Filter/Filter.css: -------------------------------------------------------------------------------- 1 | .filter__button { 2 | margin-right:10px; 3 | box-shadow:inset 0px 1px 0px 0px #ffffff; 4 | background:linear-gradient(to top, #f9f9f9 5%, #e9e9e9 100%); 5 | background-color:#f9f9f9; 6 | border-radius:3px; 7 | border:1px solid #dcdcdc; 8 | display:inline-block; 9 | cursor:pointer; 10 | color:#666666; 11 | font-family:Arial; 12 | font-size:12px; 13 | font-weight:bold; 14 | padding:8px 20px; 15 | text-decoration:none; 16 | text-shadow:0px 1px 0px #ffffff; 17 | &.filter__button--active { 18 | background:linear-gradient(to top, #333 5%, #484848 100%); 19 | color: white; 20 | box-shadow:none; 21 | text-shadow:none; 22 | } 23 | } -------------------------------------------------------------------------------- /src/app/components/Filter/Filter.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import React from 'react'; 3 | import FilterLink from '../../containers/FilterActive'; 4 | import './Filter.css'; 5 | 6 | const Filter = () => ( 7 |
8 | All 9 | Todo 10 | Done 11 |
12 | ); 13 | 14 | export default Filter; 15 | -------------------------------------------------------------------------------- /src/app/components/Filter/FilterLink.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import React, { PropTypes } from 'react'; 3 | 4 | const FilterLink = (props) => { 5 | 6 | const handleClick = (e) => { 7 | e.preventDefault(); 8 | props.onClick(); 9 | }; 10 | 11 | if (props.isActive) { 12 | return {props.children}; 13 | } 14 | 15 | return {props.children}; 16 | 17 | }; 18 | 19 | FilterLink.propTypes = { 20 | isActive: PropTypes.bool.isRequired, 21 | children: PropTypes.node.isRequired, 22 | onClick: PropTypes.func.isRequired 23 | } 24 | 25 | export default FilterLink; 26 | -------------------------------------------------------------------------------- /src/app/components/Filter/__tests__/FilterLink.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import React from 'react'; 3 | import FilterLink from '../FilterLink'; 4 | import renderer from 'react-test-renderer'; 5 | 6 | describe('FilterLink', () => { 7 | 8 | const mockEvent = new Event("onClick"); 9 | mockEvent.preventDefault = (e) => {} 10 | 11 | const mockHandler = () => {} 12 | 13 | it('renders correctly', () => { 14 | const tree = renderer.create( 15 | All 16 | ).toJSON(); 17 | expect(tree).toMatchSnapshot(); 18 | }); 19 | 20 | it('renders as a span when isActive is true', () => { 21 | const tree = renderer.create( 22 | All 23 | ).toJSON(); 24 | expect(tree).toMatchSnapshot(); 25 | }); 26 | 27 | it('renders as with active css class when isActive is true', () => { 28 | const tree = renderer.create( 29 | All 30 | ).toJSON(); 31 | expect(tree).toMatchSnapshot(); 32 | }); 33 | 34 | it('fires event for supplied onClick handler', () => { 35 | const component = renderer.create( 36 | All 37 | ); 38 | 39 | let tree = component.toJSON(); 40 | expect(tree).toMatchSnapshot(); 41 | 42 | // manually trigger the callback 43 | tree.props.onClick(mockEvent); 44 | // re-rendering 45 | tree = component.toJSON(); 46 | expect(tree).toMatchSnapshot(); 47 | 48 | }); 49 | 50 | }); -------------------------------------------------------------------------------- /src/app/components/Filter/__tests__/__snapshots__/FilterLink.test.js.snap: -------------------------------------------------------------------------------- 1 | exports[`FilterLink fires event for supplied onClick handler 1`] = ` 2 | 6 | All 7 | 8 | `; 9 | 10 | exports[`FilterLink fires event for supplied onClick handler 2`] = ` 11 | 15 | All 16 | 17 | `; 18 | 19 | exports[`FilterLink renders as a span when isActive is true 1`] = ` 20 | 22 | All 23 | 24 | `; 25 | 26 | exports[`FilterLink renders as with active css class when isActive is true 1`] = ` 27 | 29 | All 30 | 31 | `; 32 | 33 | exports[`FilterLink renders correctly 1`] = ` 34 | 38 | All 39 | 40 | `; 41 | -------------------------------------------------------------------------------- /src/app/components/Filter/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Filter", 3 | "version": "0.0.0", 4 | "private": true, 5 | "main": "./Filter.jsx" 6 | } -------------------------------------------------------------------------------- /src/app/components/Todo/Todo.css: -------------------------------------------------------------------------------- 1 | .todo { 2 | font-family: proxima-nova,"Helvetica Neue",Helvetica,Roboto,Arial,sans-serif; 3 | max-width: 300px; 4 | margin: 20px auto 20px auto; 5 | } 6 | 7 | .todo__header { 8 | color: #484848; 9 | } 10 | 11 | .todo__list { 12 | padding:0; 13 | list-style-type: none; 14 | } 15 | 16 | .todo__item { 17 | background: #484848; 18 | padding: 10px; 19 | margin-bottom: 5px; 20 | border-radius:3px; 21 | color: white; 22 | &:hover { 23 | cursor: pointer; 24 | } 25 | } 26 | 27 | .todo__item--done{ 28 | display: inline-block; 29 | margin-left: 10px; 30 | margin-bottom: 1px; 31 | &:after { 32 | content: ''; 33 | display: block; 34 | width: 5px; 35 | height: 10px; 36 | border: solid white; 37 | border-width: 0 2px 2px 0; 38 | transform: rotate(45deg); 39 | } 40 | } 41 | 42 | .todo__input { 43 | height:5px; 44 | min-width: 200px; 45 | margin-right: 10px; 46 | padding:10px; 47 | font-size:12px; 48 | } 49 | 50 | .todo__button { 51 | box-shadow:inset 0px 1px 0px 0px #ffffff; 52 | background:linear-gradient(to top, #f9f9f9 5%, #e9e9e9 100%); 53 | background-color:#f9f9f9; 54 | border-radius:3px; 55 | border:1px solid #dcdcdc; 56 | display:inline-block; 57 | cursor:pointer; 58 | color:#666666; 59 | font-family:Arial; 60 | font-size:12px; 61 | font-weight:bold; 62 | padding:8px 20px; 63 | text-decoration:none; 64 | text-shadow:0px 1px 0px #ffffff; 65 | } -------------------------------------------------------------------------------- /src/app/components/Todo/Todo.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import React, { PropTypes } from 'react'; 3 | 4 | const Todo = (props) => ( 5 |
  • 6 | {props.text} {props.completed ? : false} 7 |
  • 8 | ); 9 | 10 | Todo.propTypes = { 11 | onClick: PropTypes.func.isRequired, 12 | completed: PropTypes.bool.isRequired, 13 | text: PropTypes.string.isRequired 14 | }; 15 | 16 | export default Todo; 17 | -------------------------------------------------------------------------------- /src/app/components/Todo/TodoList.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import React, { PropTypes } from 'react'; 3 | import Todo from './Todo'; 4 | import './Todo.css'; 5 | 6 | const TodoList = (props) => ( 7 |
      8 | {props.todos.map(todo => 9 | props.onClick(todo.id) } /> 10 | ) } 11 |
    12 | ); 13 | 14 | TodoList.propTypes = { 15 | todos: PropTypes.arrayOf(PropTypes.shape({ 16 | id: PropTypes.number.isRequired, 17 | completed: PropTypes.bool.isRequired, 18 | text: PropTypes.string.isRequired 19 | }).isRequired).isRequired, 20 | onClick: PropTypes.func.isRequired 21 | }; 22 | 23 | export default TodoList; 24 | -------------------------------------------------------------------------------- /src/app/components/Todo/__tests__/Todo.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import React from 'react'; 3 | import Todo from '../Todo'; 4 | import renderer from 'react-test-renderer'; 5 | 6 | describe('Todo', () => { 7 | 8 | const mockEvent = new Event("onClick"); 9 | mockEvent.preventDefault = (e) => { } 10 | 11 | const mockHandler = () => { } 12 | 13 | it('renders correctly', () => { 14 | const tree = renderer.create( 15 | 16 | ).toJSON(); 17 | expect(tree).toMatchSnapshot(); 18 | }); 19 | 20 | it('renders with span when completed is true', () => { 21 | const tree = renderer.create( 22 | 23 | ).toJSON(); 24 | expect(tree).toMatchSnapshot(); 25 | }); 26 | 27 | it('fires event for supplied onClick handler', () => { 28 | const component = renderer.create( 29 | 30 | ); 31 | 32 | let tree = component.toJSON(); 33 | expect(tree).toMatchSnapshot(); 34 | 35 | // manually trigger the callback 36 | tree.props.onClick(mockEvent); 37 | // re-rendering 38 | tree = component.toJSON(); 39 | expect(tree).toMatchSnapshot(); 40 | 41 | }); 42 | 43 | }); -------------------------------------------------------------------------------- /src/app/components/Todo/__tests__/__snapshots__/Todo.test.js.snap: -------------------------------------------------------------------------------- 1 | exports[`Todo fires event for supplied onClick handler 1`] = ` 2 |
  • 5 | This is a todo item 6 | 7 |
  • 8 | `; 9 | 10 | exports[`Todo fires event for supplied onClick handler 2`] = ` 11 |
  • 14 | This is a todo item 15 | 16 |
  • 17 | `; 18 | 19 | exports[`Todo renders correctly 1`] = ` 20 |
  • 23 | This is a todo item 24 | 25 |
  • 26 | `; 27 | 28 | exports[`Todo renders with span when completed is true 1`] = ` 29 |
  • 32 | This is a todo item 33 | 34 | 36 |
  • 37 | `; 38 | -------------------------------------------------------------------------------- /src/app/components/Todo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Todo", 3 | "version": "0.0.0", 4 | "private": true, 5 | "main": "./Todo.jsx" 6 | } -------------------------------------------------------------------------------- /src/app/containers/FilterActive.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import { connect } from 'react-redux'; 3 | import { setFilter } from '../actions'; 4 | import FilterLink from '../components/Filter/FilterLink'; 5 | 6 | const mapStateToProps = (state, ownProps) => { 7 | return { 8 | isActive: ownProps.filter === state.filters 9 | }; 10 | }; 11 | 12 | const mapDispatchToProps = (dispatch, ownProps) => { 13 | return { 14 | onClick: () => { 15 | dispatch(setFilter(ownProps.filter)) 16 | } 17 | }; 18 | }; 19 | 20 | const Filter = connect( 21 | mapStateToProps, 22 | mapDispatchToProps 23 | )(FilterLink); 24 | 25 | export default Filter; 26 | -------------------------------------------------------------------------------- /src/app/containers/TodoAdd.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import React from 'react'; 3 | import { connect } from 'react-redux'; 4 | import { addTodo } from '../actions'; 5 | 6 | let AddTodo = ({ dispatch }) => { 7 | 8 | let inputAsRef; 9 | 10 | const handleSubmit = (e) => { 11 | e.preventDefault(); 12 | if (!inputAsRef.value.trim()) { 13 | return; 14 | } 15 | dispatch(addTodo(inputAsRef.value)); 16 | inputAsRef.value = ''; 17 | } 18 | 19 | return ( 20 |
    21 |
    22 | { inputAsRef = node } } /> 23 | 24 |
    25 |
    26 | ) 27 | }; 28 | 29 | AddTodo = connect()(AddTodo); 30 | 31 | export default AddTodo; 32 | -------------------------------------------------------------------------------- /src/app/containers/TodoVisible.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import { connect } from 'react-redux'; 3 | import { toggleTodo } from '../actions'; 4 | import TodoList from '../components/Todo/TodoList'; 5 | 6 | const getVisibleTodos = (todos, filter) => { 7 | switch (filter) { 8 | case 'SHOW_ALL': 9 | return todos; 10 | case 'SHOW_COMPLETED': 11 | return todos.filter(t => t.completed); 12 | case 'SHOW_ACTIVE': 13 | return todos.filter(t => !t.completed); 14 | } 15 | } 16 | 17 | const mapStateToProps = (state) => { 18 | return { 19 | todos: getVisibleTodos(state.todos, state.filters) 20 | }; 21 | }; 22 | 23 | const mapDispatchToProps = (dispatch) => { 24 | return { 25 | onClick: (id) => { 26 | dispatch(toggleTodo(id)) 27 | } 28 | }; 29 | }; 30 | 31 | const VisibleTodoList = connect( 32 | mapStateToProps, 33 | mapDispatchToProps 34 | )(TodoList); 35 | 36 | export default VisibleTodoList; 37 | -------------------------------------------------------------------------------- /src/app/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import { Provider } from 'react-redux'; 5 | import { createStore } from 'redux'; 6 | import combinedReducers from './reducers'; 7 | import App from './components/App'; 8 | 9 | let store = createStore(combinedReducers); 10 | 11 | ReactDOM.render( 12 | 13 | 14 | , 15 | document.getElementById('root') 16 | ) 17 | -------------------------------------------------------------------------------- /src/app/reducers/filters.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const filters = (state = 'SHOW_ALL', action) => { 4 | switch (action.type) { 5 | case 'SET_FILTER': 6 | return action.filter; 7 | default: 8 | return state; 9 | } 10 | }; 11 | 12 | export default filters; 13 | -------------------------------------------------------------------------------- /src/app/reducers/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import { combineReducers } from 'redux'; 3 | import todos from './todos'; 4 | import filters from './filters'; 5 | 6 | const todoApp = combineReducers({ 7 | todos, 8 | filters 9 | }); 10 | 11 | export default todoApp; 12 | -------------------------------------------------------------------------------- /src/app/reducers/todos.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const todo = (state, action) => { 4 | switch (action.type) { 5 | case 'ADD_TODO': 6 | return { 7 | id: action.id, 8 | text: action.text, 9 | completed: false 10 | }; 11 | case 'TOGGLE_TODO': 12 | if (state.id !== action.id) { 13 | return state; 14 | } 15 | 16 | return Object.assign({}, state, { 17 | completed: !state.completed 18 | }) 19 | default: 20 | return state; 21 | } 22 | } 23 | 24 | const todos = (state = [], action) => { 25 | switch (action.type) { 26 | case 'ADD_TODO': 27 | return [ 28 | ...state, 29 | todo(undefined, action) 30 | ]; 31 | case 'TOGGLE_TODO': 32 | return state.map(t => 33 | todo(t, action) 34 | ); 35 | default: 36 | return state; 37 | } 38 | } 39 | 40 | export default todos; 41 | -------------------------------------------------------------------------------- /src/config/jest/mock.js: -------------------------------------------------------------------------------- 1 | //mock none-js filetype response for jest tests 2 | module.exports = {}; -------------------------------------------------------------------------------- /src/config/webpack/webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 4 | const viewtags = require('./webpack.viewtags.js'); 5 | 6 | module.exports = { 7 | devtool: 'eval', 8 | entry: [ 9 | path.join(__dirname, '../../app/main') 10 | ], 11 | output: { 12 | path: path.join(__dirname, '../../../build'), 13 | filename: 'assets/scripts-dev.js', 14 | publicPath: '/static/' 15 | }, 16 | plugins: [ 17 | new webpack.NoErrorsPlugin(), 18 | new webpack.DefinePlugin({ 19 | 'process.env.NODE_ENV': JSON.stringify('development') 20 | }), 21 | new ExtractTextPlugin('assets/styles-dev.css'), 22 | function () { this.plugin('done', viewtags); } 23 | ], 24 | resolve: { 25 | extensions: ['', '.js', '.jsx'] 26 | }, 27 | module: { 28 | loaders: [ 29 | { 30 | test: /\.(js|jsx)$/i, 31 | exclude: [/node_modules/], 32 | loaders: ['babel'], 33 | include: path.join(__dirname, '../../app') 34 | }, 35 | { 36 | test: /\.css$/, 37 | loader: ExtractTextPlugin.extract('style', ['css', 'postcss']) 38 | }, 39 | { 40 | test: /\.json$/i, 41 | loader: 'json' 42 | }, 43 | { 44 | test: /\.(png|jpg|jpeg|gif|svg)$/i, 45 | loader: 'file?name=assets/images/[name]-[hash:8].[ext]!image?optimizationLevel=7&bypassOnDebug' 46 | }, 47 | { 48 | test: /\.(woff|woff2|ttf|eot)$/i, 49 | loader: 'file?name=assets/fonts/[name]-[hash:8].[ext]' 50 | } 51 | ] 52 | }, 53 | postcss: function () { 54 | return [ 55 | require('precss'), 56 | require("postcss-cssnext")(), 57 | require("postcss-reporter")() 58 | ] 59 | } 60 | }; 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /src/config/webpack/webpack.config.prod.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 4 | const viewtags = require('./webpack.viewtags.js'); 5 | 6 | module.exports = { 7 | devtool: 'cheap-module-source-map', 8 | entry: [ 9 | path.join(__dirname, '../../app/main') 10 | ], 11 | output: { 12 | path: path.join(__dirname, '../../../build'), 13 | filename: 'assets/[name]-[hash:8]-scripts-min.js', 14 | publicPath: '/static/' 15 | }, 16 | plugins: [ 17 | new webpack.optimize.OccurenceOrderPlugin(), 18 | new webpack.DefinePlugin({ 19 | 'process.env.NODE_ENV': JSON.stringify('production') 20 | }), 21 | new webpack.optimize.UglifyJsPlugin({ 22 | compress: { screw_ie8: true, warnings: false }, mangle: { screw_ie8: true }, output: { comments: false, screw_ie8: true } 23 | }), 24 | new ExtractTextPlugin('assets/[name]-[hash:8]-styles-min.css'), 25 | function () { this.plugin('done', viewtags); } 26 | ], 27 | resolve: { 28 | extensions: ['', '.js', '.jsx'] 29 | }, 30 | module: { 31 | loaders: [ 32 | { 33 | test: /\.(js|jsx)?/, 34 | exclude: [/node_modules/], 35 | loaders: ['babel'], 36 | include: path.join(__dirname, '../../app') 37 | }, 38 | { 39 | test: /\.css$/, 40 | loader: ExtractTextPlugin.extract('style', ['css', 'postcss']) 41 | }, 42 | { 43 | test: /\.json$/i, 44 | loader: 'json' 45 | }, 46 | { 47 | test: /\.(png|jpg|jpeg|gif|svg)$/i, 48 | loader: 'file?name=assets/images/[name]-[hash:8].[ext]!image?optimizationLevel=7&bypassOnDebug' 49 | }, 50 | { 51 | test: /\.(woff|woff2|ttf|eot)$/i, 52 | loader: 'file?name=assets/fonts/[name]-[hash:8].[ext]' 53 | } 54 | ] 55 | }, 56 | postcss: function () { 57 | return [ 58 | require('precss'), 59 | require("postcss-cssnext")(), 60 | require("postcss-reporter")() 61 | ] 62 | } 63 | }; 64 | -------------------------------------------------------------------------------- /src/config/webpack/webpack.viewtags.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | function setAssetTagFileNames(stats) { 5 | 6 | var webpackStats = stats.toJson(); 7 | var webpackPublicPath = webpackStats.publicPath || ''; 8 | 9 | function getStyleTag(html, style) { 10 | return html + '\n' 11 | } 12 | 13 | function getScriptTag(html, script) { 14 | return html + '\n'; 15 | } 16 | 17 | function getAssetChunks(name, ext) { 18 | 19 | var chunks = webpackStats.assetsByChunkName[name]; 20 | if (!Array.isArray(chunks)) { chunks = [chunks]; } 21 | return chunks.filter(function (chunk) { 22 | return ext.test(path.extname(chunk)); 23 | }).map(function (chunk) { 24 | return webpackPublicPath + chunk; 25 | }); 26 | 27 | }; 28 | 29 | var appEntryFileName = 'main'; 30 | var scripts = getAssetChunks(appEntryFileName, /\.js$/).reduce(getScriptTag, ''); 31 | var styles = getAssetChunks(appEntryFileName, /\.css$/).reduce(getStyleTag, ''); 32 | 33 | var assetTagConfig = { 34 | scripts: scripts, 35 | styles: styles, 36 | inlineStyles: '', 37 | deferredScripts: '', 38 | deferredStyles: '' 39 | } 40 | 41 | var tagConfigPath = path.join(__dirname, '../../../build/server/views/tags.json'); 42 | 43 | fs.writeFileSync(tagConfigPath, JSON.stringify(assetTagConfig)); 44 | 45 | } 46 | 47 | module.exports = setAssetTagFileNames; -------------------------------------------------------------------------------- /src/server/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import path from 'path'; 3 | import express from 'express'; 4 | import exphbs from 'express-handlebars'; 5 | import tags from './views/tags'; 6 | import routes from './routes' 7 | 8 | const app = express().disable('x-powered-by').disable('etag'); 9 | 10 | //app settings 11 | app.set('views', path.join(__dirname, 'views')) 12 | .set('view engine', 'hbs') 13 | .set('view cache', true) 14 | .set('port', process.env.port || 8081); 15 | 16 | //app view engine 17 | app.engine('hbs', exphbs({ 18 | extname: '.hbs', 19 | defaultLayout: 'main', 20 | layoutsDir: app.get('views') + '/layouts' 21 | })); 22 | 23 | //make script tags available to views 24 | app.locals.scripts = tags.scripts; 25 | app.locals.styles = tags.styles; 26 | 27 | //register routes 28 | app.use('/static/assets', express.static(path.join(__dirname, '../assets'))); 29 | app.use(routes); 30 | 31 | //run server 32 | app.listen(app.get('port'), () => { 33 | console.log(`Server listening on port ${app.get('port')} in ${app.settings.env} mode`); 34 | }); -------------------------------------------------------------------------------- /src/server/routes/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import express from 'express'; 3 | 4 | const router = express.Router(); 5 | 6 | router.get('/', (req, res) => { 7 | return res.render('index'); 8 | }); 9 | 10 | export default router; -------------------------------------------------------------------------------- /src/server/views/error.hbs: -------------------------------------------------------------------------------- 1 | {{{error}}} -------------------------------------------------------------------------------- /src/server/views/index.hbs: -------------------------------------------------------------------------------- 1 |
    -------------------------------------------------------------------------------- /src/server/views/layouts/main.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React ES6 Boilerplate 7 | {{{inlineStyles}}} 8 | {{{styles}}} 9 | 10 | 11 | {{{body}}} 12 | {{{scripts}}} 13 | 14 | 15 | --------------------------------------------------------------------------------