├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── LICENSE ├── README.md ├── images ├── image00.png ├── image01.png ├── image02.png └── image03.png ├── nodemon.json ├── package.json ├── server ├── index.js ├── public │ └── index.html └── server.js ├── src ├── app.js ├── index.js ├── modules │ └── cart │ │ ├── actionTypes.js │ │ ├── actions.js │ │ ├── components │ │ ├── AvailableItems.js │ │ ├── CartItems.js │ │ ├── DisplayValue.js │ │ ├── ShoppingCart.js │ │ └── TaxCalculator.js │ │ ├── constants.js │ │ ├── reducer.js │ │ └── selectors.js ├── shared │ ├── styles │ │ ├── colors.js │ │ └── style.css │ └── utils │ │ ├── conversion.js │ │ ├── immutableToJS.js │ │ ├── jsToImmutable.js │ │ └── wait.js └── store │ ├── createStore.js │ ├── middleware │ └── logger.js │ └── rootReducer.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react", "stage-0"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | bin/** 2 | dist/** 3 | build/** 4 | tmp/** 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", // https://github.com/babel/babel-eslint 3 | "plugins": [ 4 | "react" // https://github.com/yannickcr/eslint-plugin-react 5 | ], 6 | "env": { // http://eslint.org/docs/user-guide/configuring.html#specifying-environments 7 | "browser": true, // browser global variables 8 | "node": true // Node.js global variables and Node.js-specific rules 9 | }, 10 | "ecmaFeatures": { 11 | "arrowFunctions": true, 12 | "blockBindings": true, 13 | "classes": true, 14 | "defaultParams": true, 15 | "destructuring": true, 16 | "forOf": true, 17 | "generators": false, 18 | "modules": true, 19 | "objectLiteralComputedProperties": true, 20 | "objectLiteralDuplicateProperties": false, 21 | "objectLiteralShorthandMethods": true, 22 | "objectLiteralShorthandProperties": true, 23 | "spread": true, 24 | "superInFunctions": true, 25 | "templateStrings": true, 26 | "jsx": true 27 | }, 28 | "rules": { 29 | /** 30 | * Strict mode 31 | */ 32 | "strict": [2, "never"], // http://eslint.org/docs/rules/strict 33 | 34 | /** 35 | * ES6 36 | */ 37 | "no-var": 2, // http://eslint.org/docs/rules/no-var 38 | "prefer-const": 2, // http://eslint.org/docs/rules/prefer-const 39 | 40 | /** 41 | * Variables 42 | */ 43 | "no-shadow": 2, // http://eslint.org/docs/rules/no-shadow 44 | "no-shadow-restricted-names": 2, // http://eslint.org/docs/rules/no-shadow-restricted-names 45 | "no-unused-vars": [2, { // http://eslint.org/docs/rules/no-unused-vars 46 | "vars": "local", 47 | "args": "after-used" 48 | }], 49 | "no-use-before-define": 0, // http://eslint.org/docs/rules/no-use-before-define 50 | 51 | /** 52 | * Possible errors 53 | */ 54 | "comma-dangle": [2, "always-multiline"], // http://eslint.org/docs/rules/comma-dangle 55 | "no-cond-assign": [2, "always"], // http://eslint.org/docs/rules/no-cond-assign 56 | "no-console": 1, // http://eslint.org/docs/rules/no-console 57 | "no-debugger": 1, // http://eslint.org/docs/rules/no-debugger 58 | "no-alert": 1, // http://eslint.org/docs/rules/no-alert 59 | "no-constant-condition": 1, // http://eslint.org/docs/rules/no-constant-condition 60 | "no-dupe-keys": 2, // http://eslint.org/docs/rules/no-dupe-keys 61 | "no-duplicate-case": 2, // http://eslint.org/docs/rules/no-duplicate-case 62 | "no-empty": 2, // http://eslint.org/docs/rules/no-empty 63 | "no-ex-assign": 2, // http://eslint.org/docs/rules/no-ex-assign 64 | "no-extra-boolean-cast": 0, // http://eslint.org/docs/rules/no-extra-boolean-cast 65 | "no-extra-semi": 2, // http://eslint.org/docs/rules/no-extra-semi 66 | "no-func-assign": 2, // http://eslint.org/docs/rules/no-func-assign 67 | "no-inner-declarations": 2, // http://eslint.org/docs/rules/no-inner-declarations 68 | "no-invalid-regexp": 2, // http://eslint.org/docs/rules/no-invalid-regexp 69 | "no-irregular-whitespace": 2, // http://eslint.org/docs/rules/no-irregular-whitespace 70 | "no-obj-calls": 2, // http://eslint.org/docs/rules/no-obj-calls 71 | "no-sparse-arrays": 2, // http://eslint.org/docs/rules/no-sparse-arrays 72 | "no-unreachable": 2, // http://eslint.org/docs/rules/no-unreachable 73 | "use-isnan": 2, // http://eslint.org/docs/rules/use-isnan 74 | "block-scoped-var": 0, // http://eslint.org/docs/rules/block-scoped-var 75 | 76 | /** 77 | * Best practices 78 | */ 79 | "consistent-return": 2, // http://eslint.org/docs/rules/consistent-return 80 | "curly": [2, "multi-line"], // http://eslint.org/docs/rules/curly 81 | "default-case": 2, // http://eslint.org/docs/rules/default-case 82 | "dot-notation": [2, { // http://eslint.org/docs/rules/dot-notation 83 | "allowKeywords": true 84 | }], 85 | "eqeqeq": 2, // http://eslint.org/docs/rules/eqeqeq 86 | "guard-for-in": 0, // http://eslint.org/docs/rules/guard-for-in 87 | "no-caller": 2, // http://eslint.org/docs/rules/no-caller 88 | "no-else-return": 2, // http://eslint.org/docs/rules/no-else-return 89 | "no-eq-null": 2, // http://eslint.org/docs/rules/no-eq-null 90 | "no-eval": 2, // http://eslint.org/docs/rules/no-eval 91 | "no-extend-native": 2, // http://eslint.org/docs/rules/no-extend-native 92 | "no-extra-bind": 2, // http://eslint.org/docs/rules/no-extra-bind 93 | "no-fallthrough": 2, // http://eslint.org/docs/rules/no-fallthrough 94 | "no-floating-decimal": 2, // http://eslint.org/docs/rules/no-floating-decimal 95 | "no-implied-eval": 2, // http://eslint.org/docs/rules/no-implied-eval 96 | "no-lone-blocks": 2, // http://eslint.org/docs/rules/no-lone-blocks 97 | "no-loop-func": 2, // http://eslint.org/docs/rules/no-loop-func 98 | "no-multi-str": 2, // http://eslint.org/docs/rules/no-multi-str 99 | "no-native-reassign": 2, // http://eslint.org/docs/rules/no-native-reassign 100 | "no-new": 2, // http://eslint.org/docs/rules/no-new 101 | "no-new-func": 2, // http://eslint.org/docs/rules/no-new-func 102 | "no-new-wrappers": 2, // http://eslint.org/docs/rules/no-new-wrappers 103 | "no-octal": 2, // http://eslint.org/docs/rules/no-octal 104 | "no-octal-escape": 2, // http://eslint.org/docs/rules/no-octal-escape 105 | "no-param-reassign": 2, // http://eslint.org/docs/rules/no-param-reassign 106 | "no-proto": 2, // http://eslint.org/docs/rules/no-proto 107 | "no-redeclare": 2, // http://eslint.org/docs/rules/no-redeclare 108 | "no-return-assign": 2, // http://eslint.org/docs/rules/no-return-assign 109 | "no-script-url": 2, // http://eslint.org/docs/rules/no-script-url 110 | "no-self-compare": 2, // http://eslint.org/docs/rules/no-self-compare 111 | "no-sequences": 2, // http://eslint.org/docs/rules/no-sequences 112 | "no-throw-literal": 2, // http://eslint.org/docs/rules/no-throw-literal 113 | "no-with": 2, // http://eslint.org/docs/rules/no-with 114 | "radix": 2, // http://eslint.org/docs/rules/radix 115 | "vars-on-top": 2, // http://eslint.org/docs/rules/vars-on-top 116 | "wrap-iife": [2, "any"], // http://eslint.org/docs/rules/wrap-iife 117 | "yoda": 2, // http://eslint.org/docs/rules/yoda 118 | 119 | /** 120 | * Style 121 | */ 122 | "indent": [2, 2, {"SwitchCase": 1}], // http://eslint.org/docs/rules/indent 123 | "brace-style": [2, // http://eslint.org/docs/rules/brace-style 124 | "1tbs", { 125 | "allowSingleLine": true 126 | }], 127 | "quotes": [ 128 | 2, "single", "avoid-escape" // http://eslint.org/docs/rules/quotes 129 | ], 130 | "camelcase": [2, { // http://eslint.org/docs/rules/camelcase 131 | "properties": "never" 132 | }], 133 | "comma-spacing": [2, { // http://eslint.org/docs/rules/comma-spacing 134 | "before": false, 135 | "after": true 136 | }], 137 | "comma-style": [2, "last"], // http://eslint.org/docs/rules/comma-style 138 | "eol-last": 2, // http://eslint.org/docs/rules/eol-last 139 | "func-names": 1, // http://eslint.org/docs/rules/func-names 140 | "key-spacing": [2, { // http://eslint.org/docs/rules/key-spacing 141 | "beforeColon": false, 142 | "afterColon": true 143 | }], 144 | "new-cap": [2, { // http://eslint.org/docs/rules/new-cap 145 | "newIsCap": true, 146 | "capIsNew": false 147 | }], 148 | "no-multiple-empty-lines": [2, { // http://eslint.org/docs/rules/no-multiple-empty-lines 149 | "max": 2 150 | }], 151 | "no-nested-ternary": 2, // http://eslint.org/docs/rules/no-nested-ternary 152 | "no-new-object": 2, // http://eslint.org/docs/rules/no-new-object 153 | "no-spaced-func": 2, // http://eslint.org/docs/rules/no-spaced-func 154 | "no-trailing-spaces": 2, // http://eslint.org/docs/rules/no-trailing-spaces 155 | "no-extra-parens": [2, "functions"], // http://eslint.org/docs/rules/no-extra-parens 156 | "no-underscore-dangle": 0, // http://eslint.org/docs/rules/no-underscore-dangle 157 | "one-var": [2, "never"], // http://eslint.org/docs/rules/one-var 158 | "padded-blocks": [2, "never"], // http://eslint.org/docs/rules/padded-blocks 159 | "semi": [2, "always"], // http://eslint.org/docs/rules/semi 160 | "semi-spacing": [2, { // http://eslint.org/docs/rules/semi-spacing 161 | "before": false, 162 | "after": true 163 | }], 164 | "space-after-keywords": 2, // http://eslint.org/docs/rules/space-after-keywords 165 | "space-before-blocks": 2, // http://eslint.org/docs/rules/space-before-blocks 166 | "space-before-function-paren": [2, "never"], // http://eslint.org/docs/rules/space-before-function-paren 167 | "space-infix-ops": 2, // http://eslint.org/docs/rules/space-infix-ops 168 | "space-return-throw-case": 2, // http://eslint.org/docs/rules/space-return-throw-case 169 | "spaced-comment": 2, // http://eslint.org/docs/rules/spaced-comment 170 | 171 | /** 172 | * JSX style 173 | */ 174 | "react/display-name": 0, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/display-name.md 175 | //"react/jsx-boolean-value": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-boolean-value.md 176 | "react/jsx-quotes": 0, // deprecated 177 | "jsx-quotes": [2, "prefer-double"], // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-quotes.md 178 | "react/jsx-no-undef": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-no-undef.md 179 | "react/jsx-sort-props": 0, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-sort-props.md 180 | "react/jsx-sort-prop-types": 0, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-sort-prop-types.md 181 | "react/jsx-uses-react": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-uses-react.md 182 | "react/jsx-uses-vars": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-uses-vars.md 183 | "react/no-did-mount-set-state": [2, "allow-in-func"], // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/no-did-mount-set-state.md 184 | "react/no-did-update-set-state": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/no-did-update-set-state.md 185 | "react/no-multi-comp": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/no-multi-comp.md 186 | "react/no-unknown-property": 1, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/no-unknown-property.md 187 | "react/prop-types": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/prop-types.md 188 | "react/react-in-jsx-scope": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/react-in-jsx-scope.md 189 | "react/self-closing-comp": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/self-closing-comp.md 190 | "react/wrap-multilines": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/wrap-multilines.md 191 | "react/sort-comp": [2, { // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/sort-comp.md 192 | "order": [ 193 | "displayName", 194 | "propTypes", 195 | "contextTypes", 196 | "childContextTypes", 197 | "mixins", 198 | "statics", 199 | "defaultProps", 200 | "constructor", 201 | "getDefaultProps", 202 | "getInitialState", 203 | "state", 204 | "getChildContext", 205 | "/^_(?!(on|get|render))/", 206 | "componentWillMount", 207 | "componentDidMount", 208 | "componentWillReceiveProps", 209 | "shouldComponentUpdate", 210 | "componentWillUpdate", 211 | "componentDidUpdate", 212 | "componentWillUnmount", 213 | "/^_?on.+$/", 214 | "/^_?get.+$/", 215 | "/^_?render.+$/", 216 | "render" 217 | ] 218 | }] 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | npm-debug.log 29 | 30 | # Build directory 31 | dist 32 | static 33 | config.json 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Rangle.io 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 | ## Improving React + Redux performance with Reselect 2 | 3 | Reference repository for the blog post [located here](http://blog.rangle.io/react-and-redux-performance-with-reselect/). 4 | 5 | ### Getting Started 6 | 7 | ``` 8 | git clone https://github.com/neilff/react-redux-performance/ 9 | 10 | npm install 11 | npm run dev 12 | ``` 13 | 14 | ### Additional Resources 15 | 16 | - https://github.com/reactjs/reselect 17 | - http://redux.js.org/docs/recipes/ComputingDerivedData.html 18 | -------------------------------------------------------------------------------- /images/image00.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neilff/react-redux-performance/1cd9b63f07044a8a9ca77174041713743ff62ebf/images/image00.png -------------------------------------------------------------------------------- /images/image01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neilff/react-redux-performance/1cd9b63f07044a8a9ca77174041713743ff62ebf/images/image01.png -------------------------------------------------------------------------------- /images/image02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neilff/react-redux-performance/1cd9b63f07044a8a9ca77174041713743ff62ebf/images/image02.png -------------------------------------------------------------------------------- /images/image03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neilff/react-redux-performance/1cd9b63f07044a8a9ca77174041713743ff62ebf/images/image03.png -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "verbose": true, 3 | "ignore": [".git/*", "src/*", "node_modules/*", "dist/*", "build/*"] 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-redux-performance", 3 | "version": "0.2.0", 4 | "description": "React + Redux Performance using Reselect", 5 | "engines": { 6 | "node": "4.x" 7 | }, 8 | "main": "src/app.js", 9 | "scripts": { 10 | "test:watch": "npm test -- --watch", 11 | "clean": "rimraf static/ dist/", 12 | "build": "npm run clean && env NODE_ENV=production webpack", 13 | "start": "npm run build; node server/index.js", 14 | "dev": "npm run clean; NODE_ENV=development nodemon --exec node server/index.js", 15 | "lint": "eslint src" 16 | }, 17 | "keywords": [ 18 | "react", 19 | "redux", 20 | "d3" 21 | ], 22 | "author": "Neil Fenton (http://github.com/neilff)", 23 | "repository": { 24 | "type": "git", 25 | "url": "git://github.com/neilff/react-redux-performance.git" 26 | }, 27 | "license": "MIT", 28 | "devDependencies": { 29 | "babel": "^6.3.26", 30 | "babel-cli": "^6.5.0", 31 | "babel-core": "^6.4.0", 32 | "babel-eslint": "^4.1.3", 33 | "babel-loader": "^6.2.1", 34 | "babel-polyfill": "^6.7.4", 35 | "babel-preset-es2015": "^6.3.13", 36 | "babel-preset-react": "^6.3.13", 37 | "babel-preset-stage-0": "^6.5.0", 38 | "babel-register": "^6.7.2", 39 | "css-loader": "^0.23.0", 40 | "cssnext-loader": "^1.0.1", 41 | "enzyme": "^2.2.0", 42 | "eslint": "^1.8.0", 43 | "eslint-loader": "^1.1.1", 44 | "eslint-plugin-react": "^3.6.3", 45 | "extract-text-webpack-plugin": "^1.0.1", 46 | "file-loader": "^0.8.5", 47 | "json-loader": "^0.5.3", 48 | "nodemon": "^1.8.1", 49 | "postcss-loader": "^0.8.0", 50 | "react-hot-loader": "^1.3.0", 51 | "source-map-loader": "^0.1.5", 52 | "style-loader": "^0.13.0", 53 | "url-loader": "^0.5.6", 54 | "webpack": "^1.12.2", 55 | "webpack-dev-middleware": "^1.5.1", 56 | "webpack-dev-server": "^1.14.1", 57 | "webpack-hot-middleware": "^2.7.1" 58 | }, 59 | "dependencies": { 60 | "basscss": "^7.1.1", 61 | "body-parser": "^1.15.0", 62 | "chalk": "^1.1.1", 63 | "d3": "^3.5.16", 64 | "express": "^4.13.4", 65 | "immutable": "^3.7.6", 66 | "ionicons": "^2.0.1", 67 | "react": "^0.14.7", 68 | "react-dom": "^0.14.7", 69 | "react-redux": "^4.4.1", 70 | "redux": "^3.3.1", 71 | "redux-localstorage": "^0.4.0", 72 | "redux-logger": "^2.6.1", 73 | "redux-thunk": "^2.0.1", 74 | "reselect": "^2.5.1", 75 | "sass-loader": "^3.2.0", 76 | "winston": "^2.2.0" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | require('babel-core/register'); 2 | require('babel-polyfill'); 3 | require('./server.js'); 4 | -------------------------------------------------------------------------------- /server/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Reselect Example 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | import bodyParser from 'body-parser'; 2 | import express from 'express'; 3 | import http from 'http'; 4 | import path from 'path'; 5 | 6 | const PORT = process.env.PORT || 8001; 7 | const NODE_ENV = process.env.NODE_ENV || 'production'; 8 | 9 | const app = express(); 10 | 11 | // Configure Express 12 | app.use(bodyParser.urlencoded({ extended: false })); 13 | app.use('/dist', express.static(path.join(__dirname, '..', '/dist'))); 14 | app.use('/public', express.static(path.join(__dirname, '..', '/dist'))); 15 | 16 | // Compile webpack bundle in development 17 | if (NODE_ENV !== 'production') { 18 | const webpack = require('webpack'); 19 | const config = require('../webpack.config'); 20 | 21 | const compiler = webpack(config); 22 | 23 | app.use(require('webpack-dev-middleware')(compiler, { 24 | noInfo: true, 25 | publicPath: config.output.publicPath 26 | })); 27 | 28 | app.use(require('webpack-hot-middleware')(compiler, { 29 | log: console.log, 30 | path: '/__webpack_hmr', 31 | heartbeat: 10 * 1000 32 | })); 33 | } 34 | 35 | // Send index.html when root url is requested 36 | app.get('/', (req, res) => res.sendFile(__dirname + '/public/index.html')); 37 | 38 | const server = http.createServer(app); 39 | 40 | console.log(JSON.stringify({ 41 | 'React Redux Performance': null, 42 | 'Address:': `http://localhost:${ PORT }`, 43 | 'NODE_ENV:': NODE_ENV, 44 | }, null, 2)); 45 | 46 | server.listen(PORT); 47 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import jsToImmutable from 'shared/utils/jsToImmutable'; 5 | 6 | import ShoppingCart from 'modules/cart/components/ShoppingCart'; 7 | 8 | import createStore from 'store/createStore'; 9 | 10 | const store = createStore(jsToImmutable(window.__INITIAL_STATE__ || {})); 11 | 12 | render( 13 | 14 | 15 | , 16 | document.getElementById('root') 17 | ); 18 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // Require Babel Transpiler 2 | require('babel-core/register'); 3 | require('babel-polyfill'); 4 | 5 | // Require CSS 6 | require('basscss/css/basscss.css'); 7 | require('ionicons/css/ionicons.css'); 8 | require('./shared/styles/style.css'); 9 | 10 | // Require APP 11 | require('./app.js'); 12 | -------------------------------------------------------------------------------- /src/modules/cart/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const ADD_ITEM = '@@redux/ADD_ITEM'; 2 | export const REMOVE_ITEM = '@@redux/REMOVE_ITEM'; 3 | export const SELECT_STATE = '@@redux/SELECT_STATE'; 4 | export const CHANGE_QUANTITY = '@@redux/CHANGE_QUANTITY'; 5 | -------------------------------------------------------------------------------- /src/modules/cart/actions.js: -------------------------------------------------------------------------------- 1 | import { 2 | ADD_ITEM, 3 | REMOVE_ITEM, 4 | SELECT_STATE, 5 | CHANGE_QUANTITY, 6 | } from './actionTypes'; 7 | 8 | export function addItem(itemId) { 9 | return { 10 | type: ADD_ITEM, 11 | payload: itemId, 12 | }; 13 | } 14 | 15 | export function removeItem(itemId) { 16 | return { 17 | type: REMOVE_ITEM, 18 | payload: itemId, 19 | }; 20 | } 21 | 22 | export function changeQuantity(index, change) { 23 | return { 24 | type: CHANGE_QUANTITY, 25 | payload: { index, change }, 26 | }; 27 | } 28 | 29 | export function selectState(state) { 30 | return { 31 | type: SELECT_STATE, 32 | payload: state, 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /src/modules/cart/components/AvailableItems.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes, Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { bindActionCreators } from 'redux'; 4 | import { Map } from 'immutable'; 5 | 6 | import { getAvailableItems } from 'modules/cart/selectors'; 7 | 8 | function mapStateToProps(state) { 9 | return { 10 | items: getAvailableItems(state), 11 | }; 12 | } 13 | 14 | import * as cartActions from 'modules/cart/actions'; 15 | 16 | function mapDispatchToProps(dispatch) { 17 | return bindActionCreators({ 18 | ...cartActions, 19 | }, dispatch); 20 | } 21 | 22 | class AvailableItems extends Component { 23 | render() { 24 | const { items, addItem } = this.props; 25 | 26 | return ( 27 | 28 | 29 | 30 | 31 | 32 | 34 | 35 | 36 | {(() => { 37 | if (items.size === 0) { 38 | return ( 39 | 40 | 43 | 44 | ); 45 | } 46 | 47 | return items.map((i, idx) => ( 48 | 49 | 50 | 51 | 56 | 57 | )).toList(); 58 | })()} 59 | 60 |
ItemPrice 33 |
41 | There are no more available items. 42 |
{ i.get('name') }${ i.get('price') } 52 | 55 |
61 | ); 62 | } 63 | } 64 | 65 | AvailableItems.displayName = 'AvailableItems'; 66 | AvailableItems.propTypes = { 67 | items: PropTypes.instanceOf(Map), 68 | addItem: PropTypes.func, 69 | }; 70 | AvailableItems.defaultProps = {}; 71 | 72 | export default connect( 73 | mapStateToProps, 74 | mapDispatchToProps 75 | )(AvailableItems); 76 | -------------------------------------------------------------------------------- /src/modules/cart/components/CartItems.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes, Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { bindActionCreators } from 'redux'; 4 | import { Map } from 'immutable'; 5 | 6 | import { getItemsWithTotals } from 'modules/cart/selectors'; 7 | 8 | function mapStateToProps(state) { 9 | return { 10 | items: getItemsWithTotals(state), 11 | }; 12 | } 13 | 14 | import * as cartActions from 'modules/cart/actions'; 15 | 16 | function mapDispatchToProps(dispatch) { 17 | return bindActionCreators({ 18 | ...cartActions, 19 | }, dispatch); 20 | } 21 | 22 | class CartItems extends Component { 23 | render() { 24 | const { items, removeItem, changeQuantity } = this.props; 25 | 26 | return ( 27 | 28 | {(() => { 29 | if (items.size === 0) { 30 | return ( 31 | 32 | 33 | There are no items in your cart. 34 | 35 | 36 | ); 37 | } 38 | 39 | return items.map((i, idx) => ( 40 | 41 | { i.get('name') } 42 | 43 | 48 | 49 | { i.get('quantity') } 50 | 51 | 56 | 57 | ${ i.get('price') } 58 | ${ i.get('total') } 59 | 60 | 61 | 62 | 63 | )).toList(); 64 | })()} 65 | 66 | ); 67 | } 68 | } 69 | 70 | CartItems.displayName = 'CartItems'; 71 | CartItems.propTypes = { 72 | items: PropTypes.instanceOf(Map), 73 | removeItem: PropTypes.func, 74 | changeQuantity: PropTypes.func, 75 | }; 76 | CartItems.defaultProps = {}; 77 | 78 | export default connect( 79 | mapStateToProps, 80 | mapDispatchToProps 81 | )(CartItems); 82 | -------------------------------------------------------------------------------- /src/modules/cart/components/DisplayValue.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { bindActionCreators } from 'redux'; 4 | 5 | function mapStateToProps(state, ownProps) { 6 | return { 7 | value: ownProps.selector(state), 8 | }; 9 | } 10 | 11 | import * as cartActions from 'modules/cart/actions'; 12 | 13 | function mapDispatchToProps(dispatch) { 14 | return bindActionCreators({ 15 | ...cartActions, 16 | }, dispatch); 17 | } 18 | 19 | export class DisplayValue extends Component { 20 | render() { 21 | const { value } = this.props; 22 | 23 | return ( 24 |
25 | ${ value.toFixed(2) } 26 |
27 | ); 28 | } 29 | } 30 | 31 | DisplayValue.displayName = 'DisplayValue'; 32 | DisplayValue.propTypes = { 33 | value: PropTypes.number, 34 | id: PropTypes.string, 35 | }; 36 | DisplayValue.defaultProps = {}; 37 | 38 | export default connect( 39 | mapStateToProps, 40 | mapDispatchToProps 41 | )(DisplayValue); 42 | -------------------------------------------------------------------------------- /src/modules/cart/components/ShoppingCart.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { getFinalTotal, getItemSubtotal } from 'modules/cart/selectors'; 4 | 5 | import CartItems from 'modules/cart/components/CartItems'; 6 | import TaxCalculator from 'modules/cart/components/TaxCalculator'; 7 | import DisplayValue from 'modules/cart/components/DisplayValue'; 8 | import AvailableItems from 'modules/cart/components/AvailableItems'; 9 | 10 | function ShoppingCart() { 11 | return ( 12 |
15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 25 | 26 | 27 | 28 | 29 | 32 | 35 | 36 | 37 | 40 | 43 | 44 | 45 | 48 | 51 | 52 | 53 |
ItemQuantityCost Per UnitTotal 24 |
30 |
Total Tax
31 |
33 | 34 |
38 |
Subtotal
39 |
41 | 42 |
46 |
Total
47 |
49 | 50 |
54 |
55 | 56 |
57 | 58 |
59 |
60 | ); 61 | } 62 | 63 | ShoppingCart.displayName = 'ShoppingCart'; 64 | ShoppingCart.propTypes = {}; 65 | ShoppingCart.defaultProps = {}; 66 | 67 | export default ShoppingCart; 68 | -------------------------------------------------------------------------------- /src/modules/cart/components/TaxCalculator.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { bindActionCreators } from 'redux'; 4 | 5 | import { taxRates } from 'modules/cart/constants'; 6 | import { getSelectedState, getTotalTax } from 'modules/cart/selectors'; 7 | 8 | function mapStateToProps(state) { 9 | return { 10 | currentState: getSelectedState(state), 11 | totalTax: getTotalTax(state), 12 | }; 13 | } 14 | 15 | import * as cartActions from 'modules/cart/actions'; 16 | 17 | function mapDispatchToProps(dispatch) { 18 | return bindActionCreators({ 19 | ...cartActions, 20 | }, dispatch); 21 | } 22 | 23 | export class TaxCalculator extends Component { 24 | render() { 25 | const { currentState, selectState, totalTax } = this.props; 26 | 27 | return ( 28 |
29 | ${ totalTax.toFixed(2) } 30 | 31 | 44 | 45 |
46 | ); 47 | } 48 | } 49 | 50 | TaxCalculator.displayName = 'TaxCalculator'; 51 | TaxCalculator.propTypes = { 52 | totalTax: PropTypes.number, 53 | taxPercent: PropTypes.number, 54 | currentState: PropTypes.string, 55 | selectState: PropTypes.func, 56 | }; 57 | TaxCalculator.defaultProps = {}; 58 | 59 | export default connect( 60 | mapStateToProps, 61 | mapDispatchToProps 62 | )(TaxCalculator); 63 | -------------------------------------------------------------------------------- /src/modules/cart/constants.js: -------------------------------------------------------------------------------- 1 | import { fromJS } from 'immutable'; 2 | 3 | export const taxRates = { 4 | AB: 0.05, 5 | BC: 0.12, 6 | MB: 0.13, 7 | ON: 0.13, 8 | }; 9 | 10 | export const itemsAvailable = fromJS({ 11 | 'MB': { 12 | id: 'MB', 13 | name: 'MacBook Pro', 14 | price: 1200, 15 | }, 16 | 'AD': { 17 | id: 'AD', 18 | name: 'Apple Display', 19 | price: 1000, 20 | }, 21 | 'LM': { 22 | id: 'LM', 23 | name: 'Logitech Mouse', 24 | price: 14, 25 | }, 26 | 'AK': { 27 | id: 'AK', 28 | name: 'Apple Keyboard', 29 | price: 36, 30 | }, 31 | 'DL': { 32 | id: 'DL', 33 | name: 'Desk Lamp', 34 | price: 24, 35 | }, 36 | 'TP': { 37 | id: 'TP', 38 | name: '3d Printer', 39 | price: 399, 40 | }, 41 | 'AU': { 42 | id: 'AU', 43 | name: 'Arduino Uno', 44 | price: 34, 45 | }, 46 | 'PP': { 47 | id: 'PP', 48 | name: 'Particle Photon', 49 | price: 19, 50 | }, 51 | }); 52 | -------------------------------------------------------------------------------- /src/modules/cart/reducer.js: -------------------------------------------------------------------------------- 1 | import { fromJS } from 'immutable'; 2 | import { 3 | ADD_ITEM, 4 | REMOVE_ITEM, 5 | SELECT_STATE, 6 | CHANGE_QUANTITY, 7 | } from 'modules/cart/actionTypes'; 8 | 9 | import { itemsAvailable } from 'modules/cart/constants'; 10 | 11 | const demoItems = itemsAvailable.take(3).map(i => i.set('quantity', 3)); 12 | 13 | const INITIAL_STATE = fromJS({ 14 | items: demoItems, 15 | state: 'ON', 16 | }); 17 | 18 | export default function cartReducer(state = INITIAL_STATE, { type, payload }) { 19 | switch (type) { 20 | case ADD_ITEM: 21 | return state.setIn(['items', payload], itemsAvailable.get(payload).set('quantity', 1)); 22 | 23 | case REMOVE_ITEM: 24 | return state.updateIn(['items'], (items) => { 25 | return items.filter((i) => i.get('id') !== payload); 26 | }); 27 | 28 | case SELECT_STATE: 29 | return state.set('state', payload); 30 | 31 | case CHANGE_QUANTITY: 32 | return state.updateIn(['items', payload.index, 'quantity'], i => { 33 | const newVal = i + payload.change; 34 | return newVal > 1 ? newVal : 1; 35 | }); 36 | 37 | default: 38 | return state; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/modules/cart/selectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | 3 | import { taxRates, itemsAvailable } from 'modules/cart/constants'; 4 | 5 | export const getItems = (state) => state.cart.get('items'); 6 | export const getSelectedState = (state) => state.cart.get('state'); 7 | 8 | export const getItemsWithTotals = createSelector( 9 | [ getItems ], 10 | (items) => { 11 | return items.map(i => { 12 | return i.set('total', i.get('price', 0) * i.get('quantity')); 13 | }); 14 | } 15 | ); 16 | 17 | export const getItemSubtotal = createSelector( 18 | [ getItemsWithTotals ], 19 | (items) => { 20 | return items.reduce((acc, i) => { 21 | return acc + i.get('total'); 22 | }, 0); 23 | }, 24 | ); 25 | 26 | // Example of composing selectors 27 | export const getTotalTax = createSelector( 28 | [ getSelectedState, getItemSubtotal ], 29 | (selectedState, subtotal) => { 30 | return subtotal * taxRates[selectedState]; 31 | }, 32 | ); 33 | 34 | export const getFinalTotal = createSelector( 35 | [ getTotalTax, getItemSubtotal ], 36 | (totalTax, subtotal) => { 37 | return totalTax + subtotal; 38 | }, 39 | ); 40 | 41 | export const getAvailableItems = createSelector( 42 | [ getItems ], 43 | (items) => { 44 | return itemsAvailable.filter(i => !items.has(i.get('id'))); 45 | }, 46 | ); 47 | -------------------------------------------------------------------------------- /src/shared/styles/colors.js: -------------------------------------------------------------------------------- 1 | const colors = { 2 | black: '#231F20', 3 | white: '#F6F6F7', 4 | }; 5 | 6 | export default colors; 7 | -------------------------------------------------------------------------------- /src/shared/styles/style.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | html, 6 | body { 7 | font-family: 'Roboto'; 8 | } 9 | 10 | h1, 11 | h2, 12 | h3, 13 | h4, 14 | h5, 15 | h6 { 16 | font-family: 'Roboto'; 17 | } 18 | -------------------------------------------------------------------------------- /src/shared/utils/conversion.js: -------------------------------------------------------------------------------- 1 | import leftpad from 'left-pad'; 2 | 3 | export function convertKelvinToF(K) { 4 | let tempF; 5 | 6 | // Fahrenheit 7 | tempF = (K - 273.15) * 1.8000 + 32.00; 8 | tempF = Math.round(tempF * 10) / 10; 9 | 10 | return tempF; 11 | } 12 | 13 | export function convertKelvinToC(K) { 14 | let tempC; 15 | 16 | // Celsius 17 | tempC = K - 273.15; 18 | tempC = Math.round(tempC * 10) / 10; 19 | 20 | return tempC; 21 | } 22 | 23 | export function convertFahrenheitToK(F) { 24 | let tempK; 25 | 26 | // Kelvin 27 | tempK = ((F - 32) / 1.8) + 273.15; 28 | tempK = Math.round(tempK * 10) / 10; 29 | 30 | return tempK; 31 | } 32 | 33 | export function convertFahrenheitToC(F) { 34 | return (F - 32) / 1.8; 35 | } 36 | 37 | export function convertCelciusToK(C) { 38 | let tempK; 39 | 40 | // Kelvin 41 | tempK = C + 273.15; 42 | tempK = Math.round(tempK * 10) / 10; 43 | 44 | return tempK; 45 | } 46 | 47 | export function convertValue(value) { 48 | return (value / 100) * 2 * Math.PI; 49 | } 50 | 51 | export function calculateDonutArc(width, radius) { 52 | return d3.svg.arc() 53 | .outerRadius(width / 2) 54 | .innerRadius((width / 2) - radius) 55 | .startAngle(0); 56 | } 57 | 58 | export function convertMsToTime(ms) { 59 | let ss = parseInt(ms / 1000, 10); 60 | const hh = parseInt(ss / 3600, 10); 61 | ss = ss % 3600; 62 | const mm = parseInt(ss / 60, 10); 63 | ss = ss % 60; 64 | 65 | return `${ leftpad(hh, 2, 0) }:${ leftpad(mm, 2, 0) }:${ leftpad(ss, 2, 0) }`; 66 | } 67 | -------------------------------------------------------------------------------- /src/shared/utils/immutableToJS.js: -------------------------------------------------------------------------------- 1 | import { Iterable } from 'immutable'; 2 | 3 | /** 4 | * Provided an Immutable state object, it will return a JavaScript state tree 5 | * 6 | * @param {object} state State to convert from Immutable 7 | * @return {object} JavaScript state object 8 | */ 9 | export default function immutableToJS(state) { 10 | return Object.keys(state).reduce((newState, key) => { 11 | const val = state[key]; 12 | newState[key] = Iterable.isIterable(val) ? val.toJS() : val; 13 | return newState; 14 | }, {}); 15 | } 16 | -------------------------------------------------------------------------------- /src/shared/utils/jsToImmutable.js: -------------------------------------------------------------------------------- 1 | import { fromJS } from 'immutable'; 2 | 3 | /** 4 | * Provided an initial state object, it will convert it into an Immutable 5 | * initial state. 6 | * 7 | * @param {object} initialState Initial state object 8 | * @return {object} Object with Immutable properties 9 | */ 10 | export default function jsToImmutable(initialState) { 11 | const reducerKeys = Object.keys(initialState); 12 | return reducerKeys.reduce((acc, i) => { 13 | acc[i] = fromJS(initialState[i]); 14 | 15 | return acc; 16 | }, {}); 17 | } 18 | -------------------------------------------------------------------------------- /src/shared/utils/wait.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Wraps setTimeout in a Promise 3 | * 4 | * @param {Number} ms Miliseconds 5 | * @return {Promise} 6 | */ 7 | const wait = ms => ( 8 | new Promise(resolve => { 9 | setTimeout(() => resolve(), ms); 10 | }) 11 | ); 12 | 13 | export default wait; 14 | -------------------------------------------------------------------------------- /src/store/createStore.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from 'redux'; 2 | 3 | import thunk from 'redux-thunk'; 4 | import logger from './middleware/logger'; 5 | 6 | import rootReducer from './rootReducer'; 7 | 8 | const middlewares = __DEV__ ? 9 | [thunk, logger] : 10 | [thunk]; 11 | 12 | function configureStore(initialState) { 13 | const createStoreWithMiddleware = compose( 14 | applyMiddleware(...middlewares), 15 | )(createStore); 16 | 17 | const store = createStoreWithMiddleware(rootReducer, initialState); 18 | 19 | return store; 20 | } 21 | 22 | export default configureStore; 23 | -------------------------------------------------------------------------------- /src/store/middleware/logger.js: -------------------------------------------------------------------------------- 1 | import createLogger from 'redux-logger'; 2 | import immutableToJS from 'shared/utils/immutableToJS'; 3 | 4 | // Configure logging middleware 5 | const logger = createLogger({ 6 | collapsed: true, 7 | stateTransformer: (state) => { 8 | return immutableToJS(state); 9 | }, 10 | predicate: (getState, { type }) => { 11 | // List of actions we want to ignore 12 | const blacklist = []; 13 | 14 | return blacklist.every(i => type !== i); 15 | }, 16 | }); 17 | 18 | export default logger; 19 | -------------------------------------------------------------------------------- /src/store/rootReducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import cart from 'modules/cart/reducer'; 4 | 5 | export default combineReducers({ 6 | cart, 7 | }); 8 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 4 | 5 | console.log('========================================================'); 6 | console.log('WEBPACK NODE_ENV :: ', JSON.stringify(process.env.NODE_ENV)); 7 | console.log('========================================================'); 8 | 9 | function getEntrySources(sources) { 10 | if (process.env.NODE_ENV !== 'production') { 11 | sources.push('webpack-hot-middleware/client'); 12 | } 13 | 14 | return sources; 15 | } 16 | 17 | const basePlugins = [ 18 | new webpack.DefinePlugin({ 19 | __DEV__: process.env.NODE_ENV !== 'production', 20 | __PRODUCTION__: process.env.NODE_ENV === 'production', 21 | __MOCK_API__: process.env.MOCK_API === 'true', 22 | __TEST_UTILS__: process.env.NODE_ENV !== 'production' || process.env.QA_ENV === 'true', 23 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 24 | }), 25 | new ExtractTextPlugin('styles.css'), 26 | ]; 27 | 28 | const devPlugins = [ 29 | new webpack.HotModuleReplacementPlugin(), 30 | new webpack.NoErrorsPlugin(), 31 | ]; 32 | 33 | const prodPlugins = [ 34 | new webpack.optimize.OccurenceOrderPlugin(), 35 | new webpack.optimize.UglifyJsPlugin({ 36 | compressor: { 37 | warnings: false, 38 | }, 39 | }), 40 | ]; 41 | 42 | const plugins = basePlugins 43 | .concat(process.env.NODE_ENV === 'production' ? prodPlugins : devPlugins); 44 | 45 | module.exports = { 46 | devtool: process.env.NODE_ENV !== 'production' ? 'eval-source-map' : '', 47 | entry: { 48 | bundle: getEntrySources(['./src/index']), 49 | }, 50 | output: { 51 | publicPath: '/dist/', 52 | filename: 'bundle.js', 53 | path: path.join(__dirname, 'dist'), 54 | }, 55 | resolve: { 56 | root: path.resolve(__dirname), 57 | alias: { 58 | containers: 'src/containers', 59 | modules: 'src/modules', 60 | constants: 'constants', 61 | store: 'src/store', 62 | shared: 'src/shared', 63 | }, 64 | extensions: ['', '.js', '.jsx'], 65 | }, 66 | plugins: plugins, 67 | module: { 68 | preLoaders: [ 69 | { 70 | test: /\.js$/, 71 | loader: 'source-map-loader', 72 | }, 73 | ], 74 | loaders: [ 75 | { 76 | test: /\.css$/, 77 | loader: ExtractTextPlugin.extract( 78 | 'style-loader', 79 | 'css-loader', 80 | 'postcss-loader', 81 | 'cssnext-loader' 82 | ) 83 | }, 84 | { 85 | test: /\.scss$/, 86 | loader: ExtractTextPlugin.extract( 87 | 'style-loader', 88 | 'css-loader', 89 | 'sass-loader' 90 | ) 91 | }, 92 | { 93 | test: /\.js$/, 94 | loaders: ['react-hot', 'babel', 'eslint-loader'], 95 | exclude: /node_modules/, 96 | }, 97 | { 98 | test: /\.json$/, 99 | loader: 'json-loader', 100 | }, 101 | { 102 | test: /\.(png|jpg|jpeg|gif|svg)$/, 103 | loader: 'url-loader?prefix=img/&limit=5000', 104 | }, 105 | { 106 | test: /\.(png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot)(\?v=[0-9]\.[0-9]\.[0-9])?$/, 107 | loader: 'file-loader' 108 | }, 109 | ], 110 | }, 111 | }; 112 | --------------------------------------------------------------------------------