├── .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 | [![dependencies Status](https://david-dm.org/jeantimex/react-webapp-boilerplate/status.svg)](https://david-dm.org/jeantimex/react-webapp-boilerplate) 4 | [![devDependencies Status](https://david-dm.org/jeantimex/react-webapp-boilerplate/dev-status.svg)](https://david-dm.org/jeantimex/react-webapp-boilerplate?type=dev) 5 | [![Build Status](https://travis-ci.org/jeantimex/react-webapp-boilerplate.svg?branch=master)](https://travis-ci.org/jeantimex/react-webapp-boilerplate) 6 | [![Coverage Status](https://coveralls.io/repos/github/jeantimex/react-webapp-boilerplate/badge.svg)](https://coveralls.io/github/jeantimex/react-webapp-boilerplate) 7 | 8 | ![rocket](docs/images/cover.png)
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 | ![devtools](docs/images/devtools.png)
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 |
    80 |

    todos

    81 | 87 |
    88 | 89 |
    90 | 91 |
    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 | --------------------------------------------------------------------------------