├── .babelrc
├── .eslintignore
├── .eslintrc
├── .gitignore
├── .travis.yml
├── LICENSE
├── README.md
├── app
├── actions
│ └── index.js
├── index.html
├── index.js
├── locales
│ ├── en-US.json
│ └── zh-CN.json
├── pages
│ ├── about
│ │ └── About.js
│ └── todos
│ │ ├── Todos.js
│ │ └── todos.scss
├── reducers
│ └── TodoReducer.js
├── store
│ └── index.js
└── styles.scss
├── docs
└── images
│ ├── cover.png
│ └── devtools.png
├── jest.conf.json
├── package.json
├── scripts
├── enzyme-intl.js
├── release.js
└── translate.js
├── tests
├── actions
│ └── index.spec.js
├── pages
│ ├── about
│ │ └── About.spec.js
│ └── todos
│ │ └── Todos.spec.js
└── reducers
│ └── TodoReducer.spec.js
├── webpack.config.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": [
3 | "transform-class-properties",
4 | ["transform-runtime", {
5 | "polyfill": false
6 | }],
7 | ["react-intl", {
8 | "messagesDir": "./.tmp/messages/"
9 | }]
10 | ],
11 | "presets": [
12 | ["latest", {
13 | "es2015": {
14 | "modules": false
15 | }
16 | }],
17 | "react",
18 | "stage-0"
19 | ],
20 | "env": {
21 | "test": {
22 | "presets": [
23 | "latest",
24 | "react",
25 | "stage-0"
26 | ]
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | dist/*
2 | node_modules/*
3 | **/node_modules/*
4 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "plugins": [
4 | "prettier"
5 | ],
6 | "rules": {
7 | "prettier/prettier": ["error", {
8 | "printWidth": 80,
9 | "singleQuote": true,
10 | "tabWidth": 2,
11 | "trailingComma": "es5",
12 | "useTabs": false
13 | }]
14 | }
15 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 |
6 | # Runtime data
7 | pids
8 | *.pid
9 | *.seed
10 |
11 | # Directory for instrumented libs generated by jscoverage/JSCover
12 | lib-cov
13 |
14 | # Coverage directory used by tools like istanbul
15 | coverage
16 |
17 | # nyc test coverage
18 | .nyc_output
19 |
20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
21 | .grunt
22 |
23 | # node-waf configuration
24 | .lock-wscript
25 |
26 | # Compiled binary addons (http://nodejs.org/api/addons.html)
27 | build/Release
28 |
29 | # Dependency directories
30 | node_modules
31 | jspm_packages
32 |
33 | # Optional npm cache directory
34 | .npm
35 |
36 | # Optional REPL history
37 | .node_repl_history
38 |
39 | # Application specific directories
40 | .tmp
41 | dist
42 | coverage
43 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 |
3 | node_js:
4 | - "6"
5 |
6 | cache:
7 | directories:
8 | # cache node modules
9 | - $HOME/.npm
10 | - $HOME/.yarn-cache
11 | - node_modules
12 |
13 | notifications:
14 | # disable email notification
15 | email: false
16 |
17 | before_install:
18 | # Repo for Yarn
19 | - curl -o- -L https://yarnpkg.com/install.sh | bash
20 | - export PATH=$HOME/.yarn/bin:$PATH
21 | - yarn global add coveralls
22 | # remove unused node modules from cache
23 | - npm prune
24 |
25 | install:
26 | - yarn
27 |
28 | script:
29 | - yarn run test
30 |
31 | after_script:
32 | # send code-coverage report to coveralls
33 | - coveralls < ./coverage/lcov.info || true
34 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Yong Su @jeantimex
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Webapp Boilerplate
2 |
3 | [](https://david-dm.org/jeantimex/react-webapp-boilerplate)
4 | [](https://david-dm.org/jeantimex/react-webapp-boilerplate?type=dev)
5 | [](https://travis-ci.org/jeantimex/react-webapp-boilerplate)
6 | [](https://coveralls.io/github/jeantimex/react-webapp-boilerplate)
7 |
8 | 
9 |
10 | ## Features
11 |
12 | **Quick scaffolding**
13 | Save your time in putting React, Redux, Router, Webpack, Jest and localization together, so you can focus on coding your awesome project.
14 |
15 | **Basic react eco system**
16 | The scaffolded project will include the latest React, Redux, React Router and React Intl.
17 |
18 | **Webpack 3**
19 | Enjoy the tree shaking feature in Webpack 3.
20 |
21 | **Jest**
22 | Facebook's painless JavaScript test runner, no need to configure Karma Webpack, no need to use Sinon and Babel Rewire.
23 |
24 | ## Quick start
25 |
26 | **Get up and running**
27 | 1. Clone this repo using `git clone https://github.com/jeantimex/react-webapp-boilerplate.git`
28 | 2. Run `yarn` or `npm install` to install the dependencies
29 | 3. Run `yarn run dev` or `npm run dev` to see the example app at `http://localhost:3000`
30 |
31 | 
32 |
33 | **Unit testing**
34 | Unit testing is powered by **Jest**, run `yarn run test` or `npm run test` and the results will be printed:
35 | ```
36 | PASS tests/pages/todos/Todos.spec.js
37 | PASS tests/pages/about/About.spec.js
38 | PASS tests/reducers/TodoReducer.spec.js
39 | PASS tests/actions/index.spec.js
40 | -----------------|----------|----------|----------|----------|----------------|
41 | File | % Stmts | % Branch | % Funcs | % Lines |Uncovered Lines |
42 | -----------------|----------|----------|----------|----------|----------------|
43 | All files | 100 | 100 | 100 | 100 | |
44 | pages/about | 100 | 100 | 100 | 100 | |
45 | About.js | 100 | 100 | 100 | 100 | |
46 | pages/todos | 100 | 100 | 100 | 100 | |
47 | Todos.js | 100 | 100 | 100 | 100 | |
48 | reducers | 100 | 100 | 100 | 100 | |
49 | TodoReducer.js | 100 | 100 | 100 | 100 | |
50 | -----------------|----------|----------|----------|----------|----------------|
51 | ```
52 |
53 | **Localization**
54 | This demo supports two locales: `en-US` and `zh-CN`, you can add other locales for your application. By default, `en-US` is used, to choose a different locale for development and final build, simply specify the `LOCALE` node environment to your locale, for example:
55 |
56 | - `LOCALE=zh-CN yarn run dev` or `LOCALE=zh-CN npm run dev`: Running example app in Chinese language.
57 | - `LOCALE=zh-CN yarn run build` or `LOCALE=zh-CN npm run build`: Build the dist for Chinese language.
58 | - `yarn run release` or `npm run release`: Bundle the assets for all supported locales that are defined in `app/locales` folder.
59 |
60 | ## License
61 |
62 | MIT License
63 |
64 | Copyright (c) 2017 Yong Su @jeantimex
65 |
66 | Permission is hereby granted, free of charge, to any person obtaining a copy
67 | of this software and associated documentation files (the "Software"), to deal
68 | in the Software without restriction, including without limitation the rights
69 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
70 | copies of the Software, and to permit persons to whom the Software is
71 | furnished to do so, subject to the following conditions:
72 |
73 | The above copyright notice and this permission notice shall be included in all
74 | copies or substantial portions of the Software.
75 |
76 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
77 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
78 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
79 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
80 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
81 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
82 | SOFTWARE.
83 |
--------------------------------------------------------------------------------
/app/actions/index.js:
--------------------------------------------------------------------------------
1 | export const ADD_TODO_ITEM = 'ADD_TODO_ITEM';
2 | export const TOGGLE_TODO_ITEM = 'TOGGLE_TODO_ITEM';
3 | export const DELETE_TODO_ITEM = 'DELETE_TODO_ITEM';
4 | export const SET_TODO_FILTER_TYPE = 'SET_TODO_FILTER_TYPE';
5 |
6 | export const addTodoItemAction = text => ({
7 | type: ADD_TODO_ITEM,
8 | payload: {
9 | text,
10 | },
11 | });
12 |
13 | export const toggleTodoItemAction = id => ({
14 | type: TOGGLE_TODO_ITEM,
15 | payload: {
16 | id,
17 | },
18 | });
19 |
20 | export const deleteTodoItemAction = id => ({
21 | type: DELETE_TODO_ITEM,
22 | payload: {
23 | id,
24 | },
25 | });
26 |
27 | export const setTodoFilterTypeAction = filterType => ({
28 | type: SET_TODO_FILTER_TYPE,
29 | payload: {
30 | filterType,
31 | },
32 | });
33 |
--------------------------------------------------------------------------------
/app/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | React Webapp Boilerplate
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/app/index.js:
--------------------------------------------------------------------------------
1 | import ReactDOM from 'react-dom';
2 | import React from 'react';
3 |
4 | // Redux and React Router
5 | import { Provider } from 'react-redux';
6 | import createHistory from 'history/createBrowserHistory';
7 | import { Route } from 'react-router';
8 | import { ConnectedRouter } from 'react-router-redux';
9 |
10 | // React Intl
11 | import { IntlProvider, addLocaleData } from 'react-intl';
12 | import localeData from 'locale-data';
13 | import messages from 'locale-messages';
14 |
15 | import store from 'store';
16 |
17 | import Todos from 'pages/todos/Todos';
18 | import About from 'pages/about/About';
19 |
20 | import './styles.scss';
21 |
22 | const history = createHistory();
23 |
24 | addLocaleData(localeData);
25 |
26 | ReactDOM.render(
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | ,
37 | document.getElementById('root')
38 | );
39 |
--------------------------------------------------------------------------------
/app/locales/en-US.json:
--------------------------------------------------------------------------------
1 | {}
--------------------------------------------------------------------------------
/app/locales/zh-CN.json:
--------------------------------------------------------------------------------
1 | {
2 | "helloWorld": "您好世界",
3 | "welcome": "Hello {name}, you have {unreadCount, number} {unreadCount, plural, one {message} other {messages}}"
4 | }
5 |
--------------------------------------------------------------------------------
/app/pages/about/About.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const About = () => About us
;
4 |
5 | export default About;
6 |
--------------------------------------------------------------------------------
/app/pages/todos/Todos.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { connect } from 'react-redux';
4 | import classNames from 'classnames';
5 | import noop from 'lodash.noop';
6 |
7 | import {
8 | addTodoItemAction,
9 | deleteTodoItemAction,
10 | setTodoFilterTypeAction,
11 | toggleTodoItemAction,
12 | } from 'actions';
13 |
14 | import 'todomvc-app-css/index.css';
15 | import './todos.scss';
16 |
17 | export class Todos extends Component {
18 | handleKeyPress = e => {
19 | const { addTodoItem } = this.props;
20 | const input = e.target;
21 | const text = input.value;
22 |
23 | if (e.key === 'Enter' && text && text.length > 0) {
24 | addTodoItem(text);
25 | // Clear the text field
26 | input.value = '';
27 | }
28 | };
29 |
30 | handleChange = e => {
31 | const { toggleTodoItem } = this.props;
32 | const input = e.target;
33 |
34 | toggleTodoItem(input.id);
35 | };
36 |
37 | handleClose = id => {
38 | const { deleteTodoItem } = this.props;
39 |
40 | deleteTodoItem(id);
41 | };
42 |
43 | handleFilterTypeChange = filterType => {
44 | const { setTodoFilterType } = this.props;
45 |
46 | setTodoFilterType(filterType);
47 | };
48 |
49 | render() {
50 | const { todoItems, filterType, activeItemsCount } = this.props;
51 |
52 | const items = todoItems.map(item => {
53 | const className = classNames('todo-item', {
54 | completed: item.completed,
55 | });
56 |
57 | return (
58 |
59 |
60 |
67 |
68 |
73 |
74 | );
75 | });
76 |
77 | return (
78 |
79 |
88 |
89 |
92 |
93 |
122 |
123 | );
124 | }
125 | }
126 |
127 | Todos.defaultProps = {
128 | todoItems: [],
129 | filterType: 'all',
130 | addTodoItem: noop,
131 | toggleTodoItem: noop,
132 | deleteTodoItem: noop,
133 | setTodoFilterType: noop,
134 | activeItemsCount: 0,
135 | };
136 |
137 | Todos.propTypes = {
138 | todoItems: PropTypes.arrayOf(
139 | PropTypes.shape({
140 | id: PropTypes.string,
141 | text: PropTypes.string,
142 | completed: PropTypes.bool,
143 | })
144 | ),
145 | filterType: PropTypes.oneOf(['all', 'active', 'completed']),
146 | addTodoItem: PropTypes.func,
147 | toggleTodoItem: PropTypes.func,
148 | deleteTodoItem: PropTypes.func,
149 | setTodoFilterType: PropTypes.func,
150 | activeItemsCount: PropTypes.number,
151 | };
152 |
153 | export const mapStateToProps = state => {
154 | const { items, filterType } = state.todo;
155 | let todoItems = items.toArray();
156 |
157 | if (filterType === 'active') {
158 | todoItems = todoItems.filter(item => !item.completed);
159 | } else if (filterType === 'completed') {
160 | todoItems = todoItems.filter(item => item.completed);
161 | }
162 |
163 | return {
164 | todoItems,
165 | filterType,
166 | activeItemsCount: items.toArray().filter(item => !item.completed).length,
167 | };
168 | };
169 |
170 | export const mapDispatchToProps = dispatch => ({
171 | addTodoItem: text => {
172 | dispatch(addTodoItemAction(text));
173 | },
174 | toggleTodoItem: id => {
175 | dispatch(toggleTodoItemAction(id));
176 | },
177 | deleteTodoItem: id => {
178 | dispatch(deleteTodoItemAction(id));
179 | },
180 | setTodoFilterType: filterType => {
181 | dispatch(setTodoFilterTypeAction(filterType));
182 | },
183 | });
184 |
185 | export default connect(mapStateToProps, mapDispatchToProps)(Todos);
186 |
--------------------------------------------------------------------------------
/app/pages/todos/todos.scss:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0 auto !important;
3 | }
4 |
5 | .todoapp {
6 | .filters {
7 | a {
8 | cursor: pointer;
9 | }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/app/reducers/TodoReducer.js:
--------------------------------------------------------------------------------
1 | import { OrderedMap } from 'immutable';
2 | import uuid from 'uuid/v4';
3 |
4 | import {
5 | ADD_TODO_ITEM,
6 | DELETE_TODO_ITEM,
7 | TOGGLE_TODO_ITEM,
8 | SET_TODO_FILTER_TYPE,
9 | } from 'actions';
10 |
11 | export const defaultState = {
12 | items: OrderedMap(),
13 | filterType: 'all',
14 | };
15 |
16 | const todoReducer = (state = defaultState, action = {}) => {
17 | switch (action.type) {
18 | case ADD_TODO_ITEM: {
19 | const { text } = action.payload;
20 | const id = uuid();
21 | const items = state.items.set(id, {
22 | id,
23 | text,
24 | completed: false,
25 | });
26 |
27 | return {
28 | ...state,
29 | items,
30 | };
31 | }
32 | case TOGGLE_TODO_ITEM: {
33 | const { id } = action.payload;
34 | const item = state.items.get(id);
35 | item.completed = !item.completed;
36 | const items = state.items.set(id, item);
37 |
38 | return {
39 | ...state,
40 | items,
41 | };
42 | }
43 | case DELETE_TODO_ITEM: {
44 | const { id } = action.payload;
45 | const items = state.items.delete(id);
46 |
47 | return {
48 | ...state,
49 | items,
50 | };
51 | }
52 | case SET_TODO_FILTER_TYPE: {
53 | const { filterType } = action.payload;
54 |
55 | return {
56 | ...state,
57 | filterType,
58 | };
59 | }
60 | default:
61 | return state;
62 | }
63 | };
64 |
65 | export default todoReducer;
66 |
--------------------------------------------------------------------------------
/app/store/index.js:
--------------------------------------------------------------------------------
1 | import thunk from 'redux-thunk';
2 | import { createStore, combineReducers, applyMiddleware } from 'redux';
3 | import { routerReducer, routerMiddleware } from 'react-router-redux';
4 | import { composeWithDevTools } from 'redux-devtools-extension';
5 |
6 | import todoReducer from 'reducers/TodoReducer';
7 |
8 | const middleware = [routerMiddleware(history), thunk];
9 |
10 | // Add the reducer to your store on the `router` key
11 | // Also apply our middleware for navigating
12 | const store = createStore(
13 | combineReducers({
14 | todo: todoReducer,
15 | router: routerReducer,
16 | }),
17 | composeWithDevTools(applyMiddleware(...middleware))
18 | );
19 |
20 | export default store;
21 |
--------------------------------------------------------------------------------
/app/styles.scss:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | margin: 0;
4 | padding: 0;
5 | }
6 |
--------------------------------------------------------------------------------
/docs/images/cover.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jeantimex/react-webapp-boilerplate/35911b7f4f81b801756c89a3a98cef4dfdd71a85/docs/images/cover.png
--------------------------------------------------------------------------------
/docs/images/devtools.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jeantimex/react-webapp-boilerplate/35911b7f4f81b801756c89a3a98cef4dfdd71a85/docs/images/devtools.png
--------------------------------------------------------------------------------
/jest.conf.json:
--------------------------------------------------------------------------------
1 | {
2 | "moduleNameMapper": {
3 | "^actions(.*)$": "/app/actions$1",
4 | "^pages(.*)$": "/app/pages$1",
5 | "^reducers(.*)$": "/app/reducers$1",
6 | "store": "/app/store",
7 | "enzyme-intl": "/scripts/enzyme-intl.js",
8 | "locale-data": "/node_modules/react-intl/locale-data/en",
9 | "locale-messages": "/app/locales/en-US.json",
10 | "\\.(css|scss)$": "identity-obj-proxy"
11 | },
12 | "collectCoverageFrom" : [
13 | "app/**/*.js",
14 | "!**/index.js"
15 | ],
16 | "coveragePathIgnorePatterns": [
17 | "/node_modules/",
18 | "/scripts"
19 | ],
20 | "coverageThreshold": {
21 | "global": {
22 | "branches": 90,
23 | "functions": 90,
24 | "lines": 90,
25 | "statements": 90
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-webapp-boilerplate",
3 | "version": "1.0.1",
4 | "description": "A sample project that demonstrates how to scaffold a web application using React and Webpack.",
5 | "main": "index.js",
6 | "scripts": {
7 | "clean": "rimraf dist",
8 | "dev": "webpack-dev-server",
9 | "prod": "cross-env NODE_ENV=production webpack-dev-server --env.prod=true",
10 | "build:bundle": "cross-env NODE_ENV=production webpack --env.prod=true",
11 | "build:langs": "babel-node ./scripts/translate.js",
12 | "build": "npm run clean && npm run build:bundle && npm run build:langs",
13 | "release": "npm run clean && babel-node ./scripts/release.js",
14 | "lint": "eslint ./app ./tests ./scripts ./webpack.config.babel.js",
15 | "test": "npm run lint && npm run jest",
16 | "jest": "cross-env NODE_ENV=test jest --config jest.conf.json --coverage"
17 | },
18 | "repository": {
19 | "type": "git",
20 | "url": "git+https://github.com/jeantimex/react-webapp-boilerplate.git"
21 | },
22 | "keywords": [
23 | "react",
24 | "webapp",
25 | "webpack",
26 | "boilerplate"
27 | ],
28 | "author": "Yong Su",
29 | "license": "MIT",
30 | "bugs": {
31 | "url": "https://github.com/jeantimex/react-webapp-boilerplate/issues"
32 | },
33 | "homepage": "https://github.com/jeantimex/react-webapp-boilerplate#readme",
34 | "devDependencies": {
35 | "babel-cli": "^6.26.0",
36 | "babel-core": "^6.26.0",
37 | "babel-eslint": "^7.2.3",
38 | "babel-jest": "^21.0.0",
39 | "babel-loader": "^7.1.2",
40 | "babel-plugin-react-intl": "^2.3.1",
41 | "babel-plugin-transform-class-properties": "^6.24.1",
42 | "babel-plugin-transform-runtime": "^6.23.0",
43 | "babel-preset-latest": "^6.24.1",
44 | "babel-preset-react": "^6.24.1",
45 | "babel-preset-stage-0": "^6.24.1",
46 | "chai": "^4.1.2",
47 | "cross-env": "^5.0.5",
48 | "css-loader": "^0.28.7",
49 | "enzyme": "^2.9.1",
50 | "eslint": "^4.6.1",
51 | "eslint-import-resolver-webpack": "^0.8.3",
52 | "eslint-plugin-import": "^2.7.0",
53 | "eslint-plugin-prettier": "^2.2.0",
54 | "eslint-plugin-react": "^7.3.0",
55 | "extract-text-webpack-plugin": "^3.0.0",
56 | "file-loader": "^0.11.2",
57 | "glob": "^7.1.2",
58 | "identity-obj-proxy": "^3.0.0",
59 | "jest": "^21.0.0",
60 | "mkdirp": "^0.5.1",
61 | "mocha": "^3.5.0",
62 | "node-sass": "^4.5.3",
63 | "prettier": "^1.6.1",
64 | "react-addons-test-utils": "^15.6.0",
65 | "react-test-renderer": "^15.6.1",
66 | "rimraf": "^2.6.1",
67 | "sass-loader": "^6.0.6",
68 | "style-loader": "^0.18.2",
69 | "webpack": "^3.5.5",
70 | "webpack-dev-server": "^2.7.1"
71 | },
72 | "dependencies": {
73 | "babel-runtime": "^6.26.0",
74 | "classnames": "^2.2.5",
75 | "history": "^4.7.2",
76 | "immutable": "^3.8.1",
77 | "lodash.noop": "^3.0.1",
78 | "prop-types": "^15.5.10",
79 | "react": "^15.6.1",
80 | "react-dom": "^15.6.1",
81 | "react-intl": "^2.3.0",
82 | "react-redux": "^5.0.6",
83 | "react-router-redux": "^5.0.0-alpha.6",
84 | "redux": "^3.7.2",
85 | "redux-devtools-extension": "^2.13.2",
86 | "redux-thunk": "^2.2.0",
87 | "todomvc-app-css": "^2.1.0",
88 | "uuid": "^3.1.0"
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/scripts/enzyme-intl.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Components using the react-intl module require access to the intl context.
3 | * This is not available when mounting single components in Enzyme.
4 | * These helper functions aim to address that and wrap a valid,
5 | * English-locale intl context around them.
6 | */
7 |
8 | import React from 'react';
9 | import { IntlProvider, intlShape } from 'react-intl';
10 | import { mount, shallow } from 'enzyme';
11 |
12 | // You can pass your messages to the IntlProvider. Optional: remove if unneeded.
13 | import messages from '../app/locales/en-US'; // en-US.json
14 |
15 | // Create the IntlProvider to retrieve context for wrapping around.
16 | const intlProvider = new IntlProvider({ locale: 'en', messages }, {});
17 | const { intl } = intlProvider.getChildContext();
18 |
19 | /**
20 | * When using React-Intl `injectIntl` on components, props.intl is required.
21 | */
22 | const nodeWithIntlProp = node => React.cloneElement(node, { intl });
23 |
24 | export const shallowWithIntl = (node, { context } = {}) =>
25 | shallow(nodeWithIntlProp(node), {
26 | context: Object.assign({}, context, { intl }),
27 | });
28 |
29 | export const mountWithIntl = (node, { context, childContextTypes } = {}) =>
30 | mount(nodeWithIntlProp(node), {
31 | context: Object.assign({}, context, { intl }),
32 | childContextTypes: Object.assign(
33 | {},
34 | { intl: intlShape },
35 | childContextTypes
36 | ),
37 | });
38 |
--------------------------------------------------------------------------------
/scripts/release.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | const exec = require('child_process').exec;
3 | const readdirSync = require('fs').readdirSync;
4 | const path = require('path');
5 | const async = require('async');
6 |
7 | const join = path.join;
8 | const extname = path.extname;
9 |
10 | const localesPath = join(__dirname, '..', 'app', 'locales');
11 |
12 | const languages = readdirSync(localesPath)
13 | .filter(fileName => extname(fileName) === '.json')
14 | .map(fileName => fileName.slice(0, fileName.indexOf('.')));
15 |
16 | const queue = async.queue((language, callback) => {
17 | exec(
18 | 'npm run build',
19 | {
20 | cwd: join(__dirname, '..'),
21 | env: Object.assign(process.env, {
22 | LOCALE: language,
23 | }),
24 | },
25 | error => {
26 | if (!error) {
27 | console.log('Building', language, 'succeed!');
28 | callback(null, true);
29 | } else {
30 | console.log('Building', language, 'failed!');
31 | callback(null, false);
32 | process.exit(1);
33 | }
34 | }
35 | );
36 | }, 2);
37 |
38 | queue.push(languages);
39 |
40 | queue.drain = () => {
41 | console.log('Building all assets succeed!');
42 | };
43 |
--------------------------------------------------------------------------------
/scripts/translate.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const rimraf = require('rimraf');
3 | const globSync = require('glob').sync;
4 | const mkdirpSync = require('mkdirp').sync;
5 |
6 | const messagesPattern = './.tmp/messages/**/*.json';
7 | const outputDir = './app/locales/';
8 |
9 | // Aggregates the default messages that were extracted from the example app's
10 | // React components via the React Intl Babel plugin. An error will be thrown if
11 | // there are messages in different components that use the same `id`. The result
12 | // is a flat collection of `id: message` pairs for the app's default locale.
13 | const defaultMessages = globSync(messagesPattern)
14 | .map(filename => fs.readFileSync(filename, 'utf8'))
15 | .map(file => JSON.parse(file))
16 | .reduce((collection, descriptors) => {
17 | descriptors.forEach(({ id, defaultMessage }) => {
18 | if (Object.prototype.hasOwnProperty.call(collection, id)) {
19 | throw new Error(`Duplicate message id: ${id}`);
20 | }
21 |
22 | collection[id] = defaultMessage;
23 | });
24 |
25 | return collection;
26 | }, {});
27 |
28 | mkdirpSync(outputDir);
29 |
30 | fs.writeFileSync(
31 | `${outputDir}en-US.json`,
32 | JSON.stringify(defaultMessages, null, 2)
33 | );
34 |
35 | rimraf('./.tmp/messages', () => {});
36 |
--------------------------------------------------------------------------------
/tests/actions/index.spec.js:
--------------------------------------------------------------------------------
1 | import { assert } from 'chai';
2 | import {
3 | ADD_TODO_ITEM,
4 | DELETE_TODO_ITEM,
5 | TOGGLE_TODO_ITEM,
6 | SET_TODO_FILTER_TYPE,
7 | addTodoItemAction,
8 | toggleTodoItemAction,
9 | deleteTodoItemAction,
10 | setTodoFilterTypeAction,
11 | } from 'actions';
12 |
13 | describe('todo actions', () => {
14 | it('should return a payload with text', () => {
15 | const action = addTodoItemAction('release product');
16 | const expected = {
17 | type: ADD_TODO_ITEM,
18 | payload: {
19 | text: 'release product',
20 | },
21 | };
22 | assert.deepEqual(expected, action);
23 | });
24 |
25 | it('should return a payload with todo item id', () => {
26 | const action = toggleTodoItemAction('123-456');
27 | const expected = {
28 | type: TOGGLE_TODO_ITEM,
29 | payload: {
30 | id: '123-456',
31 | },
32 | };
33 | assert.deepEqual(expected, action);
34 | });
35 |
36 | it('should return a payload with todo item id', () => {
37 | const action = deleteTodoItemAction('123-456');
38 | const expected = {
39 | type: DELETE_TODO_ITEM,
40 | payload: {
41 | id: '123-456',
42 | },
43 | };
44 | assert.deepEqual(expected, action);
45 | });
46 |
47 | it('should return a payload with correct filter type', () => {
48 | const action = setTodoFilterTypeAction('all');
49 | const expected = {
50 | type: SET_TODO_FILTER_TYPE,
51 | payload: {
52 | filterType: 'all',
53 | },
54 | };
55 | assert.deepEqual(expected, action);
56 | });
57 | });
58 |
--------------------------------------------------------------------------------
/tests/pages/about/About.spec.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { shallow } from 'enzyme';
3 | import { assert } from 'chai';
4 |
5 | import About from 'pages/about/About';
6 |
7 | describe('About Page', () => {
8 | it('should render the about page', () => {
9 | const wrapper = shallow();
10 | assert.ok(wrapper.hasClass('viewport'));
11 | });
12 | });
13 |
--------------------------------------------------------------------------------
/tests/pages/todos/Todos.spec.js:
--------------------------------------------------------------------------------
1 | jest.mock('actions', () => ({
2 | addTodoItemAction: jest.fn(),
3 | toggleTodoItemAction: jest.fn(),
4 | deleteTodoItemAction: jest.fn(),
5 | setTodoFilterTypeAction: jest.fn(),
6 | }));
7 |
8 | import React from 'react';
9 | import { assert } from 'chai';
10 | import { shallow } from 'enzyme';
11 | import { OrderedMap } from 'immutable';
12 | import {
13 | addTodoItemAction,
14 | toggleTodoItemAction,
15 | deleteTodoItemAction,
16 | setTodoFilterTypeAction,
17 | } from 'actions';
18 | import { Todos, mapStateToProps, mapDispatchToProps } from 'pages/todos/Todos';
19 |
20 | describe('Todos Page', () => {
21 | let wrapper;
22 |
23 | const addTodoItem = jest.fn();
24 | const toggleTodoItem = jest.fn();
25 | const deleteTodoItem = jest.fn();
26 | const setTodoFilterType = jest.fn();
27 |
28 | beforeEach(() => {
29 | wrapper = shallow(
30 |
36 | );
37 | });
38 |
39 | afterEach(() => {
40 | addTodoItem.mockClear();
41 | toggleTodoItem.mockClear();
42 | deleteTodoItem.mockClear();
43 | setTodoFilterType.mockClear();
44 | });
45 |
46 | it('should render the todos page', () => {
47 | assert.ok(wrapper.hasClass('todoapp'));
48 | });
49 |
50 | it('should highlight the all button', () => {
51 | wrapper.setProps({ filterType: 'all' });
52 | const button = wrapper
53 | .find('.filters')
54 | .find('a')
55 | .at(0);
56 | assert.ok(button.hasClass('selected'));
57 | });
58 |
59 | it('should not highlight the all button', () => {
60 | wrapper.setProps({ filterType: 'active' });
61 | const button = wrapper
62 | .find('.filters')
63 | .find('a')
64 | .at(0);
65 | assert.notOk(button.hasClass('selected'));
66 | });
67 |
68 | it('should highlight the active button', () => {
69 | wrapper.setProps({ filterType: 'active' });
70 | const button = wrapper
71 | .find('.filters')
72 | .find('a')
73 | .at(1);
74 | assert.ok(button.hasClass('selected'));
75 | });
76 |
77 | it('should not highlight the active button', () => {
78 | wrapper.setProps({ filterType: 'completed' });
79 | const button = wrapper
80 | .find('.filters')
81 | .find('a')
82 | .at(1);
83 | assert.notOk(button.hasClass('selected'));
84 | });
85 |
86 | it('should trigger setTodoFilterType with all', () => {
87 | const button = wrapper
88 | .find('.filters')
89 | .find('a')
90 | .at(0);
91 | button.simulate('click');
92 | assert.lengthOf(setTodoFilterType.mock.calls, 1);
93 | assert.equal('all', setTodoFilterType.mock.calls[0][0]);
94 | });
95 |
96 | it('should trigger setTodoFilterType with active', () => {
97 | const button = wrapper
98 | .find('.filters')
99 | .find('a')
100 | .at(1);
101 | button.simulate('click');
102 | assert.lengthOf(setTodoFilterType.mock.calls, 1);
103 | assert.equal('active', setTodoFilterType.mock.calls[0][0]);
104 | });
105 |
106 | it('should trigger setTodoFilterType with completed', () => {
107 | const button = wrapper
108 | .find('.filters')
109 | .find('a')
110 | .at(2);
111 | button.simulate('click');
112 | assert.lengthOf(setTodoFilterType.mock.calls, 1);
113 | assert.equal('completed', setTodoFilterType.mock.calls[0][0]);
114 | });
115 |
116 | it('should render 2 todo items', () => {
117 | const todoItems = [
118 | { id: '1', text: 'item 1', completed: false },
119 | { id: '2', text: 'item 2', completed: true },
120 | ];
121 | wrapper.setProps({ todoItems });
122 | wrapper.update();
123 | const itemList = wrapper.find('.todo-item');
124 | assert.equal(itemList.length, 2);
125 | });
126 |
127 | it('should call handleClose', () => {
128 | const handleClose = jest.fn();
129 | const todoItems = [
130 | { id: '1', text: 'item 1', completed: false },
131 | { id: '2', text: 'item 2', completed: true },
132 | ];
133 | wrapper.instance().handleClose = handleClose;
134 | wrapper.setProps({ todoItems });
135 | wrapper.update();
136 | const closeButton = wrapper
137 | .find('.todo-item')
138 | .at(0)
139 | .find('.destroy');
140 | closeButton.simulate('click');
141 | assert.lengthOf(handleClose.mock.calls, 1);
142 | assert.equal('1', handleClose.mock.calls[0][0]);
143 | });
144 |
145 | it('should call deleteTodoItem', () => {
146 | wrapper.instance().handleClose('1');
147 | assert.equal('1', deleteTodoItem.mock.calls[0][0]);
148 | });
149 |
150 | it('should call toggleTodoItem', () => {
151 | wrapper.instance().handleChange({
152 | target: {
153 | id: '1',
154 | },
155 | });
156 | assert.equal('1', toggleTodoItem.mock.calls[0][0]);
157 | });
158 |
159 | it('should call addTodoItem', () => {
160 | wrapper.instance().handleKeyPress({
161 | key: 'Enter',
162 | target: {
163 | value: 'release product',
164 | },
165 | });
166 | assert.equal('release product', addTodoItem.mock.calls[0][0]);
167 | });
168 |
169 | it('should not call addTodoItem', () => {
170 | wrapper.instance().handleKeyPress({
171 | key: 'Esc',
172 | target: {
173 | value: 'release product',
174 | },
175 | });
176 | assert.lengthOf(addTodoItem.mock.calls, 0);
177 | });
178 |
179 | it('should setup mapDispatchToProps properly', () => {
180 | const dispatch = jest.fn();
181 | const props = mapDispatchToProps(dispatch);
182 | props.addTodoItem('test');
183 | props.toggleTodoItem('1');
184 | props.deleteTodoItem('1');
185 | props.setTodoFilterType('all');
186 | assert.lengthOf(addTodoItemAction.mock.calls, 1);
187 | assert.lengthOf(toggleTodoItemAction.mock.calls, 1);
188 | assert.lengthOf(deleteTodoItemAction.mock.calls, 1);
189 | assert.lengthOf(setTodoFilterTypeAction.mock.calls, 1);
190 | });
191 |
192 | it('should map to the correct props when filter type is all', () => {
193 | const items = OrderedMap()
194 | .set('1', { id: '1', text: 'item 1', completed: false })
195 | .set('2', { id: '2', text: 'item 2', completed: true });
196 | const filterType = 'all';
197 | const state = { todo: { items, filterType } };
198 | const props = mapStateToProps(state);
199 | const expected = {
200 | todoItems: [
201 | { id: '1', text: 'item 1', completed: false },
202 | { id: '2', text: 'item 2', completed: true },
203 | ],
204 | filterType: 'all',
205 | activeItemsCount: 1,
206 | };
207 | assert.deepEqual(expected, props);
208 | });
209 |
210 | it('should map to the correct props when filter type is active', () => {
211 | const items = OrderedMap()
212 | .set('1', { id: '1', text: 'item 1', completed: false })
213 | .set('2', { id: '2', text: 'item 2', completed: true });
214 | const filterType = 'active';
215 | const state = { todo: { items, filterType } };
216 | const props = mapStateToProps(state);
217 | const expected = {
218 | todoItems: [{ id: '1', text: 'item 1', completed: false }],
219 | filterType: 'active',
220 | activeItemsCount: 1,
221 | };
222 | assert.deepEqual(expected, props);
223 | });
224 |
225 | it('should map to the correct props when filter type is completed', () => {
226 | const items = OrderedMap()
227 | .set('1', { id: '1', text: 'item 1', completed: false })
228 | .set('2', { id: '2', text: 'item 2', completed: true });
229 | const filterType = 'completed';
230 | const state = { todo: { items, filterType } };
231 | const props = mapStateToProps(state);
232 | const expected = {
233 | todoItems: [{ id: '2', text: 'item 2', completed: true }],
234 | filterType: 'completed',
235 | activeItemsCount: 1,
236 | };
237 | assert.deepEqual(expected, props);
238 | });
239 | });
240 |
--------------------------------------------------------------------------------
/tests/reducers/TodoReducer.spec.js:
--------------------------------------------------------------------------------
1 | jest.mock('uuid/v4');
2 |
3 | import uuid from 'uuid/v4';
4 | import { assert } from 'chai';
5 | import reducer, { defaultState } from 'reducers/TodoReducer';
6 |
7 | uuid.mockImplementation(() => '123-456');
8 |
9 | describe('todo reducer', () => {
10 | it('should default to the default state when initially called', () => {
11 | const state = reducer(undefined, { type: 'unknown-type' });
12 | assert.deepEqual(state, defaultState);
13 | });
14 |
15 | it('should return the default state', () => {
16 | const state = reducer(undefined);
17 | assert.deepEqual(defaultState, state);
18 | });
19 |
20 | it('should add todo item', () => {
21 | const state = reducer(undefined, {
22 | type: 'ADD_TODO_ITEM',
23 | payload: {
24 | text: 'release product',
25 | },
26 | });
27 | const { items } = state;
28 | const expected = [
29 | {
30 | id: '123-456',
31 | text: 'release product',
32 | completed: false,
33 | },
34 | ];
35 | assert.deepEqual(expected, items.toArray());
36 | });
37 |
38 | it('should toggle todo item', () => {
39 | let state = reducer(undefined, {
40 | type: 'ADD_TODO_ITEM',
41 | payload: {
42 | text: 'release product',
43 | },
44 | });
45 | state = reducer(state, {
46 | type: 'TOGGLE_TODO_ITEM',
47 | payload: {
48 | id: '123-456',
49 | },
50 | });
51 | const { items } = state;
52 | const expected = [
53 | {
54 | id: '123-456',
55 | text: 'release product',
56 | completed: true,
57 | },
58 | ];
59 | assert.deepEqual(expected, items.toArray());
60 | });
61 |
62 | it('should delete todo item', () => {
63 | let state = reducer(undefined, {
64 | type: 'ADD_TODO_ITEM',
65 | payload: {
66 | text: 'release product',
67 | },
68 | });
69 | state = reducer(state, {
70 | type: 'DELETE_TODO_ITEM',
71 | payload: {
72 | id: '123-456',
73 | },
74 | });
75 | const { items } = state;
76 | const expected = [];
77 | assert.deepEqual(expected, items.toArray());
78 | });
79 |
80 | it('should set the todo filter type', () => {
81 | const state = reducer(undefined, {
82 | type: 'SET_TODO_FILTER_TYPE',
83 | payload: {
84 | filterType: 'all',
85 | },
86 | });
87 | const { filterType } = state;
88 | assert.deepEqual('all', filterType);
89 | });
90 | });
91 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const webpack = require('webpack');
2 | const path = require('path');
3 | const ExtractTextPlugin = require('extract-text-webpack-plugin');
4 |
5 | const locale = process.env.LOCALE || 'en-US';
6 | const sourcePath = path.join(__dirname, 'app');
7 | const outputPath = path.join(__dirname, 'dist', locale);
8 |
9 | module.exports = env => {
10 | const nodeEnv = env && env.prod ? 'production' : 'development';
11 | const isProd = nodeEnv === 'production';
12 | const languageCode = locale.toLowerCase().split(/[_-]+/)[0];
13 |
14 | const extractSass = new ExtractTextPlugin({
15 | filename: '[name].bundle.css',
16 | disable: false,
17 | allChunks: true,
18 | });
19 |
20 | const plugins = [
21 | new webpack.optimize.ModuleConcatenationPlugin(),
22 | new webpack.optimize.CommonsChunkPlugin({
23 | name: 'vendor',
24 | minChunks: Infinity,
25 | filename: 'vendor.bundle.js',
26 | }),
27 | new webpack.EnvironmentPlugin({
28 | NODE_ENV: nodeEnv,
29 | }),
30 | new webpack.NamedModulesPlugin(),
31 | new webpack.DefinePlugin({
32 | LOCALE: JSON.stringify(languageCode),
33 | }),
34 | extractSass,
35 | ];
36 |
37 | if (isProd) {
38 | plugins.push(
39 | new webpack.LoaderOptionsPlugin({
40 | minimize: true,
41 | debug: false,
42 | }),
43 | new webpack.optimize.UglifyJsPlugin({
44 | compress: {
45 | warnings: false,
46 | screw_ie8: true,
47 | conditionals: true,
48 | unused: true,
49 | comparisons: true,
50 | sequences: true,
51 | dead_code: true,
52 | evaluate: true,
53 | if_return: true,
54 | join_vars: true,
55 | },
56 | output: {
57 | comments: false,
58 | },
59 | sourceMap: true,
60 | })
61 | );
62 | } else {
63 | plugins.push(new webpack.HotModuleReplacementPlugin());
64 | }
65 |
66 | return {
67 | devtool: isProd ? 'source-map' : 'inline-source-map',
68 | context: sourcePath,
69 | entry: {
70 | app: './index.js',
71 | vendor: ['react'],
72 | },
73 | output: {
74 | path: outputPath,
75 | filename: '[name].bundle.js',
76 | },
77 | module: {
78 | rules: [
79 | {
80 | test: /\.html$/,
81 | exclude: /node_modules/,
82 | use: {
83 | loader: 'file-loader',
84 | query: {
85 | name: '[name].[ext]',
86 | },
87 | },
88 | },
89 | {
90 | test: /\.s?css$/,
91 | include: [sourcePath, path.resolve('node_modules/todomvc-app-css')],
92 | use: extractSass.extract({
93 | use: [
94 | {
95 | loader: 'css-loader',
96 | },
97 | {
98 | loader: 'sass-loader',
99 | },
100 | ],
101 | fallback: 'style-loader',
102 | }),
103 | },
104 | {
105 | test: /\.(js|jsx)$/,
106 | exclude: /node_modules/,
107 | use: ['babel-loader'],
108 | },
109 | ],
110 | },
111 | resolve: {
112 | extensions: [
113 | '.webpack-loader.js',
114 | '.web-loader.js',
115 | '.loader.js',
116 | '.js',
117 | '.jsx',
118 | ],
119 | modules: [path.resolve(__dirname, 'node_modules'), sourcePath],
120 | alias: {
121 | actions: path.join(__dirname, 'app', 'actions'),
122 | pages: path.join(__dirname, 'app', 'pages'),
123 | reducers: path.join(__dirname, 'app', 'reducers'),
124 | store: path.join(__dirname, 'app', 'store'),
125 | 'locale-data': `react-intl/locale-data/${languageCode}`,
126 | 'locale-messages': `./locales/${locale}.json`,
127 | },
128 | },
129 |
130 | plugins,
131 |
132 | performance: isProd && {
133 | maxAssetSize: 100,
134 | maxEntrypointSize: 300,
135 | hints: 'warning',
136 | },
137 |
138 | devServer: {
139 | contentBase: './app',
140 | disableHostCheck: true,
141 | historyApiFallback: true,
142 | host: 'localhost',
143 | port: 3000,
144 | compress: isProd,
145 | inline: !isProd,
146 | hot: !isProd,
147 | stats: {
148 | assets: true,
149 | children: false,
150 | chunks: false,
151 | hash: false,
152 | modules: false,
153 | publicPath: false,
154 | timings: true,
155 | version: false,
156 | warnings: true,
157 | },
158 | },
159 | };
160 | };
161 |
--------------------------------------------------------------------------------