├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .flowconfig ├── .gitattributes ├── .gitignore ├── .huskyrc ├── .lintstagedrc ├── .nycrc ├── .prettierignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── package.json ├── postcss.config.js ├── scripts ├── ci.js ├── script.js └── test.js ├── src ├── flow │ └── general-stub.js.flow ├── html │ └── index.html ├── img │ ├── chuck-norris.jpg │ ├── favicon.png │ └── user.jpg └── js │ ├── __tests__ │ └── index.js │ ├── app │ ├── actions │ │ ├── actions.js │ │ ├── index.js │ │ └── types.js │ ├── apis │ │ ├── __tests__ │ │ │ └── getJokeApi-spec.js │ │ ├── getJokeApi.js │ │ ├── index.js │ │ └── types.js │ ├── epics │ │ ├── __tests__ │ │ │ └── getJokeEpic-spec.js │ │ ├── getJokeEpic.js │ │ └── index.js │ ├── reducers │ │ ├── __tests__ │ │ │ ├── chuckNorrisReducer-spec.js │ │ │ ├── createReducer-spec.js │ │ │ └── todoManagerReducer-spec.js │ │ ├── chuckNorrisReducer.js │ │ ├── createReducer.js │ │ ├── index.js │ │ ├── layoutReducer.js │ │ └── todoManagerReducer.js │ ├── selectors │ │ ├── index.js │ │ ├── makeVisibleTodosSelector.js │ │ └── types.js │ ├── states │ │ ├── index.js │ │ └── types.js │ ├── store │ │ ├── index.js │ │ └── reduxDevtoolsExtension.js │ └── utils │ │ ├── __tests__ │ │ ├── env-spec.js │ │ └── urlHelper-spec.js │ │ ├── env.js │ │ ├── muiTheme.js │ │ └── urlHelper.js │ ├── components │ ├── app │ │ └── App.js │ ├── chuckNorris │ │ ├── ChuckNorris.js │ │ ├── ChuckNorrisConnected.js │ │ └── index.js │ ├── error │ │ ├── PageNotFoundError.js │ │ ├── UnexpectedError.js │ │ └── index.js │ ├── home │ │ ├── Home.js │ │ └── index.js │ ├── layout │ │ ├── Layout.js │ │ ├── LayoutConnected.js │ │ ├── MenuDrawer.js │ │ ├── MenuDrawerConnected.js │ │ └── index.js │ └── todoManager │ │ ├── AddTodo.js │ │ ├── AddTodoConnected.js │ │ ├── Footer.js │ │ ├── Link.js │ │ ├── LinkConnected.js │ │ ├── Todo.js │ │ ├── TodoList.js │ │ ├── TodoListConnected.js │ │ ├── TodoManager.js │ │ ├── __tests__ │ │ ├── Link-spec.js │ │ └── TodoList-spec.js │ │ └── index.js │ └── index.js ├── webpack.base.config.js ├── webpack.config.js ├── webpack.dev.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-flow", "@babel/preset-react"], 3 | "plugins": [ 4 | "@babel/plugin-proposal-export-default-from", 5 | "@babel/plugin-proposal-class-properties" 6 | ], 7 | "env": { 8 | "test": { 9 | "plugins": ["istanbul"] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .webpack/** 2 | node/** 3 | node_modules/** 4 | scripts/* 5 | **/*.flow 6 | postcss.config.js 7 | webpack.base.config.js 8 | webpack.config.js 9 | webpack.dev.config.js 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | // using Airbnb and Flow best recommendations 3 | "extends": [ 4 | "airbnb", 5 | "plugin:flowtype/recommended", 6 | "plugin:prettier/recommended", 7 | "prettier/react" 8 | ], 9 | // to handle ES7's static properties in React component 10 | // https://github.com/gajus/eslint-plugin-flowtype#installation 11 | "parser": "babel-eslint", 12 | // Enable Flow type linting 13 | "plugins": ["flowtype"], 14 | // To prevent "'window' is not defined" or "'document' is not defined" 15 | "env": { 16 | "es6": true, 17 | "browser": true, 18 | "node": true, 19 | "mocha": true 20 | }, 21 | // overriding certain rules 22 | "rules": { 23 | // Allow JSX on `.js` 24 | // See https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-filename-extension.md 25 | "react/jsx-filename-extension": [ 26 | 1, 27 | { 28 | "extensions": [".js"] 29 | } 30 | ], 31 | // `// @flow` must exist on the top of all files 32 | "flowtype/require-valid-file-annotation": [ 33 | 2, 34 | "always", 35 | { 36 | "annotationStyle": "line" 37 | } 38 | ], 39 | // To allow Immer to mutate `draft` object 40 | // See https://github.com/mweststrate/immer/issues/189 41 | "no-param-reassign": [ 42 | "error", 43 | { 44 | "props": true, 45 | "ignorePropertyModificationsFor": ["draft"] 46 | } 47 | ] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | # Example to ignore problematic modules that cause Flow errors 3 | # /node_modules/problematic-module/.* 4 | 5 | [options] 6 | module.file_ext=.js 7 | module.file_ext=.jsx 8 | module.file_ext=.json 9 | 10 | # Example to fix "Required module not found" error after ignoring dependencies above due to flow validation error 11 | # module.name_mapper='^problematic-module$' -> '/src/flow/general-stub.js.flow' 12 | 13 | esproposal.class_static_fields=enable 14 | esproposal.class_instance_fields=enable 15 | 16 | suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe 17 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # All text files should have the "lf" (Unix) line endings 2 | * text eol=lf 3 | 4 | # Explicitly declare text files you want to always be normalized and converted 5 | # to native line endings on checkout. 6 | *.js text 7 | *.css text 8 | *.scss text 9 | *.html text 10 | *.md text 11 | *.json text 12 | *.flow text 13 | .babelrc text 14 | .editorconfig text 15 | .eslintrc text 16 | .flowconfig text 17 | .gitattributes text 18 | .gitignore text 19 | .huskyrc text 20 | .lintstagedrc text 21 | .nycrc text 22 | .prettierignore text 23 | .prettierrc text 24 | yarn.lock text 25 | 26 | # Denote all files that are truly binary and should not be modified. 27 | *.png binary 28 | *.jpg binary 29 | *.eot binary 30 | *.ttf binary 31 | *.woff binary 32 | *.woff2 binary 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.class 3 | 4 | # Mobile Tools for Java (J2ME) 5 | .mtj.tmp/ 6 | 7 | # Package Files # 8 | *.jar 9 | *.war 10 | *.ear 11 | *.iml 12 | npm-debug.log 13 | yarn-error.log 14 | stats.json 15 | target/ 16 | .idea/ 17 | node_modules/ 18 | reports/ 19 | dist/ 20 | .nyc_output/ 21 | 22 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 23 | hs_err_pid* 24 | -------------------------------------------------------------------------------- /.huskyrc: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "lint-staged" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.{js,json,md}": [ 3 | "prettier --write", 4 | "git add" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "require": [ 3 | "@babel/register" 4 | ], 5 | "reporter": [ 6 | "html", 7 | "cobertura" 8 | ], 9 | "sourceMap": false, 10 | "instrument": false, 11 | "all": true 12 | } 13 | 14 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | dist/ 3 | reports/ 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 0.17.1 - 2020-09-07 4 | 5 | - Dependency updates. 6 | 7 | ## 0.17.0 - 2019-10-03 8 | 9 | - Webpack - Removed `optimization` block because it is redundant. 10 | - Webpack - Configured `MiniCssExtractPlugin` to create CSS files under `css/` for consistency. 11 | - Used React Hooks. 12 | 13 | ## 0.16.0 - 2019-04-03 14 | 15 | - Externalized husky config from `package.json` into `.huskyrc` and `.lintstagedrc`. 16 | - Dependency update. 17 | 18 | ## 0.15.0 - 2018-12-28 19 | 20 | - Replaced `react-router-redux` with `connected-react-router` because the earlier won't work with Redux v6. 21 | - Run Prettier on Git pre commit. 22 | - Removed `uglifyjs-webpack-plugin` to use Webpack's built-in `terser-webpack-plugin`. 23 | 24 | ## 0.14.0 - 2018-10-04 25 | 26 | - Babel - Upgrade to v7. 27 | - Material-UI - Upgrade to v3. 28 | - `yarn reinstall` - Added `--network-timeout 1000000` to fix "There appears to be trouble with your network connection. Retrying..." error. 29 | - Replaced `immutableJs` with `immer` because... 30 | - Works great with Flow out of the box. Flow type for `Record` in `immutableJs` is still broken. 31 | - Straight up JS when mutating properties. 32 | - No loss of performance. 33 | - ImmutableJS is somewhat defunct. 34 | - Introduced Prettier to format the code. 35 | - Configured to play well with ESLint and Airbnb rules so that less time spent on fixing linter errors. 36 | - `yarn reinstall` - Don't clear global cache... just the app's `node_modules` before re-installing it. 37 | - Note: `react-router-redux` is deprecated now, but it's left in here until there is a better way to programmatically change routes from middleware instead of pushing `history` object into actions as recommended by https://reacttraining.com/react-router/web/guides/redux-integration 38 | 39 | ## 0.13.0 - 2018-08-02 40 | 41 | - `Package.json` - `"sideEffects": false` for tree-shaking purpose. See https://webpack.js.org/guides/tree-shaking/ 42 | - Webpack - When bundling JS files, move comments into separate files to further reduce file size. 43 | - Dependency update. 44 | 45 | ## 0.12.0 - 2018-05-23 46 | 47 | - ESLint - Configured to automatically fix "soft" errors when running any Webpack commands and `yarn test`. 48 | - Webpack - Configured CompressionPlugin to generate GZIP compression on asset files. 49 | - Replaced `moment` with `date-fns` because the latter has smaller bundle and it creates immutable objects. 50 | - Added `recompose` that contains useful React utility function components and HOCs. 51 | - Upgraded `material-ui` to v1.0 (Release). 52 | - Upgraded `rxjs` to v6.x. 53 | - Added `flow:restart` package.json script that will stop Flow server before re-running it. 54 | 55 | ## 0.11.0 - 2018-03-18 56 | 57 | - Webpack 4 58 | 59 | - Replaced `extract-text-webpack-plugin` with `mini-css-extract-plugin`. 60 | - Removed `Happypack`. 61 | - Removed `style-loader`. 62 | - Applied tree-shaking to `vendor.js` to significantly reduce file size from 1.73MB to 768KB. 63 | 64 | - Restructured file organization. 65 | 66 | - When running `yarn ci`, don't run Flow because it has higher chances of breaking when attempting to run Flow server on CI servers such as Jenkins and TFS Build. 67 | 68 | - ESLint - Configured rules to allow quotes around number... for example: `const a = { '1919': 'value' };` 69 | 70 | - Mocha - Replaced deprecated `--compilers` with `--require`. See https://github.com/mochajs/mocha/wiki/compilers-deprecation 71 | 72 | - Flow - Flow-typed files. 73 | 74 | - Dependency update. 75 | 76 | ## 0.10.0 - 2017-11-29 77 | 78 | - Upgraded `material-ui` to v1.0. 79 | - Replaced `roboto-fontface` with `typeface-roboto`. Worked better with `material-ui`. 80 | - Added `classnames` to make it easier to work with CSS class names since `material-ui` has switched to `jss`. 81 | - Added `material-ui-icons`. 82 | - Removed `radium`. 83 | - Removed `sass-loader`. 84 | - Removed `node-sass`. 85 | - Removed `react-tap-event-plugin`. No longer needed with `material-ui`. 86 | - Replaced `redux-saga` with `redux-observable`. 87 | 88 | - Added `redux-observable`. 89 | - Added `rxjs`. 90 | - Removed `redux-saga`. 91 | - Removed `isomorphic-fetch`. RxJS provides `Observable.ajax()` out of the box to perform API call. 92 | - Removed `redux-saga-test-plan`. 93 | 94 | - Upgraded `react` to v16. 95 | 96 | - Added `enzyme-adapter-react-16`. 97 | - Removed `react-addons-perf`. No longer supported. 98 | 99 | - Upgraded `react-router-redux` to v5. 100 | 101 | - Added `react-router-dom`. 102 | - Upgraded `history` to v4.x. 103 | - Removed `react-router`. 104 | 105 | - Replaced `webpack-parallel-uglify-plugin` with `uglifyjs-webpack-plugin` because the latter now supports parallel threads and it is tad faster. 106 | 107 | - Added `find-cache-dir` for consistent cache location under `node_modules/` for easier cleanup. 108 | 109 | - Dependency update. 110 | 111 | ## 0.9.2 - 2017-09-08 112 | 113 | - `nyc` failed to exclude directories properly. 114 | - Added `.eslintignore` file. 115 | - Configured `yarn reinstall` to remove `node_modules` dir before reinstalling it. 116 | - Cleaned up code. 117 | - Dependency updates. 118 | 119 | ## 0.9.1 - 2017-09-06 120 | 121 | - `image-webpack-loader` produced corrupted JPEG files. 122 | - Dependency updates. 123 | 124 | ## 0.9.0 - 2017-09-01 125 | 126 | - Significantly sped up Webpack build time. 127 | - Replaced `npm` with `yarn`. 128 | - `yarn start` will automatically open the browser and bring user to the landing page. 129 | - Added `postcss.config.js` to fix "No PostCSS Config found" error. See https://github.com/postcss/postcss-loader/issues/204 130 | - Added `prop-types` to handle "Accessing PropTypes via the main React package is deprecated. Use the prop-types package from npm instead." warning. 131 | - Externalized nyc config from `package.json` to `.nycrc`. 132 | - ESLint configuration - Allowed trailing comma in multi-line object literal or array (works with IntelliJ IDEA 2017). 133 | - Added `cache-loader` to fix HappyPack's "Option 'tempDir' has been deprecated. Configuring it will cause an error to be thrown in future versions." warning. 134 | - Removed deprecated `react-addons-test-utils` and added `react-test-renderer` to get `enzyme` working again with new React version. 135 | - Dependency update. 136 | 137 | ## 0.8.1 - 2017-02-23 138 | 139 | - Dependency update. 140 | 141 | - Fixed deprecation warnings from `image-webpack-loader`. See https://github.com/tcoopman/image-webpack-loader/issues/68 142 | - Fixed deprecation warnings from `webpack 2`. 143 | - `fallbackLoader` option - Replaced with `fallback`. 144 | - `loader` option - Replaced with `use`. 145 | 146 | ## 0.8.0 - 2017-01-18 147 | 148 | - Replaced unmaintained `isparta` with `nyc` and `babel-plugin-istanbul`. 149 | 150 | - `nyc` configuration must reside under `package.json` because `babel-plugin-istanbul` will not work properly when placed under `.nycrc`. 151 | - See https://github.com/istanbuljs/nyc/issues/419 152 | - See https://github.com/istanbuljs/nyc/issues/377 153 | - Instead of hardcoding `include` path under `package.json`, which prevents dynamic configuration based on user settings, `exclude` paths are used. 154 | 155 | - Configured Flow type check. 156 | 157 | - Configured Flow type linting. 158 | - `npm run flow` - Run Flow. 159 | - `npm test` - Run Flow first before running tests. 160 | - ESLint rule to ensure `// @flow` exists on the top of all files. 161 | - For certain Flow related problems (`record.toJS()`, `PropTypes.children`, etc), suppressed with `// $FlowFixMe` until they are fixed in the future. 162 | 163 | - Added `yarn.lock` file to accommodate developers using Yarn. 164 | 165 | - Simplified module structure to prevent too many "single file in a directory" problems. 166 | - Combined `npm run ci:clean`, `npm run ci:test` and `npm run ci:coverage` into `npm run ci` to prevent Mocha from running twice (once to generate test result file and another to generate code coverage result file). 167 | 168 | - `scripts/script.js` - `--require ./src/js/__tests__` should use user configurable path. 169 | 170 | - Suppressed "WARNING in asset size limit" warning on `npm run build`. 171 | - Removed commented `DedupePlugin` config because it will be removed in Webpack 2. See https://github.com/webpack/webpack/issues/2644 172 | - Fixed "Using NoErrorsPlugin is deprecated. Use NoEmitOnErrorsPlugin instead.". 173 | 174 | ## 0.7.1 - 2016-12-29 175 | 176 | - Dependency updates, particularly react-redux 5.x and webpack 2.2.x. 177 | 178 | ## 0.7.0 - 2016-11-30 179 | 180 | - Webpack 2.x and tree shaking. Since Webpack 2.x supports `import` natively, modules are no longer converted to CommonJS modules by Babel. 181 | - Upgraded to `material-ui` 0.16.x. 182 | - Added `npm run stats` to create `stats.json` that can be loaded to http://webpack.github.io/analyse/ 183 | - Enabled HTTPS on webpack-dev-server. 184 | - Dropped `es6-promise` and `babel-plugin-transform-runtime`. Replaced with `babel-polyfill` to have more complete ES6 polyfills. 185 | - Configured `rimraf` in `npm run reinstall` not to delete `rimraf` and `.bin` within `node_modules` to prevent Windows from throwing an error. See https://github.com/isaacs/rimraf/issues/102 186 | 187 | ## 0.6.0 - 2016-09-20 188 | 189 | - Configured `react-addons-perf` module to work with `React Perf` extension in Chrome. 190 | 191 | ## 0.5.0 - 2016-08-24 192 | 193 | - Added `Reselect`. 194 | - Added `.gitattributes` to ensure end-of-line is always LF instead of CRLF to satisfy ESLint. 195 | - Added `.editorconfig`. 196 | - Cleaned up code. 197 | - Combined src and test in same dir to make things easier to test. 198 | 199 | ## 0.4.0 - 2016-07-19 200 | 201 | - Enabled Redux Dev Tools. 202 | - `npm test ./test/abc` and `npm run test:watch ./test/abc` to run (and watch) only tests within `./test/abc`. 203 | - Configured `webpack-dev-server` to prevent "No 'Access-Control-Allow-Origin' header is present on the requested resource". 204 | - Added `enzyme` and `es6-promise` dependencies. 205 | - Ref Callback instead of Ref String. See `https://facebook.github.io/react/docs/more-about-refs.html`. 206 | - Dependency updates. 207 | - `history v3.0.0` still doesn't work with `react-router`. See https://github.com/reactjs/react-router/issues/3515 208 | - Tested with `Node.js v6.2.2`. 209 | - `webpack-dev-server` not resolving Roboto font path in CSS file. 210 | - Lint both `src` and `test` dirs on `npm test` and `npm run ci`. Lint first before running tests. 211 | 212 | ## 0.3.3 - 2016-07-01 213 | 214 | - `npm run test:watch` - cross-platform approach to watch for changes in test files before rerunning the tests. 215 | - webpack-dev-server's proxy doesn't work when the context root doesn't have a trailing slash. 216 | - `npm run ci` doesn't execute ESLint after executing Mocha. 217 | - Changed `const { describe, it } = global;` back to `import { describe, it } from 'mocha';` since `mocha --watch` works now. 218 | - Dependency updates. 219 | - `history v3.0.0` still doesn't work with `react-router`. See https://github.com/reactjs/react-router/issues/3515 220 | 221 | ## 0.3.2 - 2016-06-23 222 | 223 | - Allowed user to override context root when running the production build: `CONTEXT_ROOT=/new-context-root npm run build`. 224 | 225 | ## 0.3.1 - 2016-06-22 226 | 227 | - Changed `import { describe, it } from 'mocha';` to `const { describe, it } = global;` to allow `mocha --watch` to work. See https://github.com/mochajs/mocha/issues/1847. 228 | - Dependency updates. 229 | - `history v3.0.0` doesn't work with `react-router`. See https://github.com/reactjs/react-router/issues/3515 230 | - Git-ignored `dist/`. 231 | 232 | ## 0.3.0 - 2016-05-20 233 | 234 | - `Invalid regular expression: /^\api\(.*)\/?$/: Unmatched ')'` with running `npm start` in Windows. 235 | - Cross-platform compatible NPM script. Tested to work on Mac and Windows. 236 | - Updated dependency versions. 237 | 238 | ## 0.2.0 - 2016-05-11 239 | 240 | - Ported to `choonchernlim-archetype-webapp`. 241 | 242 | ## 0.1.0 - 2016-04-11 243 | 244 | - Initial. 245 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Choon-Chern Lim 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 | # front-end-stack 2 | 3 | Starter kit for building single-page app using modern front-end stack. 4 | 5 | ## Getting Started 6 | 7 | - Install the following tools:- 8 | 9 | - [Node.js](https://github.com/creationix/nvm). 10 | - [Yarn](https://yarnpkg.com/en/docs/install) because it is still much faster than NPM v5. 11 | 12 | - In Chrome, install the following dev tool extensions:- 13 | 14 | - [React Developer Tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi?hl=en) 15 | - [Redux DevTools](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd) 16 | 17 | - If you are using JetBrains products (ex: IntelliJ IDEA, WebStorm):- 18 | 19 | - Install and configure [File Watcher](https://prettier.io/docs/en/webstorm.html#running-prettier-on-save-using-file-watcher) to format code on save using Prettier. 20 | - Enable "ESLint" in your IDE, which will pick up `.eslintrc` from the project and enforce it. 21 | 22 | - Clone or download/unzip this project template. 23 | 24 | - Run `yarn` to install dependencies. 25 | 26 | - To start app development, run `yarn start`. 27 | 28 | - This command will:- 29 | - Start Webpack Dev Server. 30 | - Open default browser. 31 | - Open `https://localhost:8080`. 32 | - When you modify the source code, the configured Hot Module Replacement will automatically refresh the browser content. 33 | - Since HTTPS is used, Chrome will prompt warning regarding untrusted security certificate. To disable this check:- 34 | - In Chrome, go to `chrome://flags/#allow-insecure-localhost`. 35 | - Click "Enable". 36 | - Click "Relaunch Now". 37 | 38 | - To package for production, run `yarn build`. 39 | 40 | - This script will clean the distribution directory and create minified bundle files. 41 | 42 | - To package for production with a different context root than the one defined in `package.json`, run `CONTEXT_ROOT=/new-context-root yarn build`. 43 | 44 | - To configure as Jenkins job, run `yarn ci`. 45 | - This script will create test result and code coverage files. 46 | 47 | ## Commands 48 | 49 | These commands are cross-platform compatible. 50 | 51 | | Command | Description | 52 | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | 53 | | `yarn test` | Format code, static type check, lint src/test files and run entire tests | 54 | | `yarn test <./path>` | Format code, static type check, lint src/test files and run only tests within `<./path>` | 55 | | `yarn test:watch` | Watch for changes in all test files and rerun `yarn test` | 56 | | `yarn test:watch <./path>` | Watch for changes in selected test files and rerun `yarn test <./path>` | 57 | | `yarn build` | Build production bundle (compressed cache busting asset files) | 58 | | `yarn ci` | Remove report dir, format code, static type check, lint src/test files, run tests, run code coverage and generate result files for CI | 59 | | `yarn reinstall` | Remove `node_module` and install modules listed in `package.json` | 60 | | `yarn start` | Start Node.js Express server with Hot Module Replacement | 61 | | `yarn stats` | Create `stats.json` that be loaded to http://webpack.github.io/analyse/ to visualize build | 62 | | `yarn flow` | Run Flow static type check | 63 | | `yarn flow:restart` | Restart Flow server before running static type check | 64 | | `yarn prettier` | Format code | 65 | 66 | ## Dependencies 67 | 68 | | Dependency | Description | 69 | | ---------------------- | ------------------------------------------------------------ | 70 | | @material-ui/core | UI - Google's material design UI components built with React | 71 | | @material-ui/icons | UI - Google Material icons | 72 | | @material-ui/styles | UI - Style hooks | 73 | | classnames | UI - Conditionally joining classNames together | 74 | | connected-react-router | React - Router with Redux integration | 75 | | date-fns | Parse, validate, manipulate and display dates | 76 | | history | Managing browser history | 77 | | immer | Handling immutable objects | 78 | | prop-types | React - Runtime type checking for React props | 79 | | react | React - Core | 80 | | react-dom | React - DOM | 81 | | react-redux | React - Redux integration | 82 | | react-router-dom | React - Router | 83 | | recompose | React - Useful utility function components and HOCs. | 84 | | redux | Redux - Core | 85 | | redux-observable | Redux - Side Effects middleware using RxJS' Observables | 86 | | reselect | Memoized selector for React components | 87 | | rxjs | Handling async actions | 88 | | typeface-roboto | UI - Roboto font, adhering to Google Material Design spec | 89 | 90 | ## Dev Dependencies 91 | 92 | | Dependency | Description | 93 | | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 94 | | @babel/cli | Babel - CLI commands | 95 | | @babel/core | Babel - Core compiler | 96 | | @babel/plugin-proposal-class-properties | Babel - https://babeljs.io/docs/en/babel-plugin-proposal-class-properties | 97 | | @babel/plugin-proposal-export-default-from | Babel - https://babeljs.io/docs/en/babel-plugin-proposal-export-default-from | 98 | | @babel/polyfill | Babel - Emulate a full ES2015+ environment | 99 | | @babel/preset-env | Babel - To use latest JavaScript | 100 | | @babel/preset-flow | Babel - Flow preset | 101 | | @babel/preset-react | Babel - React preset | 102 | | @babel/register | Babel - Provide `require` hook | 103 | | autoprefixer | Webpack - Add vendor prefixes in CSS | 104 | | babel-eslint | Babel - For linting ES7 syntax... ex: `static` properties | 105 | | babel-loader | Babel - Loader for transpiling | 106 | | babel-plugin-istanbul | Babel - Istanbul instrumentation to ES6 code. Used in conjunction with `nyc`. | 107 | | chai | Test - Expect lib | 108 | | chai-as-promised | Test - Fluent approach to test promises | 109 | | clean-webpack-plugin | Webpack - Clean output dir between builds | 110 | | compression-webpack-plugin | Webpack - Generate GZip asset files | 111 | | css-loader | Webpack - CSS loader | 112 | | enzyme | Test - Testing utilities for React | 113 | | enzyme-adapter-react-16 | Test - Enzyme adapter that targets React 16 | 114 | | eslint | ESLint - For enforcing coding style | 115 | | eslint-config-airbnb | ESLint - Using Airbnb's coding style | 116 | | eslint-config-prettier | Prettier - Turns off unnecessary ESLint rules or might conflict with Prettier | 117 | | eslint-loader | Webpack - ESLint loader | 118 | | eslint-plugin-flowtype | ESLint - Flow type linting | 119 | | eslint-plugin-import | ESLint - Linting of ES2015+ (ES6+) import/export syntax | 120 | | eslint-plugin-jsx-a11y | ESLint - Static AST checker for accessibility rules on JSX elements | 121 | | eslint-plugin-prettier | ESLint - Runs Prettier as an ESLint rule | 122 | | eslint-plugin-react | ESLint - React specific linting rules | 123 | | file-loader | Webpack - File loader | 124 | | flow-bin | Flow - Static type checker for JavaScript | 125 | | html-webpack-plugin | Webpack - Generates `index.html` using hash filenames for cache busting | 126 | | husky | Git - Provides Git hooks to run Prettier | 127 | | image-webpack-loader | Webpack - Image loader and handling compression | 128 | | jsdom | Test - A JavaScript implementation of the WHATWG DOM and HTML standards | 129 | | lint-staged | Git - Run Prettier on staged files | 130 | | mini-css-extract-plugin | Webpack - Extract CSS into separate files | 131 | | mocha | Test - JS test framework | 132 | | mocha-junit-reporter | Test - Creating JUnit result file for Jenkins | 133 | | nock | Test - HTTP mocking and expectations library | 134 | | nodemon | Test - Monitor test files and rerun tests. Needed due to cross-platform test runner because `mocha --watch` doesn't produce run results when executed from `require('child_process').exec` | 135 | | nyc | Test - Istanbul CLI for code coverage | 136 | | postcss-loader | Webpack - Post CSS loader to run autoprefixer | 137 | | prettier | Prettier - Opinionated code formatter | 138 | | react-test-renderer | Test - Works in conjunction with Enzyme | 139 | | rimraf | Util - `rm -rf` for both Unix and Windows world | 140 | | sinon | Test - Standalone test spies, stubs and mocks | 141 | | url-loader | Webpack - URL loader | 142 | | webpack | Webpack - Core | 143 | | webpack-cli | Webpack - CLI | 144 | | webpack-dev-server | Webpack - Node.js Express server | 145 | 146 | ## Project Structure 147 | 148 | ``` 149 | . 150 | ├── dist -> Distribution dir - Production bundle, including index.html 151 | │   └── ... 152 | ├── node_modules -> Installed modules dir 153 | │   └── ... 154 | ├── reports -> Reports dir - Generated reports for Jenkins 155 | │   └── ... 156 | ├── scripts -> Scripts dir - Cross-platform NPM scripts 157 | │   └── ... 158 | ├── src -> Dir for actual source files and test files 159 | │   └── ... 160 | ├── .babelrc -> Babel configuration 161 | ├── .editorconfig -> Coding style for different editors 162 | ├── .eslintignore -> ESLint ignore list 163 | ├── .eslintrc -> ESLint configuration 164 | ├── .flowconfig -> Flow configuration 165 | ├── .gitattributes -> Custom Git configuration 166 | ├── .gitignore -> Git ignore list 167 | ├── .huskyrc -> Husky configuration 168 | ├── .lintstagedrc -> Lint staged configuration 169 | ├── .nycrc -> Istanbul CLI configuration 170 | ├── .prettierignore -> Prettier ignore list 171 | ├── .prettierrc -> Prettier configuration 172 | ├── CHANGELOG.md -> Change logs 173 | ├── LICENSE.md -> License, if needed 174 | ├── package.json -> NPM scripts and dependencies 175 | ├── postcss.config.js -> To fix "No PostCSS Config found" error 176 | ├── README.md -> Readme file for the app 177 | ├── stats.json -> Generated file when running `yarn stats` 178 | ├── webpack.base.config.js -> Common Webpack config 179 | ├── webpack.config.js -> Production Webpack config 180 | ├── webpack.dev.config.js -> Development Webpack config 181 | └── yarn.lock -> Dependency versions lock file used by Yarn 182 | ``` 183 | 184 | ## Troubleshooting 185 | 186 | ### Error: dyld: Library not loaded 187 | 188 | When running `yarn start`, you get this error... 189 | 190 | ``` 191 | Module build failed: Error: dyld: Library not loaded: /usr/local/opt/libpng/lib/libpng16.16.dylib 192 | Referenced from: /path/to/front-end-stack/node_modules/mozjpeg/vendor/cjpeg 193 | Reason: image not found 194 | ``` 195 | 196 | To fix it, run `brew install libpng` ... [see here for more info](https://github.com/tcoopman/image-webpack-loader/issues/51) 197 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "front-end-stack", 3 | "version": "0.17.1", 4 | "private": true, 5 | "sideEffects": false, 6 | "config": { 7 | "context_root": "/", 8 | "dist_uri": "/assets/", 9 | "src_dir_path": "src/", 10 | "test_dir_path": "src/**/*-spec.js", 11 | "dist_dir_path": "dist/assets/", 12 | "entry_file_path": "dist/index.html", 13 | "report_dir_path": "reports/" 14 | }, 15 | "scripts": { 16 | "test": "node ./scripts/test", 17 | "test:watch": "nodemon --exec 'yarn test'", 18 | "build": "webpack", 19 | "ci": "node ./scripts/ci", 20 | "reinstall": "rimraf node_modules/ && yarn --network-timeout 1000000", 21 | "start": "webpack-dev-server --config webpack.dev.config.js", 22 | "stats": "webpack --profile --json > stats.json", 23 | "flow": "flow --color always", 24 | "flow:restart": "flow stop && yarn flow", 25 | "prettier": "prettier --write '**/*.{js,json,md}'" 26 | }, 27 | "dependencies": { 28 | "@material-ui/core": "4.11.0", 29 | "@material-ui/icons": "4.9.1", 30 | "@material-ui/styles": "4.10.0", 31 | "classnames": "2.2.6", 32 | "connected-react-router": "6.8.0", 33 | "date-fns": "2.16.1", 34 | "history": "4.10.1", 35 | "immer": "7.0.8", 36 | "prop-types": "15.7.2", 37 | "react": "16.13.1", 38 | "react-dom": "16.13.1", 39 | "react-redux": "7.2.1", 40 | "react-router-dom": "5.2.0", 41 | "recompose": "0.30.0", 42 | "redux": "4.0.5", 43 | "redux-observable": "1.2.0", 44 | "reselect": "4.0.0", 45 | "rxjs": "6.6.3", 46 | "typeface-roboto": "0.0.75" 47 | }, 48 | "devDependencies": { 49 | "@babel/cli": "7.11.6", 50 | "@babel/core": "7.11.6", 51 | "@babel/plugin-proposal-class-properties": "7.10.4", 52 | "@babel/plugin-proposal-export-default-from": "7.10.4", 53 | "@babel/polyfill": "7.11.5", 54 | "@babel/preset-env": "7.11.5", 55 | "@babel/preset-flow": "7.10.4", 56 | "@babel/preset-react": "7.10.4", 57 | "@babel/register": "7.11.5", 58 | "autoprefixer": "9.8.6", 59 | "babel-eslint": "10.1.0", 60 | "babel-loader": "8.1.0", 61 | "babel-plugin-istanbul": "6.0.0", 62 | "chai": "4.2.0", 63 | "chai-as-promised": "7.1.1", 64 | "clean-webpack-plugin": "3.0.0", 65 | "compression-webpack-plugin": "5.0.2", 66 | "css-loader": "4.2.2", 67 | "enzyme": "3.11.0", 68 | "enzyme-adapter-react-16": "1.15.4", 69 | "eslint": "7.8.1", 70 | "eslint-config-airbnb": "18.2.0", 71 | "eslint-config-prettier": "6.11.0", 72 | "eslint-loader": "4.0.2", 73 | "eslint-plugin-flowtype": "5.2.0", 74 | "eslint-plugin-import": "2.22.0", 75 | "eslint-plugin-jsx-a11y": "6.3.1", 76 | "eslint-plugin-prettier": "3.1.4", 77 | "eslint-plugin-react": "7.20.6", 78 | "file-loader": "6.1.0", 79 | "flow-bin": "0.133.0", 80 | "html-webpack-plugin": "4.4.1", 81 | "husky": "4.3.0", 82 | "image-webpack-loader": "6.0.0", 83 | "jsdom": "16.4.0", 84 | "lint-staged": "10.3.0", 85 | "mini-css-extract-plugin": "0.11.0", 86 | "mocha": "8.1.3", 87 | "mocha-junit-reporter": "2.0.0", 88 | "nock": "13.0.4", 89 | "nodemon": "2.0.4", 90 | "nyc": "15.1.0", 91 | "postcss-loader": "4.0.0", 92 | "prettier": "2.1.1", 93 | "react-test-renderer": "16.13.1", 94 | "rimraf": "3.0.2", 95 | "sinon": "9.0.3", 96 | "url-loader": "4.1.0", 97 | "webpack": "4.44.1", 98 | "webpack-cli": "3.3.12", 99 | "webpack-dev-server": "3.11.0" 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | module.exports = { 4 | plugins: [require('autoprefixer')], 5 | }; 6 | -------------------------------------------------------------------------------- /scripts/ci.js: -------------------------------------------------------------------------------- 1 | // noinspection NpmUsedModulesInstalled 2 | /** 3 | * `yarn run ci` 4 | * 5 | * Cross-platform script to generate test result file and coverage result file for CI. 6 | */ 7 | const process = require('process'); 8 | const path = require('path'); 9 | const script = require('./script'); 10 | 11 | const mochaOpts = script.mochaOpts; 12 | const reportDirPath = process.env.npm_package_config_report_dir_path; 13 | const testDirPath = process.env.npm_package_config_test_dir_path; 14 | const distDirPath = process.env.npm_package_config_dist_dir_path; 15 | const srcDirPath = process.env.npm_package_config_src_dir_path; 16 | const mochaFilePath = path.join(reportDirPath, 'test-results.xml'); 17 | 18 | process.env.NODE_ENV = 'test'; 19 | 20 | const prettier = 'yarn prettier'; 21 | const flow = 'yarn flow'; 22 | const eslint = `eslint ${srcDirPath} ${testDirPath} --fix --color`; 23 | 24 | // In addition to the `exclude` patterns defined in package.json, add user defined paths. 25 | // Most importantly, `distDirPath` has to be excluded because it contains large bundled JS files 26 | // and it causes "JavaScript heap out of memory" error. 27 | const nycExtraExcludes = [ 28 | distDirPath, 29 | reportDirPath, 30 | '**/__tests__/', 31 | 'node/', 32 | 'node_modules/', 33 | 'scripts/', 34 | ] 35 | .map(pattern => `--exclude=${pattern}`) 36 | .join(' '); 37 | 38 | const nyc = `nyc ${nycExtraExcludes} --include=${srcDirPath} --report-dir=${reportDirPath}`; 39 | const mocha = `node_modules/mocha/bin/_mocha ${testDirPath} ${mochaOpts} --reporter mocha-junit-reporter --reporter-options mochaFile=${mochaFilePath} --colors`; 40 | 41 | const removeReportDir = `rimraf ${reportDirPath}`; 42 | const removeNycOutputDir = 'rimraf .nyc_output/'; 43 | 44 | script.run( 45 | `${removeReportDir} && ${prettier} && ${flow} && ${eslint} && ${nyc} ${mocha} && ${removeNycOutputDir}`, 46 | [ 47 | `Rimraf : Remove ${reportDirPath}`, 48 | 'Prettier : Format code', 49 | 'Flow : Static type check', 50 | 'ESLint : Code linting', 51 | 'Mocha : Run tests and create JUnit report file', 52 | 'Istanbul : Run code coverage and create HTML and Cobertura report files', 53 | 'Rimraf : Remove .nyc_output/ generated by Istanbul', 54 | ], 55 | ); 56 | -------------------------------------------------------------------------------- /scripts/script.js: -------------------------------------------------------------------------------- 1 | // noinspection NpmUsedModulesInstalled 2 | /** 3 | * Script runner. 4 | */ 5 | const process = require('process'); 6 | const console = require('console'); 7 | const path = require('path'); 8 | const exec = require('child_process').exec; 9 | 10 | const run = (command, comments) => { 11 | if (comments) { 12 | console.log('Performing the following action(s):-'); 13 | comments.forEach(comment => console.log(`- ${comment}`)); 14 | console.log(); 15 | } 16 | 17 | console.log(`Executing: ${command}`); 18 | 19 | exec(command, (err, stdout, stderr) => { 20 | // display `stdout` first, if exist. Some plugins pipe the error messages here. 21 | if (stdout) { 22 | console.log(stdout); 23 | } 24 | 25 | // throw `stderr` instead of `err`, if exist, to reduce log clutters 26 | if (err) { 27 | throw stderr; 28 | } 29 | 30 | console.log('OK'); 31 | }); 32 | }; 33 | 34 | const srcDirPath = process.env.npm_package_config_src_dir_path; 35 | const testBootstrap = path.join(srcDirPath, 'js', '__tests__', 'index.js'); 36 | 37 | module.exports = { 38 | run, 39 | mochaOpts: `--recursive --require @babel/register --require ${testBootstrap}`, 40 | }; 41 | -------------------------------------------------------------------------------- /scripts/test.js: -------------------------------------------------------------------------------- 1 | // noinspection NpmUsedModulesInstalled 2 | /** 3 | * `yarn test` - to run all tests. 4 | * `yarn test ./test/submodule` - to run all tests within `./test/submodule`. 5 | * 6 | * Cross-platform script to lint src/test files and execute Mocha tests. 7 | */ 8 | const process = require('process'); 9 | const script = require('./script'); 10 | 11 | const mochaOpts = script.mochaOpts; 12 | 13 | // remove first 2 arguments... first arg is usually the path to nodejs, and 14 | // the second arg is script location 15 | const args = process.argv.slice(2); 16 | 17 | // if user passes argument, set first argument as `testDirPath`, otherwise use default value from 18 | // package.json 19 | const testDirPath = args.length ? args[0] : process.env.npm_package_config_test_dir_path; 20 | 21 | const srcDirPath = process.env.npm_package_config_src_dir_path; 22 | 23 | const prettier = 'yarn prettier'; 24 | const flow = 'yarn flow'; 25 | const eslint = `eslint ${srcDirPath} ${testDirPath} --fix --color`; 26 | const mocha = `mocha ${testDirPath} ${mochaOpts} --colors`; 27 | 28 | process.env.NODE_ENV = 'test'; 29 | 30 | script.run(`${prettier} && ${flow} && ${eslint} && ${mocha}`, [ 31 | 'Prettier : Format code', 32 | 'Flow : Static type check', 33 | 'ESLint : Code linting', 34 | 'Mocha : Run tests', 35 | ]); 36 | -------------------------------------------------------------------------------- /src/flow/general-stub.js.flow: -------------------------------------------------------------------------------- 1 | // General stub to handle "Required module not found" error with Flow 2 | // See https://github.com/reactjs/react-redux/issues/137 3 | // noinspection JSUnusedGlobalSymbols 4 | export default {}; 5 | -------------------------------------------------------------------------------- /src/html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /src/img/chuck-norris.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/choonchernlim/front-end-stack/e882f3c08de66da01cce8e5c3fedc384a696107f/src/img/chuck-norris.jpg -------------------------------------------------------------------------------- /src/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/choonchernlim/front-end-stack/e882f3c08de66da01cce8e5c3fedc384a696107f/src/img/favicon.png -------------------------------------------------------------------------------- /src/img/user.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/choonchernlim/front-end-stack/e882f3c08de66da01cce8e5c3fedc384a696107f/src/img/user.jpg -------------------------------------------------------------------------------- /src/js/__tests__/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /** 3 | * Global setup before running any specs. 4 | * 5 | * IMPORTANT: Don't pollute `global.*` to prevent any side effects when running tests! 6 | */ 7 | import '@babel/polyfill'; 8 | import { configure } from 'enzyme'; 9 | import Adapter from 'enzyme-adapter-react-16'; 10 | 11 | // To prevent the following error when using Enzyme:- 12 | // 13 | // Enzyme Internal Error: Enzyme expects an adapter to be configured, but found none. To 14 | // configure an adapter, you should call `Enzyme.configure({ adapter: new Adapter() })` 15 | // before using any of Enzyme's top level APIs, where `Adapter` is the adapter 16 | // corresponding to the library currently being tested. For example: 17 | configure({ adapter: new Adapter() }); 18 | -------------------------------------------------------------------------------- /src/js/app/actions/actions.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const ACTIONS = Object.freeze({ 4 | GET_JOKE: 'CHUCK-NORRIS/GET-JOKE', 5 | GET_JOKE_FAILED: 'CHUCK-NORRIS/GET-JOKE-FAILED', 6 | GET_JOKE_SUCCEEDED: 'CHUCK-NORRIS/GET-JOKE-SUCCEEDED', 7 | 8 | MENU_LEFT_OPENED: 'LAYOUT/MENU-LEFT-OPENED', 9 | TOGGLE_MENU: 'LAYOUT/TOGGLE-MENU', 10 | 11 | ADD_TODO: 'TODO-MANAGER/ADD-TODO', 12 | SET_VISIBILITY_FILTER: 'TODO-MANAGER/SET-VISIBILITY-FILTER', 13 | TOGGLE_TODO: 'TODO-MANAGER/TOGGLE-TODO', 14 | }); 15 | 16 | export default ACTIONS; 17 | -------------------------------------------------------------------------------- /src/js/app/actions/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import type { 4 | AddTodoFn, 5 | GetJokeFailedFn, 6 | GetJokeFn, 7 | GetJokeSucceededFn, 8 | MenuLeftOpenedFn, 9 | SetVisibilityFilterFn, 10 | ToggleMenuFn, 11 | ToggleTodoFn, 12 | } from './types'; 13 | import ACTIONS from './actions'; 14 | 15 | let nextTodoId: number = 0; 16 | 17 | const getJoke: GetJokeFn = () => ({ 18 | type: ACTIONS.GET_JOKE, 19 | state: { 20 | completed: false, 21 | joke: null, 22 | error: null, 23 | }, 24 | }); 25 | 26 | const getJokeFailed: GetJokeFailedFn = (error) => ({ 27 | type: ACTIONS.GET_JOKE_FAILED, 28 | state: { 29 | completed: true, 30 | joke: null, 31 | error, 32 | }, 33 | }); 34 | 35 | const getJokeSucceeded: GetJokeSucceededFn = (joke) => ({ 36 | type: ACTIONS.GET_JOKE_SUCCEEDED, 37 | state: { 38 | completed: true, 39 | joke, 40 | error: null, 41 | }, 42 | }); 43 | 44 | const menuLeftOpened: MenuLeftOpenedFn = (open) => ({ 45 | type: ACTIONS.MENU_LEFT_OPENED, 46 | shouldMenuLeftOpened: open, 47 | isMenuCurrentlyOpened: open, 48 | }); 49 | 50 | const toggleMenu: ToggleMenuFn = () => ({ 51 | type: ACTIONS.TOGGLE_MENU, 52 | }); 53 | 54 | const addTodo: AddTodoFn = (text) => { 55 | nextTodoId += 1; 56 | return { 57 | type: ACTIONS.ADD_TODO, 58 | id: nextTodoId, 59 | text, 60 | }; 61 | }; 62 | 63 | const setVisibilityFilter: SetVisibilityFilterFn = (filter) => ({ 64 | type: ACTIONS.SET_VISIBILITY_FILTER, 65 | filter, 66 | }); 67 | 68 | const toggleTodo: ToggleTodoFn = (id) => ({ type: ACTIONS.TOGGLE_TODO, id }); 69 | 70 | const actions = { 71 | getJoke, 72 | getJokeFailed, 73 | getJokeSucceeded, 74 | 75 | menuLeftOpened, 76 | toggleMenu, 77 | 78 | addTodo, 79 | setVisibilityFilter, 80 | toggleTodo, 81 | }; 82 | 83 | export { ACTIONS }; 84 | 85 | export default actions; 86 | -------------------------------------------------------------------------------- /src/js/app/actions/types.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import ACTIONS from './actions'; 3 | 4 | type ActionType = $Values; 5 | 6 | // This cannot be an exact type 7 | export type AnyAction = $ReadOnly<{ 8 | type: ActionType, 9 | }>; 10 | 11 | export type GetJokeAction = $ReadOnly<{| 12 | type: ActionType, 13 | state: { 14 | completed: boolean, 15 | joke: ?string, 16 | error: ?string, 17 | }, 18 | |}>; 19 | 20 | export type MenuLeftOpenedAction = $ReadOnly<{| 21 | type: ActionType, 22 | shouldMenuLeftOpened: boolean, 23 | isMenuCurrentlyOpened: boolean, 24 | |}>; 25 | 26 | export type ToggleMenuAction = $ReadOnly<{| 27 | type: ActionType, 28 | |}>; 29 | 30 | export type AddTodoAction = $ReadOnly<{| 31 | type: ActionType, 32 | id: number, 33 | text: string, 34 | |}>; 35 | 36 | export type ToggleTodoAction = $ReadOnly<{| 37 | type: ActionType, 38 | id: number, 39 | |}>; 40 | 41 | export type SetVisibilityFilterAction = $ReadOnly<{| 42 | type: ActionType, 43 | filter: string, 44 | |}>; 45 | 46 | export type GetJokeFn = () => GetJokeAction; 47 | export type GetJokeFailedFn = (error: string) => GetJokeAction; 48 | export type GetJokeSucceededFn = (joke: string) => GetJokeAction; 49 | 50 | export type MenuLeftOpenedFn = (open: boolean) => MenuLeftOpenedAction; 51 | export type ToggleMenuFn = () => ToggleMenuAction; 52 | 53 | export type AddTodoFn = (text: string) => AddTodoAction; 54 | export type SetVisibilityFilterFn = (filter: string) => SetVisibilityFilterAction; 55 | export type ToggleTodoFn = (id: number) => ToggleTodoAction; 56 | -------------------------------------------------------------------------------- /src/js/app/apis/__tests__/getJokeApi-spec.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { describe, it, afterEach, beforeEach } from 'mocha'; 3 | import nock from 'nock'; 4 | import { expect } from 'chai'; 5 | import { JSDOM } from 'jsdom'; 6 | import getJokeApi, { RANDOM_JOKE_SERVER, RANDOM_JOKE_URI } from '../getJokeApi'; 7 | 8 | const { window } = new JSDOM('', { url: RANDOM_JOKE_SERVER }); 9 | 10 | describe('Chuck Norris', () => { 11 | describe('APIs', () => { 12 | describe('getJoke', () => { 13 | beforeEach(() => { 14 | global.window = window; 15 | }); 16 | 17 | afterEach(() => { 18 | global.window = undefined; 19 | nock.cleanAll(); 20 | }); 21 | 22 | it('given successful call, should return value', done => { 23 | nock(RANDOM_JOKE_SERVER) 24 | .get(RANDOM_JOKE_URI) 25 | .reply(200, { 26 | value: { 27 | joke: '<YAY>', 28 | }, 29 | }); 30 | 31 | getJokeApi().subscribe( 32 | actualValue => { 33 | expect(actualValue).to.deep.equal(''); 34 | done(); 35 | }, 36 | error => { 37 | expect(error).to.be.an('undefined'); 38 | done(); 39 | }, 40 | ); 41 | }); 42 | 43 | it('given failed call, should not return value', done => { 44 | nock(RANDOM_JOKE_SERVER) 45 | .get(RANDOM_JOKE_URI) 46 | .reply(400); 47 | 48 | getJokeApi().subscribe( 49 | actualValue => { 50 | expect.fail(actualValue, undefined, 'Should not have value'); 51 | done(); 52 | }, 53 | error => { 54 | expect(error).to.not.be.an('undefined'); 55 | done(); 56 | }, 57 | ); 58 | }); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/js/app/apis/getJokeApi.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { ajax, type AjaxResponse } from 'rxjs/ajax'; 3 | import { map } from 'rxjs/operators'; 4 | import type { GetJokeApiFn } from './types'; 5 | 6 | export const RANDOM_JOKE_SERVER: string = 'https://api.icndb.com'; 7 | export const RANDOM_JOKE_URI: string = '/jokes/random'; 8 | 9 | // One way to decode HTML 10 | // See http://stackoverflow.com/questions/7394748/whats-the-right-way-to-decode-a-string-that-has-special-html-entities-in-it 11 | const decodeHtml = (html: string): string => { 12 | const element: HTMLTextAreaElement = window.document.createElement('textarea'); 13 | element.innerHTML = html; 14 | return element.value; 15 | }; 16 | 17 | const getJokeApi: GetJokeApiFn = () => 18 | ajax({ 19 | url: RANDOM_JOKE_SERVER + RANDOM_JOKE_URI, 20 | crossDomain: true, 21 | createXHR: () => new window.XMLHttpRequest(), 22 | }).pipe(map((e: AjaxResponse) => decodeHtml(e.response.value.joke))); 23 | 24 | export default getJokeApi; 25 | -------------------------------------------------------------------------------- /src/js/app/apis/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import getJokeApi from './getJokeApi'; 3 | import type { Apis } from './types'; 4 | 5 | const apis: Apis = { 6 | getJokeApi, 7 | }; 8 | 9 | export default apis; 10 | -------------------------------------------------------------------------------- /src/js/app/apis/types.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { Observable } from 'rxjs'; 4 | 5 | export type GetJokeApiFn = () => Observable; 6 | 7 | export type Apis = { getJokeApi: GetJokeApiFn }; 8 | -------------------------------------------------------------------------------- /src/js/app/epics/__tests__/getJokeEpic-spec.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { describe, it } from 'mocha'; 3 | import { ActionsObservable } from 'redux-observable'; 4 | import { of, throwError } from 'rxjs'; 5 | import { toArray } from 'rxjs/operators'; 6 | import { expect } from 'chai'; 7 | import actions from '../../actions'; 8 | import getJokeEpic from '../getJokeEpic'; 9 | 10 | describe('Chuck Norris', () => { 11 | describe('Epics', () => { 12 | describe('getJoke', () => { 13 | it('given successful call, should return joke succeeded action', () => { 14 | const action$ = ActionsObservable.of(actions.getJoke()); 15 | const apis = { 16 | getJokeApi: () => of('test'), 17 | }; 18 | 19 | getJokeEpic(action$, null, apis) 20 | .pipe(toArray()) 21 | .subscribe(actualActions => 22 | expect(actualActions).to.deep.equal([actions.getJokeSucceeded('test')]), 23 | ); 24 | }); 25 | 26 | it('given failed call, should return joke failed action', () => { 27 | const action$ = ActionsObservable.of(actions.getJoke()); 28 | const apis = { 29 | getJokeApi: () => 30 | throwError({ 31 | message: 'test', 32 | }), 33 | }; 34 | 35 | getJokeEpic(action$, null, apis) 36 | .pipe(toArray()) 37 | .subscribe(actualActions => 38 | expect(actualActions).to.deep.equal([actions.getJokeFailed('test')]), 39 | ); 40 | }); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/js/app/epics/getJokeEpic.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { Observable, of } from 'rxjs'; 3 | import type { AjaxError } from 'rxjs/ajax'; 4 | import { catchError, map, mergeMap } from 'rxjs/operators'; 5 | import { ofType } from 'redux-observable'; 6 | import actions, { ACTIONS } from '../actions'; 7 | import type { Apis } from '../apis/types'; 8 | 9 | type GetJokeEpicFn = (action$: Observable, store: *, apis: Apis) => Observable; 10 | 11 | const getJokeEpic: GetJokeEpicFn = (action$, store, { getJokeApi }) => 12 | action$.pipe( 13 | ofType(ACTIONS.GET_JOKE), 14 | mergeMap(() => 15 | getJokeApi().pipe( 16 | map((value: string) => actions.getJokeSucceeded(value)), 17 | catchError((error: AjaxError) => of(actions.getJokeFailed(error.message))), 18 | ), 19 | ), 20 | ); 21 | 22 | export default getJokeEpic; 23 | -------------------------------------------------------------------------------- /src/js/app/epics/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { combineEpics } from 'redux-observable'; 3 | import getJokeEpic from './getJokeEpic'; 4 | import apis from '../apis'; 5 | 6 | const epics: Array = [getJokeEpic]; 7 | 8 | // Inject apis into epics to make it easier to test. 9 | // 10 | // See https://github.com/redux-observable/redux-observable/issues/194 11 | // See https://medium.com/kevin-salters-blog/writing-epic-unit-tests-bd85f05685b 12 | const rootEpic = (...args: Array<*>) => combineEpics(...epics)(...args, { ...apis }); 13 | 14 | export default rootEpic; 15 | -------------------------------------------------------------------------------- /src/js/app/reducers/__tests__/chuckNorrisReducer-spec.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { describe, it } from 'mocha'; 3 | import { expect } from 'chai'; 4 | import chuckNorrisReducer, { initialState } from '../chuckNorrisReducer'; 5 | import actions, { ACTIONS } from '../../actions'; 6 | 7 | describe('Chuck Norris', () => { 8 | describe('Reducer', () => { 9 | describe('Default', () => { 10 | it('given unknown action, should return initial state', () => { 11 | expect(chuckNorrisReducer(undefined, { type: ACTIONS.MENU_LEFT_OPENED })).to.equal( 12 | initialState, 13 | ); 14 | }); 15 | }); 16 | 17 | describe('GET_JOKE', () => { 18 | it('when getting joke, should set completed to false', () => { 19 | const currentState = { 20 | ...initialState, 21 | joke: 'joke', 22 | }; 23 | 24 | const actualState = chuckNorrisReducer(currentState, actions.getJoke()); 25 | 26 | const expectedState = { 27 | ...initialState, 28 | completed: false, 29 | }; 30 | 31 | expect(actualState).to.deep.equal(expectedState); 32 | }); 33 | }); 34 | 35 | describe('GET_JOKE_SUCCEEDED', () => { 36 | it('when invoked, should return new joke', () => { 37 | const currentState = { 38 | ...initialState, 39 | completed: false, 40 | joke: 'joke', 41 | }; 42 | 43 | const actualState = chuckNorrisReducer(currentState, actions.getJokeSucceeded('new joke')); 44 | 45 | const expectedState = { 46 | ...initialState, 47 | joke: 'new joke', 48 | }; 49 | 50 | expect(actualState).to.deep.equal(expectedState); 51 | }); 52 | }); 53 | 54 | describe('GET_JOKE_FAILED', () => { 55 | it('when invoked, should return error', () => { 56 | const currentState = { 57 | ...initialState, 58 | completed: false, 59 | joke: 'joke', 60 | }; 61 | 62 | const actualState = chuckNorrisReducer(currentState, actions.getJokeFailed('error')); 63 | 64 | const expectedState = { 65 | ...initialState, 66 | error: 'error', 67 | }; 68 | 69 | expect(actualState).to.deep.equal(expectedState); 70 | }); 71 | }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/js/app/reducers/__tests__/createReducer-spec.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { describe, it } from 'mocha'; 3 | import { expect } from 'chai'; 4 | import createReducer from '../createReducer'; 5 | import { ACTIONS } from '../../actions'; 6 | 7 | describe('Common', () => { 8 | describe('Utils', () => { 9 | describe('Create Reducer', () => { 10 | it('given no handlers, should return existing state', () => { 11 | const existingState = []; 12 | const handlers = {}; 13 | const reducer = createReducer(existingState, handlers); 14 | 15 | const actualState = reducer(existingState, { type: ACTIONS.GET_JOKE }); 16 | 17 | expect(actualState).to.be.deep.equal(existingState); 18 | }); 19 | 20 | it('given handlers with no matching action, should return existing state', () => { 21 | const existingState = []; 22 | const handlers = { 23 | 'DIFFERENT-ACTION': () => ['1'], 24 | }; 25 | 26 | const reducer = createReducer(existingState, handlers); 27 | 28 | const actualState = reducer(existingState, { type: ACTIONS.GET_JOKE }); 29 | 30 | expect(actualState).to.be.deep.equal(existingState); 31 | }); 32 | 33 | it('given handlers with matching action, should return new state', () => { 34 | const existingState = []; 35 | const expectedState = ['1']; 36 | const handlers = { 37 | [ACTIONS.GET_JOKE]: () => expectedState, 38 | }; 39 | 40 | const reducer = createReducer(existingState, handlers); 41 | 42 | const actualState = reducer(existingState, { type: ACTIONS.GET_JOKE }); 43 | 44 | expect(actualState).to.be.deep.equal(expectedState); 45 | }); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/js/app/reducers/__tests__/todoManagerReducer-spec.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { describe, it } from 'mocha'; 3 | import { expect } from 'chai'; 4 | import todoManagerReducer, { initialState } from '../todoManagerReducer'; 5 | import actions, { ACTIONS } from '../../actions'; 6 | 7 | describe('Todo Manager', () => { 8 | describe('Reducer', () => { 9 | describe('Default', () => { 10 | it('given unknown action, should return initial state', () => { 11 | expect(todoManagerReducer(undefined, { type: ACTIONS.GET_JOKE })).to.equal(initialState); 12 | }); 13 | }); 14 | 15 | describe('ADD_TODO', () => { 16 | it('when adding todo, should return new todo', () => { 17 | const currentState = initialState; 18 | 19 | const action = actions.addTodo('item 1'); 20 | const actualState = todoManagerReducer(currentState, action); 21 | 22 | const expectedState = { 23 | ...initialState, 24 | todos: [ 25 | { 26 | id: action.id, 27 | text: 'item 1', 28 | completed: false, 29 | }, 30 | ], 31 | }; 32 | 33 | expect(actualState).to.deep.equal(expectedState); 34 | }); 35 | }); 36 | 37 | describe('TOGGLE_TODO', () => { 38 | it('when toggling incomplete todo, should return completed flag', () => { 39 | const currentState = { 40 | ...initialState, 41 | todos: [ 42 | { 43 | id: 1, 44 | text: 'item 1', 45 | completed: false, 46 | }, 47 | { 48 | id: 2, 49 | text: 'item 2', 50 | completed: false, 51 | }, 52 | ], 53 | }; 54 | 55 | const actualState = todoManagerReducer(currentState, actions.toggleTodo(1)); 56 | 57 | const expectedState = { 58 | ...initialState, 59 | todos: [ 60 | { 61 | id: 1, 62 | text: 'item 1', 63 | completed: true, 64 | }, 65 | { 66 | id: 2, 67 | text: 'item 2', 68 | completed: false, 69 | }, 70 | ], 71 | }; 72 | 73 | expect(actualState).to.deep.equal(expectedState); 74 | }); 75 | }); 76 | 77 | describe('SET_VISIBILITY_FILTER', () => { 78 | it('given a filter, should return action', () => { 79 | const actualState = todoManagerReducer(initialState, actions.setVisibilityFilter('ALL')); 80 | 81 | const expectedState = { 82 | ...initialState, 83 | visibilityFilter: 'ALL', 84 | }; 85 | 86 | expect(actualState).to.deep.equal(expectedState); 87 | }); 88 | }); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /src/js/app/reducers/chuckNorrisReducer.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import produce from 'immer'; 3 | import createReducer from './createReducer'; 4 | import { ACTIONS } from '../actions'; 5 | import type { GetJokeAction } from '../actions/types'; 6 | import type { ChuckNorrisState } from '../states/types'; 7 | 8 | /** 9 | * Types. 10 | */ 11 | type HandleActionFn = (state: ChuckNorrisState, action: GetJokeAction) => ChuckNorrisState; 12 | 13 | /** 14 | * Initial State. 15 | */ 16 | export const initialState: ChuckNorrisState = Object.freeze({ 17 | completed: true, 18 | joke: null, 19 | error: null, 20 | }); 21 | 22 | /** 23 | * Action handlers. 24 | */ 25 | const handleAction: HandleActionFn = (state, action) => 26 | produce(state, (draft) => { 27 | const { completed, joke, error } = action.state; 28 | 29 | draft.completed = completed; 30 | draft.joke = joke; 31 | draft.error = error; 32 | }); 33 | 34 | /** 35 | * Reducer. 36 | */ 37 | export default createReducer(initialState, { 38 | [ACTIONS.GET_JOKE]: handleAction, 39 | [ACTIONS.GET_JOKE_SUCCEEDED]: handleAction, 40 | [ACTIONS.GET_JOKE_FAILED]: handleAction, 41 | }); 42 | -------------------------------------------------------------------------------- /src/js/app/reducers/createReducer.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import type { AnyAction } from '../actions/types'; 3 | 4 | type State = *; 5 | type HandleActionFn = (state: State, action: AnyAction) => State; 6 | type CreateReducerFn = (initialState: State, handlers: Object) => HandleActionFn; 7 | 8 | /** 9 | * Creating reducer based on Redux recipe site in effort to create better flow-typed reducer. 10 | * 11 | * See http://redux.js.org/docs/recipes/ReducingBoilerplate.html 12 | * See https://robwise.github.io/blog/using-flow-annotations-in-your-redux-reducers 13 | * 14 | * @param initialState Initial state 15 | * @param handlers Action handler 16 | */ 17 | const createReducer: CreateReducerFn = (initialState, handlers) => (state = initialState, action) => 18 | Object.prototype.hasOwnProperty.call(handlers, action.type) 19 | ? handlers[action.type](state, action) 20 | : state; 21 | 22 | export default createReducer; 23 | -------------------------------------------------------------------------------- /src/js/app/reducers/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { combineReducers } from 'redux'; 3 | import { connectRouter } from 'connected-react-router'; 4 | import layout from './layoutReducer'; 5 | import todoManager from './todoManagerReducer'; 6 | import chuckNorris from './chuckNorrisReducer'; 7 | 8 | const reducers = (history: *) => 9 | combineReducers({ 10 | router: connectRouter(history), 11 | layout, 12 | todoManager, 13 | chuckNorris, 14 | }); 15 | 16 | export default reducers; 17 | -------------------------------------------------------------------------------- /src/js/app/reducers/layoutReducer.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import produce from 'immer'; 3 | import createReducer from './createReducer'; 4 | import { ACTIONS } from '../actions'; 5 | import type { MenuLeftOpenedAction } from '../actions/types'; 6 | import type { LayoutState } from '../states/types'; 7 | 8 | /** 9 | * Types. 10 | */ 11 | type MenuLeftOpenedFn = (state: LayoutState, action: MenuLeftOpenedAction) => LayoutState; 12 | type ToggleMenuFn = (state: LayoutState) => LayoutState; 13 | 14 | /** 15 | * Initial State. 16 | */ 17 | export const initialState: LayoutState = Object.freeze({ 18 | shouldMenuLeftOpened: false, 19 | isMenuCurrentlyOpened: false, 20 | }); 21 | 22 | /** 23 | * Action handlers. 24 | */ 25 | const menuLeftOpened: MenuLeftOpenedFn = (state, action) => 26 | produce(state, (draft) => { 27 | const { shouldMenuLeftOpened, isMenuCurrentlyOpened } = action; 28 | 29 | draft.shouldMenuLeftOpened = shouldMenuLeftOpened; 30 | draft.isMenuCurrentlyOpened = isMenuCurrentlyOpened; 31 | }); 32 | 33 | const toggleMenu: ToggleMenuFn = (state) => 34 | produce(state, (draft) => { 35 | draft.isMenuCurrentlyOpened = !draft.isMenuCurrentlyOpened; 36 | }); 37 | 38 | /** 39 | * Reducer. 40 | */ 41 | export default createReducer(initialState, { 42 | [ACTIONS.MENU_LEFT_OPENED]: menuLeftOpened, 43 | [ACTIONS.TOGGLE_MENU]: toggleMenu, 44 | }); 45 | -------------------------------------------------------------------------------- /src/js/app/reducers/todoManagerReducer.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import produce from 'immer'; 3 | import createReducer from './createReducer'; 4 | import { ACTIONS } from '../actions'; 5 | import type { AddTodoAction, SetVisibilityFilterAction, ToggleTodoAction } from '../actions/types'; 6 | import type { TodoManagerState } from '../states/types'; 7 | 8 | /** 9 | * Types. 10 | */ 11 | type AddTodoFn = (state: TodoManagerState, action: AddTodoAction) => TodoManagerState; 12 | type SetVisibilityFilterFn = ( 13 | state: TodoManagerState, 14 | action: SetVisibilityFilterAction, 15 | ) => TodoManagerState; 16 | type ToggleTodoFn = (state: TodoManagerState, action: ToggleTodoAction) => TodoManagerState; 17 | 18 | /** 19 | * Initial State. 20 | */ 21 | export const initialState: TodoManagerState = Object.freeze({ 22 | todos: [], 23 | visibilityFilter: 'SHOW_ALL', 24 | }); 25 | 26 | /** 27 | * Action handlers. 28 | */ 29 | const addTodo: AddTodoFn = (state, action) => 30 | produce(state, (draft) => { 31 | draft.todos.push({ 32 | id: action.id, 33 | text: action.text, 34 | completed: false, 35 | }); 36 | }); 37 | 38 | const toggleTodo: ToggleTodoFn = (state, action) => 39 | produce(state, (draft) => { 40 | const i = draft.todos.findIndex((todo) => todo.id === action.id); 41 | 42 | draft.todos[i].completed = !draft.todos[i].completed; 43 | }); 44 | 45 | const setVisibilityFilter: SetVisibilityFilterFn = (state, action) => 46 | produce(state, (draft) => { 47 | draft.visibilityFilter = action.filter; 48 | }); 49 | 50 | /** 51 | * Reducer. 52 | */ 53 | export default createReducer(initialState, { 54 | [ACTIONS.ADD_TODO]: addTodo, 55 | [ACTIONS.TOGGLE_TODO]: toggleTodo, 56 | [ACTIONS.SET_VISIBILITY_FILTER]: setVisibilityFilter, 57 | }); 58 | -------------------------------------------------------------------------------- /src/js/app/selectors/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | export { default as makeVisibleTodosSelector } from './makeVisibleTodosSelector'; 3 | 4 | // noinspection JSUnusedGlobalSymbols 5 | export default null; 6 | -------------------------------------------------------------------------------- /src/js/app/selectors/makeVisibleTodosSelector.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { createSelector } from 'reselect'; 3 | import states from '../states'; 4 | import type { VisibleTodosSelectorFn } from './types'; 5 | 6 | type MakeVisibleTodosSelectorFn = () => VisibleTodosSelectorFn; 7 | 8 | const makeVisibleTodosSelector: MakeVisibleTodosSelectorFn = () => 9 | createSelector(states.todoManager.visibilityFilter, states.todoManager.todos, (filter, todos) => { 10 | switch (filter) { 11 | case 'SHOW_ALL': 12 | return todos; 13 | case 'SHOW_COMPLETED': 14 | return todos.filter((t) => t.completed); 15 | case 'SHOW_ACTIVE': 16 | return todos.filter((t) => !t.completed); 17 | default: 18 | return todos; 19 | } 20 | }); 21 | 22 | export default makeVisibleTodosSelector; 23 | -------------------------------------------------------------------------------- /src/js/app/selectors/types.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import type { State, TodoState } from '../states/types'; 3 | 4 | export type VisibleTodosFn = (filter: string, todos: Array) => Array; 5 | export type VisibleTodosSelectorFn = (state: State) => VisibleTodosFn; 6 | -------------------------------------------------------------------------------- /src/js/app/states/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import type { ChuckNorrisState, State, TodoState } from './types'; 3 | 4 | type States = { 5 | layout: { 6 | shouldMenuLeftOpened: (state: State) => boolean, 7 | isMenuCurrentlyOpened: (state: State) => boolean, 8 | }, 9 | 10 | chuckNorris: (state: State) => ChuckNorrisState, 11 | 12 | todoManager: { 13 | visibilityFilter: (state: State) => string, 14 | todos: (state: State) => Array, 15 | }, 16 | }; 17 | 18 | const states: States = { 19 | layout: { 20 | shouldMenuLeftOpened: (state) => state.layout.shouldMenuLeftOpened, 21 | isMenuCurrentlyOpened: (state) => state.layout.isMenuCurrentlyOpened, 22 | }, 23 | 24 | chuckNorris: (state) => state.chuckNorris, 25 | 26 | todoManager: { 27 | visibilityFilter: (state) => state.todoManager.visibilityFilter, 28 | todos: (state) => state.todoManager.todos, 29 | }, 30 | }; 31 | 32 | export default states; 33 | -------------------------------------------------------------------------------- /src/js/app/states/types.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export type ChuckNorrisState = {| 4 | completed: boolean, 5 | joke: ?string, 6 | error: ?string, 7 | |}; 8 | 9 | export type LayoutState = {| 10 | shouldMenuLeftOpened: boolean, 11 | isMenuCurrentlyOpened: boolean, 12 | |}; 13 | 14 | export type TodoState = {| 15 | id: number, 16 | text: string, 17 | completed: boolean, 18 | |}; 19 | 20 | export type TodoManagerState = {| 21 | todos: Array, 22 | visibilityFilter: string, 23 | |}; 24 | 25 | export type State = { 26 | layout: LayoutState, 27 | chuckNorris: ChuckNorrisState, 28 | todoManager: TodoManagerState, 29 | }; 30 | -------------------------------------------------------------------------------- /src/js/app/store/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /** 3 | * Function to configure store and executes sagas. 4 | */ 5 | import type { GenericStoreEnchancer } from 'redux'; 6 | import { applyMiddleware, compose, createStore, StoreCreator } from 'redux'; 7 | import { routerMiddleware } from 'connected-react-router'; 8 | import { createEpicMiddleware } from 'redux-observable'; 9 | import reduxDevToolsExtension from './reduxDevtoolsExtension'; 10 | import rootEpic from '../epics/index'; 11 | import reducers from '../reducers/index'; 12 | import env from '../utils/env'; 13 | 14 | const configureStore = (history: *): StoreCreator => { 15 | const epicMiddleware = createEpicMiddleware(); 16 | 17 | const routerHistoryMiddleware = routerMiddleware(history); 18 | 19 | let enhancer: GenericStoreEnchancer = applyMiddleware(epicMiddleware, routerHistoryMiddleware); 20 | 21 | if (!env.isProduction()) { 22 | enhancer = compose(enhancer, reduxDevToolsExtension()); 23 | } 24 | 25 | const store = createStore(reducers(history), enhancer); 26 | 27 | epicMiddleware.run(rootEpic); 28 | 29 | return store; 30 | }; 31 | 32 | export default configureStore; 33 | -------------------------------------------------------------------------------- /src/js/app/store/reduxDevtoolsExtension.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /** 3 | * Enabling Redux DevTools. 4 | * 5 | * See https://github.com/zalmoxisus/redux-devtools-extension 6 | */ 7 | const reduxDevtoolsExtension = (): Function => 8 | /* eslint-disable no-underscore-dangle */ 9 | typeof window === 'object' && typeof window.__REDUX_DEVTOOLS_EXTENSION__ !== 'undefined' 10 | ? window.__REDUX_DEVTOOLS_EXTENSION__() 11 | : (f) => f; 12 | /* eslint-enable no-underscore-dangle */ 13 | 14 | export default reduxDevtoolsExtension; 15 | -------------------------------------------------------------------------------- /src/js/app/utils/__tests__/env-spec.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { describe, it, beforeEach, afterEach } from 'mocha'; 3 | import { expect } from 'chai'; 4 | import env from '../env'; 5 | 6 | describe('Common', () => { 7 | describe('Utils', () => { 8 | describe('env', () => { 9 | const originalProcessEnv = {}; 10 | 11 | beforeEach(() => { 12 | originalProcessEnv.NODE_ENV = process.env.NODE_ENV; 13 | originalProcessEnv.CONTEXT_ROOT = process.env.CONTEXT_ROOT; 14 | originalProcessEnv.APP_NAME = process.env.APP_NAME; 15 | originalProcessEnv.VERSION = process.env.VERSION; 16 | }); 17 | 18 | afterEach(() => { 19 | process.env.NODE_ENV = originalProcessEnv.NODE_ENV; 20 | process.env.CONTEXT_ROOT = originalProcessEnv.CONTEXT_ROOT; 21 | process.env.APP_NAME = originalProcessEnv.APP_NAME; 22 | process.env.VERSION = originalProcessEnv.VERSION; 23 | }); 24 | 25 | it('given process.env.NODE_ENV, should return correct value', () => { 26 | process.env.NODE_ENV = 'test-value'; 27 | expect(env.getNodeEnv()).to.deep.equal('test-value'); 28 | }); 29 | 30 | it('given process.env.CONTEXT_ROOTV, should return correct value', () => { 31 | process.env.CONTEXT_ROOT = 'test-value'; 32 | expect(env.getContextRoot()).to.deep.equal('test-value'); 33 | }); 34 | 35 | it('given process.env.APP_NAME, should return correct value', () => { 36 | process.env.APP_NAME = 'test-value'; 37 | expect(env.getAppName()).to.deep.equal('test-value'); 38 | }); 39 | 40 | it('given process.env.VERSION, should return correct value', () => { 41 | process.env.VERSION = 'test-value'; 42 | expect(env.getVersion()).to.deep.equal('test-value'); 43 | }); 44 | 45 | it('given process.env.NODE_ENV is production, should return true', () => { 46 | process.env.NODE_ENV = 'production'; 47 | expect(env.isProduction()).to.be.equal(true); 48 | }); 49 | 50 | it('given process.env.NODE_ENV is development, should return false', () => { 51 | process.env.NODE_ENV = 'development'; 52 | expect(env.isProduction()).to.be.equal(false); 53 | }); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/js/app/utils/__tests__/urlHelper-spec.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { describe, it, beforeEach, afterEach } from 'mocha'; 3 | import { expect } from 'chai'; 4 | import { url, sanitizeContextRoot } from '../urlHelper'; 5 | 6 | const existingContextRoot = process.env.CONTEXT_ROOT; 7 | 8 | describe('Common', () => { 9 | describe('Utils', () => { 10 | describe('URL Helper', () => { 11 | describe('Default', () => { 12 | const server = 'http://server'; 13 | 14 | beforeEach(() => { 15 | process.env.CONTEXT_ROOT = server; 16 | }); 17 | 18 | afterEach(() => { 19 | process.env.CONTEXT_ROOT = existingContextRoot; 20 | }); 21 | 22 | it(`given /, should be ${server}/`, () => { 23 | expect(url('/')).to.deep.equal(`${server}/`); 24 | }); 25 | 26 | it(`given /app, should be ${server}/app`, () => { 27 | expect(url('/app')).to.deep.equal(`${server}/app`); 28 | }); 29 | 30 | it(`given empty string, should be ${server}`, () => { 31 | expect(url('')).to.deep.equal(`${server}`); 32 | }); 33 | }); 34 | 35 | describe('sanitizeContextRoot', () => { 36 | it('given undefined value, should be CONTEXT_ROOT', () => { 37 | process.env.CONTEXT_ROOT = '/abc'; 38 | 39 | expect(sanitizeContextRoot(undefined)).to.deep.equal('/abc'); 40 | 41 | process.env.CONTEXT_ROOT = existingContextRoot; 42 | }); 43 | 44 | it('given /, should be empty string', () => { 45 | expect(sanitizeContextRoot('/')).to.deep.equal(''); 46 | }); 47 | 48 | it('given /app, should be /app', () => { 49 | expect(sanitizeContextRoot('/app')).to.deep.equal('/app'); 50 | }); 51 | 52 | it('given /app/, should be /app', () => { 53 | expect(sanitizeContextRoot('/app/')).to.deep.equal('/app'); 54 | }); 55 | 56 | it('given /app/path/, should be /app/path', () => { 57 | expect(sanitizeContextRoot('/app/path/')).to.deep.equal('/app/path'); 58 | }); 59 | }); 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/js/app/utils/env.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /** 3 | * Handles injected global values from Webpack's DefinePlugin. 4 | */ 5 | type Env = { 6 | getNodeEnv: Function, 7 | getContextRoot: Function, 8 | getAppName: Function, 9 | getVersion: Function, 10 | isProduction: Function, 11 | }; 12 | 13 | const env: Env = { 14 | getNodeEnv: (): ?string => process.env.NODE_ENV, 15 | getContextRoot: (): ?string => process.env.CONTEXT_ROOT, 16 | getAppName: (): ?string => process.env.APP_NAME, 17 | getVersion: (): ?string => process.env.VERSION, 18 | isProduction: (): boolean => process.env.NODE_ENV === 'production', 19 | }; 20 | 21 | export default env; 22 | -------------------------------------------------------------------------------- /src/js/app/utils/muiTheme.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import teal from '@material-ui/core/colors/teal'; 3 | import grey from '@material-ui/core/colors/grey'; 4 | import blue from '@material-ui/core/colors/blue'; 5 | import createMuiTheme from '@material-ui/core/styles/createMuiTheme'; 6 | 7 | const defaultDisplay = { 8 | color: grey[700], 9 | }; 10 | 11 | /** 12 | * Material UI theme override 13 | */ 14 | const muiTheme = createMuiTheme({ 15 | palette: { 16 | primary: teal, 17 | }, 18 | typography: { 19 | h1: defaultDisplay, 20 | h2: defaultDisplay, 21 | h3: defaultDisplay, 22 | h4: defaultDisplay, 23 | body1: defaultDisplay, 24 | body2: defaultDisplay, 25 | subtitle1: defaultDisplay, 26 | }, 27 | // custom attribute 28 | link: { 29 | color: blue[500], 30 | textDecoration: 'underline', 31 | }, 32 | }); 33 | 34 | export default muiTheme; 35 | -------------------------------------------------------------------------------- /src/js/app/utils/urlHelper.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import env from './env'; 3 | 4 | /** 5 | * Removes trailing slash from context root. 6 | * 7 | * The default value is `process.env.CONTEXT_ROOT` injected using `webpack.DefinePlugin(..)` 8 | * if exists, otherwise blank string. 9 | * 10 | * @param contextRoot Context root 11 | * @return Context root without trailing slash 12 | */ 13 | export const sanitizeContextRoot = (contextRoot: ?string = env.getContextRoot()): string => 14 | contextRoot ? contextRoot.replace(/\/$/, '') : ''; 15 | 16 | /** 17 | * Returns URL with context root prefix. 18 | * 19 | * Context root has to be dynamically determine to allow specs using `nock` to mock it out. 20 | * 21 | * @param uri URI 22 | * @return URL with context root prefix. 23 | */ 24 | export const url = (uri: string): string => sanitizeContextRoot() + uri; 25 | -------------------------------------------------------------------------------- /src/js/components/app/App.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import * as React from 'react'; 3 | import { Route, Switch } from 'react-router-dom'; 4 | import LayoutConnected from '../layout'; 5 | import Home from '../home'; 6 | import TodoManager from '../todoManager'; 7 | import ChuckNorrisConnected from '../chuckNorris/ChuckNorrisConnected'; 8 | import { PageNotFoundError, UnexpectedError } from '../error'; 9 | 10 | const App = (): React.Element<*> => ( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | 22 | export default App; 23 | -------------------------------------------------------------------------------- /src/js/components/chuckNorris/ChuckNorris.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React from 'react'; 3 | import Typography from '@material-ui/core/Typography'; 4 | import Button from '@material-ui/core/Button'; 5 | import Grid from '@material-ui/core/Grid'; 6 | import CircularProgress from '@material-ui/core/CircularProgress'; 7 | import chuckNorrisImage from '../../../img/chuck-norris.jpg'; 8 | import type { ChuckNorrisState } from '../../app/states/types'; 9 | import type { GetJokeFn } from '../../app/actions/types'; 10 | 11 | type Props = $ReadOnly<{| 12 | chuckNorris: ChuckNorrisState, 13 | onGetJoke: GetJokeFn, 14 | |}>; 15 | 16 | const ChuckNorris = ({ chuckNorris, onGetJoke }: Props) => ( 17 |
18 | 19 | Chuck Norris 20 | 21 |
22 | 23 | This view demonstrates async action using epics and RxJS. 24 |
25 |
26 | 27 | 28 | 29 | Chuck Norris 30 | 31 |
32 |
33 | 34 | 37 |
38 | 39 | {!chuckNorris.completed ? ( 40 |
41 |
42 | 43 |
44 | ) : null} 45 | {chuckNorris.joke ? ( 46 | 47 | {chuckNorris.joke} 48 | 49 | ) : null} 50 | {chuckNorris.error ? ( 51 | 52 | An error has occurred: {chuckNorris.error} 53 | 54 | ) : null} 55 |
56 |
57 |
58 | ); 59 | 60 | export default ChuckNorris; 61 | -------------------------------------------------------------------------------- /src/js/components/chuckNorris/ChuckNorrisConnected.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { connect } from 'react-redux'; 3 | import actions from '../../app/actions'; 4 | import states from '../../app/states'; 5 | import ChuckNorris from './ChuckNorris'; 6 | 7 | const mapStateToProps = (state) => ({ 8 | chuckNorris: states.chuckNorris(state), 9 | }); 10 | 11 | const mapDispatchToProps = { 12 | onGetJoke: actions.getJoke, 13 | }; 14 | 15 | const ChuckNorrisConnected = connect(mapStateToProps, mapDispatchToProps)(ChuckNorris); 16 | 17 | export default ChuckNorrisConnected; 18 | -------------------------------------------------------------------------------- /src/js/components/chuckNorris/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | export { default } from './ChuckNorrisConnected'; 3 | -------------------------------------------------------------------------------- /src/js/components/error/PageNotFoundError.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React from 'react'; 3 | import Typography from '@material-ui/core/Typography'; 4 | 5 | const PageNotFoundError = () => ( 6 | 7 | The page you are looking for does not exist. 8 | 9 | ); 10 | 11 | export default PageNotFoundError; 12 | -------------------------------------------------------------------------------- /src/js/components/error/UnexpectedError.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React from 'react'; 3 | import Typography from '@material-ui/core/Typography'; 4 | 5 | const UnexpectedError = () => ( 6 | 7 | An unexpected error has occurred while trying to process the page. 8 | 9 | ); 10 | 11 | export default UnexpectedError; 12 | -------------------------------------------------------------------------------- /src/js/components/error/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | export { default as PageNotFoundError } from './PageNotFoundError'; 3 | export { default as UnexpectedError } from './UnexpectedError'; 4 | -------------------------------------------------------------------------------- /src/js/components/home/Home.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import * as React from 'react'; 3 | import grey from '@material-ui/core/colors/grey'; 4 | import Button from '@material-ui/core/Button'; 5 | import Checkbox from '@material-ui/core/Checkbox'; 6 | import FormControlLabel from '@material-ui/core/FormControlLabel'; 7 | import Paper from '@material-ui/core/Paper'; 8 | import Radio from '@material-ui/core/Radio'; 9 | import TextField from '@material-ui/core/TextField'; 10 | import Typography from '@material-ui/core/Typography'; 11 | import makeStyles from '@material-ui/core/styles/makeStyles'; 12 | 13 | const useStyles = makeStyles((theme) => ({ 14 | paper: { 15 | padding: theme.spacing(3), 16 | color: grey[700], 17 | }, 18 | textField: { 19 | width: 200, 20 | margin: theme.spacing(1), 21 | }, 22 | button: { 23 | width: 200, 24 | margin: theme.spacing(1), 25 | }, 26 | })); 27 | 28 | const Home = (): React.Element<*> => { 29 | const classes = useStyles(); 30 | 31 | return ( 32 |
33 | 34 | Welcome! 35 | 36 |
37 | 38 | You have successfully created a single-page app! 39 |
40 |
41 | 42 | 43 | Look and Feel 44 | 45 | 46 |
47 | 48 | h1 49 | h2 50 | h3 51 | h4 52 | Body 1 53 | Body 2 54 | 55 |
56 |
57 | 58 | 59 | 60 | 61 | 62 |
63 |
64 | 65 | 68 | 71 | 74 | 77 | 78 |
79 |
80 | 81 | } /> 82 | } /> 83 | } /> 84 | 85 |
86 |
87 | 88 | } /> 89 | } /> 90 | } /> 91 |
92 |
93 | ); 94 | }; 95 | 96 | export default Home; 97 | -------------------------------------------------------------------------------- /src/js/components/home/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | export { default } from './Home'; 3 | -------------------------------------------------------------------------------- /src/js/components/layout/Layout.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import type { Element } from 'react'; 3 | import React, { useEffect } from 'react'; 4 | import PropTypes from 'prop-types'; 5 | import classNames from 'classnames'; 6 | import AppBar from '@material-ui/core/AppBar'; 7 | import Toolbar from '@material-ui/core/Toolbar'; 8 | import Typography from '@material-ui/core/Typography'; 9 | import IconButton from '@material-ui/core/IconButton'; 10 | import SvgIcon from '@material-ui/core/SvgIcon'; 11 | import MenuIcon from '@material-ui/icons/Menu'; 12 | 13 | import makeStyles from '@material-ui/core/styles/makeStyles'; 14 | import MenuDrawerConnected from './MenuDrawerConnected'; 15 | import env from '../../app/utils/env'; 16 | import muiTheme from '../../app/utils/muiTheme'; 17 | 18 | type Props = $ReadOnly<{| 19 | children: Element<*>, 20 | onMenuLeftOpened: Function, 21 | onToggleMenu: Function, 22 | shouldMenuLeftOpened: boolean, 23 | onRouteChange: Function, 24 | |}>; 25 | 26 | const useStyles = makeStyles(() => ({ 27 | title: { 28 | cursor: 'pointer', 29 | flex: 1, 30 | }, 31 | menuButton: { 32 | display: 'block', 33 | }, 34 | hide: { 35 | display: 'none', 36 | }, 37 | bodyShift: { 38 | marginLeft: 240, 39 | }, 40 | content: { 41 | margin: '0 3rem 5rem 3rem', 42 | }, 43 | })); 44 | 45 | const Layout = ({ 46 | onMenuLeftOpened, 47 | shouldMenuLeftOpened, 48 | onToggleMenu, 49 | children, 50 | onRouteChange, 51 | }: Props) => { 52 | const classes = useStyles(); 53 | 54 | // this effect will execute exactly 1 time (by passing [] as 2nd arg) 55 | useEffect(() => { 56 | const handleMediaQueryChanged = (e) => onMenuLeftOpened(e.matches); 57 | 58 | const mql = window.matchMedia(muiTheme.breakpoints.up('xl').replace('@media ', '')); 59 | 60 | // manually trigger for the first time 61 | handleMediaQueryChanged(mql); 62 | 63 | // listen to window size changes 64 | mql.addListener(handleMediaQueryChanged); 65 | 66 | // on unmount, remove the listener 67 | return () => { 68 | mql.removeListener(handleMediaQueryChanged); 69 | }; 70 | }, []); 71 | 72 | return ( 73 |
74 |
75 | 76 | 77 | 82 | 83 | 84 | 85 | onRouteChange('/')} 90 | noWrap 91 | > 92 | {`${env.getAppName()} ( ${env.getVersion()} )`} 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 |
114 | 115 |
{children}
116 |
117 | 118 | 119 |
120 | ); 121 | }; 122 | 123 | Layout.propTypes = { 124 | children: PropTypes.node.isRequired, 125 | onMenuLeftOpened: PropTypes.func.isRequired, 126 | onToggleMenu: PropTypes.func.isRequired, 127 | shouldMenuLeftOpened: PropTypes.bool.isRequired, 128 | onRouteChange: PropTypes.func.isRequired, 129 | }; 130 | 131 | export default Layout; 132 | -------------------------------------------------------------------------------- /src/js/components/layout/LayoutConnected.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { connect } from 'react-redux'; 3 | import { push } from 'connected-react-router'; 4 | import actions from '../../app/actions'; 5 | import states from '../../app/states'; 6 | import Layout from './Layout'; 7 | 8 | const mapStateToProps = (state) => ({ 9 | shouldMenuLeftOpened: states.layout.shouldMenuLeftOpened(state), 10 | }); 11 | 12 | const mapDispatchToProps = (dispatch) => ({ 13 | onMenuLeftOpened: (open: boolean) => dispatch(actions.menuLeftOpened(open)), 14 | onToggleMenu: () => dispatch(actions.toggleMenu()), 15 | onRouteChange: (location: string) => dispatch(push(location)), 16 | }); 17 | 18 | const LayoutConnected = connect(mapStateToProps, mapDispatchToProps)(Layout); 19 | 20 | export default LayoutConnected; 21 | -------------------------------------------------------------------------------- /src/js/components/layout/MenuDrawer.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React from 'react'; 3 | import Drawer from '@material-ui/core/Drawer'; 4 | import Divider from '@material-ui/core/Divider'; 5 | import List from '@material-ui/core/List'; 6 | import ListItem from '@material-ui/core/ListItem'; 7 | import ListItemIcon from '@material-ui/core/ListItemIcon'; 8 | import ListItemText from '@material-ui/core/ListItemText'; 9 | import MoodIcon from '@material-ui/icons/Mood'; 10 | import AssignmentIcon from '@material-ui/icons/Assignment'; 11 | import HomeIcon from '@material-ui/icons/Home'; 12 | import makeStyles from '@material-ui/core/styles/makeStyles'; 13 | 14 | type Props = $ReadOnly<{| 15 | isMenuCurrentlyOpened: boolean, 16 | shouldMenuLeftOpened: boolean, 17 | onToggleMenu: Function, 18 | onRouteChange: Function, 19 | |}>; 20 | 21 | const useStyles = makeStyles((theme) => ({ 22 | root: { 23 | width: 240, 24 | height: '100%', 25 | }, 26 | toolbarHeight: { 27 | minHeight: theme.mixins.toolbar.minHeight, 28 | }, 29 | })); 30 | 31 | const MenuDrawer = ({ 32 | isMenuCurrentlyOpened, 33 | shouldMenuLeftOpened, 34 | onToggleMenu, 35 | onRouteChange, 36 | }: Props) => { 37 | const classes = useStyles(); 38 | 39 | /** 40 | * When changing route, determine if there's a need to hide the menu especially when 41 | * user uses a small viewing device. 42 | * 43 | * @param path Path to switch to 44 | */ 45 | const changeRoute: Function = (path: string) => () => { 46 | onRouteChange(path); 47 | 48 | if (!shouldMenuLeftOpened) { 49 | onToggleMenu(); 50 | } 51 | }; 52 | 53 | return ( 54 | 60 | 61 |
62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | ); 88 | }; 89 | 90 | export default MenuDrawer; 91 | -------------------------------------------------------------------------------- /src/js/components/layout/MenuDrawerConnected.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { connect } from 'react-redux'; 3 | import { push } from 'connected-react-router'; 4 | import actions from '../../app/actions'; 5 | import states from '../../app/states'; 6 | import MenuDrawer from './MenuDrawer'; 7 | 8 | const mapStateToProps = (state) => ({ 9 | shouldMenuLeftOpened: states.layout.shouldMenuLeftOpened(state), 10 | isMenuCurrentlyOpened: states.layout.isMenuCurrentlyOpened(state), 11 | }); 12 | 13 | const mapDispatchToProps = (dispatch) => ({ 14 | onToggleMenu: () => dispatch(actions.toggleMenu()), 15 | onRouteChange: (location: string) => dispatch(push(location)), 16 | }); 17 | 18 | const MenuDrawerConnected = connect(mapStateToProps, mapDispatchToProps)(MenuDrawer); 19 | 20 | export default MenuDrawerConnected; 21 | -------------------------------------------------------------------------------- /src/js/components/layout/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | export { default } from './LayoutConnected'; 3 | -------------------------------------------------------------------------------- /src/js/components/todoManager/AddTodo.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { useState, useEffect } from 'react'; 3 | import Grid from '@material-ui/core/Grid'; 4 | import Button from '@material-ui/core/Button'; 5 | import TextField from '@material-ui/core/TextField'; 6 | import type { AddTodoFn } from '../../app/actions/types'; 7 | 8 | type Props = $ReadOnly<{| 9 | onAddTodo: AddTodoFn, 10 | |}>; 11 | 12 | const AddTodo = ({ onAddTodo }: Props) => { 13 | const [value, setValue] = useState(''); 14 | const [error, setError] = useState(false); 15 | let todoTextField = null; 16 | 17 | // triggers focus on text field 18 | const handleInputFocus = (): void => { 19 | if (todoTextField) { 20 | todoTextField.focus(); 21 | } 22 | }; 23 | 24 | // on button click, add new value, reset state value and set focus on text field 25 | const handleButtonClick = (): void => { 26 | if (value) { 27 | onAddTodo(value); 28 | setValue(''); 29 | setError(false); 30 | handleInputFocus(); 31 | } else { 32 | setError(true); 33 | handleInputFocus(); 34 | } 35 | }; 36 | 37 | // on input change, update state 38 | const handleInputChange = (event: SyntheticInputEvent<*>): void => { 39 | setValue(event.target.value); 40 | }; 41 | 42 | // on enter pressed on input field, trigger button click 43 | const handleInputEnter = (event: SyntheticKeyboardEvent<*>): void => { 44 | if (event.keyCode === 13) { 45 | handleButtonClick(); 46 | } 47 | }; 48 | 49 | useEffect(() => { 50 | handleInputFocus(); 51 | }, []); 52 | 53 | /* eslint-disable no-return-assign */ 54 | return ( 55 | 56 | 57 | (todoTextField = ref)} 59 | autoFocus 60 | fullWidth 61 | label="Enter Todo..." 62 | value={value} 63 | onChange={handleInputChange} 64 | onKeyDown={handleInputEnter} 65 | error={error} 66 | /> 67 | 68 | 69 | 72 | 73 | 74 | ); 75 | }; 76 | 77 | export default AddTodo; 78 | -------------------------------------------------------------------------------- /src/js/components/todoManager/AddTodoConnected.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { connect } from 'react-redux'; 3 | import actions from '../../app/actions'; 4 | import AddTodo from './AddTodo'; 5 | 6 | const mapStateToProps = () => ({}); 7 | 8 | const mapDispatchToProps = { 9 | onAddTodo: actions.addTodo, 10 | }; 11 | 12 | const AddTodoConnected = connect(mapStateToProps, mapDispatchToProps)(AddTodo); 13 | 14 | export default AddTodoConnected; 15 | -------------------------------------------------------------------------------- /src/js/components/todoManager/Footer.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import * as React from 'react'; 3 | import Typography from '@material-ui/core/Typography'; 4 | import LinkConnected from './LinkConnected'; 5 | 6 | const Footer = (): React.Element<*> => ( 7 | 8 | Show: All 9 | Active 10 | Completed 11 | 12 | ); 13 | 14 | export default Footer; 15 | -------------------------------------------------------------------------------- /src/js/components/todoManager/Link.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React from 'react'; 3 | import Button from '@material-ui/core/Button'; 4 | import makeStyles from '@material-ui/core/styles/makeStyles'; 5 | import type { SetVisibilityFilterFn } from '../../app/actions/types'; 6 | 7 | type Props = $ReadOnly<{| 8 | filter: string, 9 | active: boolean, 10 | children: string, 11 | onSetVisibilityFilter: SetVisibilityFilterFn, 12 | |}>; 13 | 14 | const useStyles = makeStyles((theme) => ({ 15 | link: theme.link, 16 | })); 17 | 18 | const Link = ({ active, filter, children, onSetVisibilityFilter }: Props) => { 19 | const classes = useStyles(); 20 | 21 | if (active) { 22 | return ; 23 | } 24 | 25 | return ( 26 | 29 | ); 30 | }; 31 | 32 | export default Link; 33 | -------------------------------------------------------------------------------- /src/js/components/todoManager/LinkConnected.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { connect } from 'react-redux'; 3 | import actions from '../../app/actions'; 4 | import Link from './Link'; 5 | 6 | const mapStateToProps = (state, ownProps) => ({ 7 | filter: ownProps.filter, 8 | active: ownProps.filter === state.todoManager.visibilityFilter, 9 | }); 10 | 11 | const mapDispatchToProps = { 12 | onSetVisibilityFilter: actions.setVisibilityFilter, 13 | }; 14 | 15 | const LinkConnected = connect(mapStateToProps, mapDispatchToProps)(Link); 16 | 17 | export default LinkConnected; 18 | -------------------------------------------------------------------------------- /src/js/components/todoManager/Todo.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import * as React from 'react'; 3 | import Typography from '@material-ui/core/Typography'; 4 | import type { ToggleTodoFn } from '../../app/actions/types'; 5 | 6 | type Props = $ReadOnly<{| 7 | onClick: ToggleTodoFn, 8 | completed: boolean, 9 | text: string, 10 | |}>; 11 | 12 | // noinspection HtmlUnknownAnchorTarget 13 | const Todo = ({ onClick, completed, text }: Props): React.Element<*> => ( 14 |
  • 15 | 16 | 21 | {text} 22 | 23 | 24 |
  • 25 | ); 26 | 27 | export default Todo; 28 | -------------------------------------------------------------------------------- /src/js/components/todoManager/TodoList.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import * as React from 'react'; 3 | import Todo from './Todo'; 4 | import type { TodoState } from '../../app/states/types'; 5 | import type { ToggleTodoFn } from '../../app/actions/types'; 6 | 7 | type Props = $ReadOnly<{| 8 | todos: Array, 9 | onToggleTodo: ToggleTodoFn, 10 | |}>; 11 | 12 | const TodoList = ({ todos, onToggleTodo }: Props): React.Element<*> => ( 13 |
      14 | {todos.map((todo) => ( 15 | onToggleTodo(todo.id)} 20 | /> 21 | ))} 22 |
    23 | ); 24 | 25 | export default TodoList; 26 | -------------------------------------------------------------------------------- /src/js/components/todoManager/TodoListConnected.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { connect } from 'react-redux'; 3 | import actions from '../../app/actions'; 4 | import TodoList from './TodoList'; 5 | import { makeVisibleTodosSelector } from '../../app/selectors'; 6 | import type { VisibleTodosSelectorFn } from '../../app/selectors/types'; 7 | 8 | const makeMapStateToProps = () => { 9 | const visibleTodosSelector: VisibleTodosSelectorFn = makeVisibleTodosSelector(); 10 | 11 | return (state) => ({ 12 | todos: visibleTodosSelector(state), 13 | }); 14 | }; 15 | 16 | const mapDispatchToProps = { 17 | onToggleTodo: actions.toggleTodo, 18 | }; 19 | 20 | const TodoListConnected = connect(makeMapStateToProps, mapDispatchToProps)(TodoList); 21 | 22 | export default TodoListConnected; 23 | -------------------------------------------------------------------------------- /src/js/components/todoManager/TodoManager.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import * as React from 'react'; 3 | import Typography from '@material-ui/core/Typography'; 4 | import Footer from './Footer'; 5 | import AddTodoConnected from './AddTodoConnected'; 6 | import TodoListConnected from './TodoListConnected'; 7 | 8 | const TodoManager = (): React.Element<*> => ( 9 |
    10 | 11 | Todo Manager 12 | 13 |
    14 | 15 | A simple todo app using Redux. 16 | 17 |
    18 |
    19 | 20 | 21 | 22 |
    23 |
    24 | ); 25 | 26 | export default TodoManager; 27 | -------------------------------------------------------------------------------- /src/js/components/todoManager/__tests__/Link-spec.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { describe, it } from 'mocha'; 3 | import React from 'react'; 4 | import Button from '@material-ui/core/Button'; 5 | import { shallow } from 'enzyme'; 6 | import { expect } from 'chai'; 7 | import Link from '../Link'; 8 | import actions from '../../../app/actions'; 9 | 10 | describe('Todo Manager', () => { 11 | describe('Components', () => { 12 | describe('Link', () => { 13 | it('given selected link, should be not a clickable button', () => { 14 | const wrapper = shallow( 15 | 20 | Hello 21 | , 22 | ); 23 | 24 | const button = wrapper.find(Button); 25 | 26 | expect(button).to.have.length(1); 27 | expect(button.prop('disabled')).to.equal(true); 28 | expect(button.prop('onClick')).to.equal(undefined); 29 | }); 30 | 31 | it('given unselected link, should be clickable button', () => { 32 | const wrapper = shallow( 33 | 38 | Hello 39 | , 40 | ); 41 | 42 | const button = wrapper.find(Button); 43 | 44 | expect(button).to.have.length(1); 45 | expect(button.prop('disabled')).to.equal(undefined); 46 | expect(button.prop('onClick')).to.be.a('Function'); 47 | }); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/js/components/todoManager/__tests__/TodoList-spec.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { describe, it } from 'mocha'; 3 | import React from 'react'; 4 | import { expect } from 'chai'; 5 | import { shallow } from 'enzyme'; 6 | import TodoList from '../TodoList'; 7 | import actions from '../../../app/actions'; 8 | 9 | describe('Todo Manager', () => { 10 | describe('Components', () => { 11 | describe('TodoList', () => { 12 | it('Given todos, should render LI items', () => { 13 | const todos = [ 14 | { 15 | id: 1, 16 | text: 'Item 1', 17 | completed: false, 18 | }, 19 | { 20 | id: 2, 21 | text: 'Item 2', 22 | completed: true, 23 | }, 24 | ]; 25 | 26 | const wrapper = shallow(); 27 | 28 | expect(wrapper.length).to.equal(1); 29 | 30 | const todoTags = wrapper.find('ul').props().children; 31 | 32 | expect(todoTags.length).to.equal(2); 33 | expect(todoTags[0].props.text).to.equal('Item 1'); 34 | expect(todoTags[1].props.text).to.equal('Item 2'); 35 | }); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/js/components/todoManager/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | export { default } from './TodoManager'; 3 | -------------------------------------------------------------------------------- /src/js/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import 'typeface-roboto/index.css'; 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | import { StoreCreator } from 'redux'; 6 | import { Provider } from 'react-redux'; 7 | import { createBrowserHistory } from 'history'; 8 | import { ConnectedRouter } from 'connected-react-router'; 9 | import ThemeProvider from '@material-ui/styles/ThemeProvider'; 10 | import configureStore from './app/store/index'; 11 | import App from './components/app/App'; 12 | import { sanitizeContextRoot } from './app/utils/urlHelper'; 13 | import muiTheme from './app/utils/muiTheme'; 14 | 15 | const history = createBrowserHistory({ 16 | basename: sanitizeContextRoot(), 17 | }); 18 | 19 | const store: StoreCreator = configureStore(history); 20 | 21 | const rootElement = document.getElementById('app'); 22 | 23 | if (rootElement !== null) { 24 | ReactDOM.render( 25 | 26 | 27 | 28 | 29 | 30 | 31 | , 32 | rootElement, 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /webpack.base.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 3 | const packageJson = require('./package.json'); 4 | 5 | const srcPath = path.join(__dirname, packageJson.config.src_dir_path); 6 | 7 | const appPath = path.join(srcPath, '/js/index.js'); 8 | 9 | // Base options for HtmlWebpackPlugin for generating `index.html` 10 | // This allows production bundle to have possibly different entry file path than webpack-dev-server 11 | const htmlWebpackPluginOptions = { 12 | title: packageJson.name, 13 | template: path.join(srcPath, '/html/index.html'), 14 | favicon: path.join(srcPath, '/img/favicon.png'), 15 | }; 16 | 17 | // Base options for WebPack 18 | const webpackOptions = { 19 | entry: { 20 | polyfill: '@babel/polyfill', 21 | app: appPath, 22 | }, 23 | 24 | module: { 25 | rules: [ 26 | { 27 | enforce: 'pre', 28 | test: /\.js?$/, 29 | loader: 'eslint-loader', 30 | exclude: /node_modules/, 31 | options: { 32 | fix: true, 33 | }, 34 | }, 35 | { 36 | test: /\.js$/, 37 | use: 'babel-loader', 38 | exclude: /node_modules/, 39 | }, 40 | { 41 | test: /\.css$/, 42 | use: [ 43 | MiniCssExtractPlugin.loader, 44 | { loader: 'css-loader', options: { importLoaders: 1 } }, 45 | 'postcss-loader', 46 | ], 47 | }, 48 | { 49 | test: /\.woff(2)?$/, 50 | loader: 'url-loader', 51 | query: { 52 | limit: '10000', 53 | mimetype: 'application/octet-stream', 54 | name: 'font/[name].[hash].[ext]', 55 | }, 56 | }, 57 | { 58 | test: /\.(jpe?g|png|gif)$/i, 59 | loaders: [ 60 | 'file-loader?hash=sha512&digest=hex&name=img/[name].[hash].[ext]', 61 | { 62 | loader: 'image-webpack-loader', 63 | query: { 64 | mozjpeg: { 65 | progressive: true, 66 | }, 67 | gifsicle: { 68 | interlaced: false, 69 | }, 70 | optipng: { 71 | optimizationLevel: 4, 72 | }, 73 | pngquant: { 74 | quality: [0.65, 0.9], 75 | speed: 4, 76 | }, 77 | }, 78 | }, 79 | ], 80 | }, 81 | ], 82 | }, 83 | 84 | plugins: [ 85 | new MiniCssExtractPlugin({ 86 | filename: 'css/[name].[hash].css', 87 | }), 88 | ], 89 | 90 | // To suppress this warning when creating the vendor bundle:- 91 | // WARNING in asset size limit: The following asset(s) exceed the recommended size limit. 92 | performance: { 93 | hints: false, 94 | }, 95 | }; 96 | 97 | module.exports = { 98 | htmlWebpackPluginOptions, 99 | webpackOptions, 100 | }; 101 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require('./webpack.base.config'); 2 | const path = require('path'); 3 | const webpack = require('webpack'); 4 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 6 | const CompressionPlugin = require('compression-webpack-plugin'); 7 | const packageJson = require('./package.json'); 8 | const process = require('process'); 9 | 10 | const distPath = path.join(__dirname, packageJson.config.dist_dir_path); 11 | 12 | // Use override value if exists, otherwise use the one defined in `package.json` 13 | const contextRoot = process.env.CONTEXT_ROOT || packageJson.config.context_root; 14 | 15 | // Make sure there is a trailing slash 16 | const distUri = path.posix.join(contextRoot, packageJson.config.dist_uri, '/'); 17 | 18 | module.exports = Object.assign({}, baseConfig.webpackOptions, { 19 | mode: 'production', 20 | 21 | output: { 22 | path: distPath, 23 | 24 | publicPath: distUri, 25 | 26 | // Using `chunkhash` instead of `hash` to ensure `vendor` and `app` have different 27 | // computed hash. This allows `vendor` file to have longer term cache on user's browser 28 | // until the vendor dependencies get updated 29 | filename: 'js/[name].[chunkhash].js', 30 | }, 31 | 32 | plugins: baseConfig.webpackOptions.plugins.concat([ 33 | new CleanWebpackPlugin(), 34 | 35 | // To prevent the following warnings in browser console:- 36 | // "It looks like you're using a minified copy of the development build of React. 37 | // When deploying React apps to production, make sure to use the production build 38 | // which skips development warnings and is faster." 39 | new webpack.DefinePlugin({ 40 | 'process.env': { 41 | NODE_ENV: JSON.stringify('production'), 42 | CONTEXT_ROOT: JSON.stringify(contextRoot), 43 | APP_NAME: JSON.stringify(packageJson.name), 44 | VERSION: JSON.stringify(packageJson.version), 45 | }, 46 | }), 47 | 48 | // Generates `index.html` at the location specified by the user 49 | new HtmlWebpackPlugin( 50 | Object.assign({}, baseConfig.htmlWebpackPluginOptions, { 51 | filename: path.join(__dirname, packageJson.config.entry_file_path), 52 | }), 53 | ), 54 | 55 | // Generates GZIP compression files 56 | new CompressionPlugin(), 57 | ]), 58 | }); 59 | -------------------------------------------------------------------------------- /webpack.dev.config.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require('./webpack.base.config'); 2 | const path = require('path'); 3 | const webpack = require('webpack'); 4 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | const packageJson = require('./package.json'); 6 | 7 | const distPath = path.join(__dirname, packageJson.config.dist_dir_path); 8 | 9 | // Use override value if exists, otherwise use the one defined in `package.json` 10 | const contextRoot = process.env.CONTEXT_ROOT || packageJson.config.context_root; 11 | 12 | const trailingSlashContextRoot = path.posix.join(contextRoot, '/'); 13 | 14 | module.exports = Object.assign({}, baseConfig.webpackOptions, { 15 | devtool: 'eval', 16 | mode: 'development', 17 | 18 | output: { 19 | path: distPath, 20 | 21 | // configure base URI to match server side context root 22 | publicPath: trailingSlashContextRoot, 23 | 24 | // When using `chunkhash` on filenames, webpack-dev-server throws an error:- 25 | // "Cannot use [chunkhash] for chunk in 'js/[name].[chunkhash].js' (use [hash] instead)" 26 | // So, instead of using `hash`, removed hash from filenames to speed up performance 27 | // https://medium.com/@okonetchnikov/long-term-caching-of-static-assets-with-webpack-1ecb139adb95#.kbk0zei4k 28 | filename: 'js/[name].js', 29 | 30 | // Include comments with information about the modules to complement devtool="eval" 31 | // https://github.com/webpack/docs/wiki/build-performance#sourcemaps 32 | pathinfo: true, 33 | }, 34 | 35 | devServer: { 36 | contentBase: distPath, 37 | 38 | // to ensure bookmarkable link works instead of getting a blank screen 39 | historyApiFallback: { 40 | index: trailingSlashContextRoot, 41 | }, 42 | 43 | // use HTTPS to ensure client side can read server side generated cookie containing CSRF token 44 | https: true, 45 | 46 | // to ensure the app can be view from other devices 47 | host: '0.0.0.0', 48 | 49 | // to prevent "Invalid host header" when being viewed from other devices 50 | disableHostCheck: true, 51 | 52 | hot: true, 53 | 54 | // automatically open the browser link 55 | open: true, 56 | 57 | // Display only errors to reduce the amount of output. 58 | stats: 'errors-only', 59 | 60 | proxy: { 61 | // Redirects `https://localhost:8080/api/*` to `https://localhost:8443//api/*` 62 | [path.posix.join(contextRoot, '/api/*')]: { 63 | target: 'https://localhost:8443', 64 | secure: false, 65 | }, 66 | // Redirects `https://localhost:8080/saml/*` to `https://localhost:8443//saml/*` 67 | [path.posix.join(contextRoot, '/saml/*')]: { 68 | target: 'https://localhost:8443', 69 | secure: false, 70 | }, 71 | }, 72 | }, 73 | 74 | plugins: baseConfig.webpackOptions.plugins.concat([ 75 | // Enable Hot Module Replacement (HMR) to exchange, add, or remove modules while 76 | // an application is running without a page reload. 77 | new webpack.HotModuleReplacementPlugin(), 78 | 79 | // Generates `index.html` at the default location, which is dist dir, so that webpack-dev-server 80 | // can find it 81 | new HtmlWebpackPlugin(baseConfig.htmlWebpackPluginOptions), 82 | 83 | // Defining variables accessible by client code 84 | new webpack.DefinePlugin({ 85 | 'process.env': { 86 | NODE_ENV: JSON.stringify('development'), 87 | CONTEXT_ROOT: JSON.stringify(contextRoot), 88 | APP_NAME: JSON.stringify(packageJson.name), 89 | VERSION: JSON.stringify(packageJson.version), 90 | }, 91 | }), 92 | ]), 93 | }); 94 | --------------------------------------------------------------------------------