├── .babelrc ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── README.md ├── build └── .gitkeep ├── config └── setupJest.js ├── jest.config.js ├── package.json ├── public └── README.md ├── scripts ├── deploy.sh └── redeploy.sh ├── src ├── client.js ├── components │ ├── App │ │ ├── App.js │ │ └── App.scss │ ├── Loading │ │ └── Loading.js │ ├── Navbar │ │ ├── Navbar.js │ │ └── Navbar.scss │ ├── Page │ │ └── Page.js │ ├── PageLayout │ │ ├── PageLayout.js │ │ └── PageLayout.scss │ ├── TextInput │ │ └── TextInput.js │ └── __tests__ │ │ ├── Navbar.js │ │ └── PageLayout.js ├── helpers │ ├── createTheme.js │ ├── request.js │ └── validators.js ├── pages │ ├── Home │ │ ├── Home.js │ │ └── Home.scss │ ├── Login │ │ ├── Login.js │ │ └── Login.scss │ ├── NotFound │ │ └── NotFound.js │ ├── ReduxDemo │ │ ├── ReduxDemo.js │ │ └── ReduxDemo.scss │ ├── Signup │ │ ├── Signup.js │ │ └── Signup.scss │ ├── UserDetail │ │ └── UserDetail.js │ ├── UsersList │ │ └── UsersList.js │ └── __tests__ │ │ ├── Home.js │ │ ├── Login.js │ │ ├── Signup.js │ │ ├── UsersList.js │ │ └── helpers │ │ └── index.js ├── redux │ ├── configureStore.js │ ├── demo │ │ ├── actions.js │ │ ├── reducer.js │ │ └── saga.js │ ├── rootReducer.js │ ├── rootSaga.js │ ├── routesMap.js │ ├── sagaHelpers.js │ └── user │ │ ├── actions.js │ │ ├── reducer.js │ │ └── saga.js └── server │ ├── apiProxy.js │ ├── render.js │ └── server.js ├── webpack ├── webpack.client.config.js ├── webpack.parts.js └── webpack.server.config.js ├── yarn-error.log └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "babel-preset-env", 4 | "babel-preset-react", 5 | "babel-preset-stage-1" 6 | ], 7 | "plugins": [ 8 | [ "babel-plugin-module-resolver", 9 | { 10 | "root": [ 11 | "./src" 12 | ] 13 | } 14 | ], 15 | "babel-plugin-universal-import", 16 | ["babel-plugin-transform-runtime", { 17 | "polyfill": false, 18 | "regenerator": true 19 | }], 20 | "babel-plugin-transform-decorators-legacy", 21 | ], 22 | "env": { 23 | "development": { 24 | "plugins": [ 25 | "react-hot-loader/babel" 26 | ] 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | build -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | 'extends': 'eslint-config-airbnb', 5 | 'env': { 6 | 'browser': true, 7 | 'node': true, 8 | 'jest': true 9 | }, 10 | 'parser': 'babel-eslint', 11 | 'rules': { 12 | // eslint-config-airbnb-base/rules/best-practices.js 13 | 'block-scoped-var': 'warn', 14 | 'class-methods-use-this': 'off', 15 | 'curly': ['warn', 'multi-line'], 16 | 'dot-notation': 'off', 17 | 'guard-for-in': 'off', 18 | 'no-case-declarations': 'warn', 19 | 'no-implicit-coercion': ['warn', { 20 | boolean: false, 21 | number: true, 22 | string: true, 23 | allow: [], 24 | }], 25 | 'no-multi-spaces': [ 26 | 'warn', { 27 | 'exceptions': { 28 | 'VariableDeclarator': true, 29 | 'ImportDeclaration': true 30 | } 31 | } 32 | ], 33 | 'no-param-reassign': 'off', 34 | 'no-useless-concat': 'off', 35 | 'prefer-destructuring': 'off', 36 | 'no-restricted-globals': 'off', 37 | 38 | // eslint-config-airbnb-base/rules/errors.js 39 | 'no-console': 'warn', 40 | 'prefer-promise-reject-errors': 'off', 41 | 42 | // eslint-config-airbnb-base/rules/node.js 43 | 'global-require': 'off', 44 | 45 | // eslint-config-airbnb-base/rules/style.js 46 | 'array-bracket-spacing': [ 'warn', 'always' ], 47 | 'brace-style': [ 'warn', 'stroustrup', { 48 | 'allowSingleLine': true 49 | }], 50 | 'camelcase': 'warn', 51 | 'comma-dangle': [ 'warn', 'always-multiline' ], 52 | 'computed-property-spacing': 'off', 53 | 'func-names': 'warn', 54 | 'id-length': [ 55 | 'warn', { 56 | 'min': 2, 57 | 'exceptions': [ '_', '$', 'i', 'j', 'k', 'x', 'y', 'e', 't' ] 58 | } 59 | ], 60 | 'indent': [ 'warn', 2, { 61 | 'SwitchCase': 1 62 | }], 63 | 'linebreak-style': [ 'warn', 'unix' ], 64 | 'lines-around-directive': 'off', 65 | 'max-len': [ 'warn', 250, 4, { 'ignoreComments': true } ], 66 | 'newline-per-chained-call': 'off', 67 | 'no-bitwise': 'off', 68 | 'no-continue': 'off', 69 | 'no-mixed-operators': 'off', 70 | 'no-multiple-empty-lines': [ 71 | 'warn', { 72 | 'max': 2, 73 | 'maxEOF': 1 74 | } 75 | ], 76 | 'no-nested-ternary': 'off', 77 | 'no-plusplus': 'off', 78 | 'no-restricted-syntax': [ 79 | 'error', 80 | 'ForInStatement', 81 | 'LabeledStatement', 82 | 'WithStatement', 83 | ], 84 | 'no-trailing-spaces': [ 85 | 'warn', { 86 | 'skipBlankLines': true 87 | } 88 | ], 89 | 'no-underscore-dangle': 'off', 90 | 'object-curly-spacing': [ 'warn', 'always' ], 91 | 'padded-blocks': 'off', 92 | 'quotes': [ 'warn', 'single', 'avoid-escape' ], 93 | 'space-before-blocks': 'warn', 94 | 'space-before-function-paren': [ 'warn', { 95 | 'anonymous': 'always', 96 | 'named': 'never' 97 | }], 98 | 'space-in-parens': 'off', 99 | 'spaced-comment': 'warn', 100 | 'keyword-spacing': 'warn', 101 | 'function-paren-newline': ['error', 'consistent'], 102 | 103 | // eslint-config-airbnb-base/rules/variables.js 104 | 'no-shadow': 'warn', 105 | 'no-unused-vars': 'warn', 106 | 'no-use-before-define': 'warn', 107 | 'no-useless-escape': 'off', 108 | 109 | // eslint-config-airbnb-base/rules/es6.js 110 | 'arrow-body-style': 'off', 111 | 'arrow-parens': [ 'warn', 'always' ], 112 | 'no-var': 'warn', 113 | 'object-shorthand': 'off', 114 | 'prefer-arrow-callback': 'warn', 115 | 'prefer-template': 'off', 116 | 117 | // eslint-config-airbnb-base/rules/imports.js 118 | 'import/first': 'off', 119 | 'import/named': 'error', 120 | 'import/namespace': [ 'error', { 121 | 'allowComputed': false 122 | }], 123 | 'import/no-extraneous-dependencies': [ 'error', { 124 | 'devDependencies': true 125 | }], 126 | 'import/newline-after-import': 'off', 127 | 'import/imports-first': 'off', 128 | 'import/no-unresolved': [ 'error', {} ], 129 | 'import/no-named-as-default': 'error', 130 | 'import/extensions': [ 'warn', 'always', { 131 | '': 'never', 132 | 'js': 'never', 133 | }], 134 | 'import/no-deprecated': 'warn', 135 | 136 | // eslint-config-airbnb/rules/react.js 137 | "react/prop-types": "warn", 138 | "react/forbid-prop-types": "off", 139 | "react/forbid-foreign-prop-types": "off", 140 | "react/no-unused-prop-types": "off", 141 | "react/react-in-jsx-scope": "off", 142 | "react/jsx-filename-extension": [ "warn", { "extensions": [ ".js", ".jsx" ] } ], 143 | "react/jsx-uses-react": "error", 144 | "react/jsx-uses-vars": "error", 145 | "react/jsx-quotes": "off", 146 | "react/jsx-first-prop-new-line": "off", 147 | "react/jsx-closing-bracket-location": "off", 148 | "react/jsx-curly-spacing": "off", 149 | "react/jsx-indent": "off", 150 | "react/self-closing-comp": [ "warn", { "component": true, "html": false } ], 151 | "react/no-multi-comp": "off", 152 | "react/sort-comp": "off", 153 | "react/prefer-stateless-function": "warn", 154 | "react/no-children-prop": "off", 155 | "react/no-danger-with-children": "error", 156 | "jsx-quotes": "error", 157 | 158 | // eslint-config-airbnb/rules/react-a11y.js 159 | "jsx-a11y/img-redundant-alt": "off", 160 | "jsx-a11y/alt-text": "off", 161 | "jsx-a11y/anchor-has-content": "off", 162 | "jsx-a11y/anchor-is-valid": "off", 163 | "jsx-a11y/no-static-element-interactions": "off", 164 | "jsx-a11y/label-has-for": "off", 165 | "jsx-a11y/click-events-have-key-events": "off", 166 | "jsx-a11y/accessible-emoji": "off", 167 | 168 | }, 169 | 'plugins': [ 170 | 'import', 171 | ], 172 | 'settings': { 173 | 'import/ignore': [ 174 | 'node_modules', 175 | '\\.(scss|less|css)$', 176 | ], 177 | 'import/resolver': { 178 | 'node': { 179 | 'moduleDirectory': [ 180 | 'node_modules', 181 | './src', 182 | ], 183 | }, 184 | }, 185 | }, 186 | "globals": { 187 | "__CLIENT__": true, 188 | "__SERVER__": true, 189 | "__TEST__": true, 190 | "__non_webpack_require__": false, 191 | }, 192 | }; 193 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage/ 3 | build/client 4 | build/server 5 | webpack/records.json 6 | .DS_Store 7 | .env -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # :black_circle: universal-web-boilerplate 2 | 3 | ## Overview 4 | 5 | A modern universal web boilerplate, built on react, redux, webpack, and node. Use as a starting point for server rendered, code split webapps. 6 | 7 | See the demo app [here](http://www.universalboilerplate.com/) 8 | 9 | ## Motivation 10 | 11 | Setting up a modern javascript web project can be time consuming and difficult. 12 | 13 | Frameworks like [nextjs](https://github.com/zeit/next.js/), [create-react-app](https://github.com/facebook/create-react-app), and [razzle](https://github.com/jaredpalmer/razzle) address this complexity by abstracting configurations away with custom scaffolding tools and setup scripts. 14 | 15 | However these frameworks and tools take control away from the developer, and make it more difficult to change or customize the configuration. 16 | 17 | Universal web boilerplate provides an opinionated yet solid foundation you need to get started, without abstracting any of the implementation details away. 18 | 19 | ## Featuring 20 | 21 | 22 | #### Modern javascript 23 | - async / await everywhere, to simplify async control flow and error handling 24 | - react HOCs applied with decorators 25 | - iterators and generators integrated with redux-saga 26 | 27 | #### Rapid developer workflow 28 | - Hot module replacement reloads the source code on both server and client on change 29 | - Changes to the web server picked up and reloaded with nodemon 30 | 31 | #### Production ready 32 | - CSS and Javascipt minified in production 33 | - CommonsChunkPlugin bundles all vendor dependencies together to be browser cached 34 | - Polyfills, autoprefixers, NODE_ENV, and all the other details taken care of 35 | 36 | #### Modern frontend tech 37 | - `react` for declarative UI 38 | - `redux` for explicit state management 39 | - `react-final-form` for simple and high performance form handling 40 | - `redux-saga` for handling async workflows 41 | - `redux-first-router` for handling universal routing 42 | - `material-ui` foundational components, with `css-modules` enabled sass for custom styling 43 | - `fetch-everywhere` for isomorphic API calls 44 | 45 | #### Testing with Jest + Enzyme 46 | - Components and pages are fully mounted, wrapped with redux, emulating the app's natural state 47 | - Components are tested from a user perspective, hitting all parts of the system 48 | 49 | 50 | #### ESLint based on `eslint-config-airbnb` for fine grained control of code syntax and quality 51 | - Optimized for a react frontend environment 52 | - IDE integration highly recommended 53 | 54 | #### Logging and error handling setup from the start 55 | - redux logger implemented on both server and client 56 | - simple http logging with morgan 57 | 58 | ## Code splitting + server rendering 59 | 60 | Universal web boilerplate utilizes the "universal" product line to solve the problem of code splitting + server rendering together. 61 | 62 | It is recommended to read more about [these modules](https://medium.com/faceyspacey). Some short excerpts below are provided to give you a general sense of what is going on. 63 | 64 | - [react-universal-component](https://github.com/faceyspacey/react-universal-component), which loads components on demand on the client, and synchronously loaded on the server. 65 | - [redux-first-router](https://github.com/faceyspacey/redux-first-router), a router which encourages route based data fetching, and redux as the source of truth for route data. 66 | 67 | ## Usage with a JSON API backend 68 | 69 | This app is designed to connect to a live backend, using the API defined [here](https://github.com/dtonys/node-api-boilerplate#api). 70 | 71 | If you do not provide an API_URL, the app will run in offline mode, and you will not be able to log in or sign up. 72 | 73 | Point the API to `http://api.universalboilerplate.com` to use the existing api deployed there. 74 | 75 | You can also run [node-api-boilerplate](https://github.com/dtonys/node-api-boilerplate) locally alongside the web server, which is recommended to get started with full stack work. 76 | 77 | The app assumes your API server is on running on a separate process, and uses a proxy to send requests to the external API. 78 | 79 | This decoupled approach makes the web and api services easier to organize, and provides a more flexible architecture. 80 | 81 | 82 | ## Prerequisites 83 | 84 | - nodejs, latest LTS - https://nodejs.org/en/ 85 | - Yarn - https://yarnpkg.com/en/ 86 | 87 | ## Setup 88 | 89 | #### Download the repo and install dependencies 90 | `git clone https://github.com/dtonys/universal-web-boilerplate && cd universal-web-boilerplate` 91 | 92 | `yarn` 93 | 94 | #### Create a `.env` file with values below, and add to the project root 95 | NOTE: Substitute your own values as needed 96 | ``` 97 | SERVER_PORT=3010 98 | API_URL=http://api.universalboilerplate.com 99 | ``` 100 | 101 | #### Start the server in development mode 102 | `npm run dev` 103 | 104 | #### Build and run the server in production mode 105 | `npm run build` 106 | 107 | `npm run start` 108 | 109 | #### Run tests 110 | `npm run test` 111 | 112 | #### Run ESLint 113 | `npm run lint-js` 114 | 115 | 116 | ## External references 117 | 118 | This boilerplate was created with inspiration from the following resources: 119 | 120 | - react-redux-universal-hot-example - https://github.com/erikras/react-redux-universal-hot-example 121 | - survivejs - https://github.com/survivejs-demos/webpack-demo 122 | - redux-first-router-demo - https://github.com/faceyspacey/redux-first-router-demo 123 | - universal-demo - https://github.com/faceyspacey/universal-demo 124 | - egghead-universal-component - https://github.com/timkindberg/egghead-universal-component 125 | - create-react-app - https://github.com/facebook/create-react-app 126 | 127 | ## Updates 128 | 129 | This boilerplate is periodically updated to keep up with the latest tools and best practices. 130 | 131 | **5/21/2018 - Updates** 132 | - Updated all non webpack 4 related dependencies to latest version. 133 | 134 | **5/28/2018 - Webpack 4 Update** 135 | - Updated to webpack 4 + all webpack 4 dependencies. 136 | - Removed a few optimization related features: autodll-webpack-plugin, babel cache 137 | 138 | **TODO( Performance: Incremental rebuild & Total build time )** 139 | 140 | **TODO( Updates: Update to latest universal stack )** 141 | -------------------------------------------------------------------------------- /build/.gitkeep: -------------------------------------------------------------------------------- 1 | .gitkeep -------------------------------------------------------------------------------- /config/setupJest.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import dotenv from 'dotenv'; 3 | import 'raf/polyfill'; 4 | import 'jest-enzyme'; 5 | import Enzyme from 'enzyme'; 6 | import Adapter from 'enzyme-adapter-react-16'; 7 | Enzyme.configure({ adapter: new Adapter() }); 8 | 9 | // setup envs 10 | const envs = dotenv.load({ path: path.resolve(__dirname, '../.env') }); 11 | Object.assign(window, envs.parsed, { 12 | __SERVER__: 'false', 13 | __CLIENT__: 'true', 14 | __TEST__: 'true', 15 | }); 16 | 17 | // mock local storage 18 | // https://github.com/tmpvar/jsdom/issues/1137 19 | const inMemoryLocalStorage = {}; 20 | window.localStorage = { 21 | setItem(key, val) { 22 | inMemoryLocalStorage[key] = val; 23 | }, 24 | getItem(key) { 25 | return inMemoryLocalStorage[key]; 26 | }, 27 | removeItem(key) { 28 | delete inMemoryLocalStorage[key]; 29 | }, 30 | }; 31 | 32 | // mock fetch API 33 | global.fetch = require('jest-fetch-mock'); 34 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'jsdom', 3 | rootDir: './src', 4 | testPathIgnorePatterns: [ 5 | '/../node_modules/', 6 | 'helpers', 7 | ], 8 | moduleNameMapper: { 9 | '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 10 | '/../../jest-config/__mocks__/fileMock.js', 11 | '\\.(css|scss|less)$': 'identity-obj-proxy', // NOTE: This would be required for local scope css 12 | }, 13 | setupTestFrameworkScriptFile: '/../config/setupJest.js', 14 | snapshotSerializers: [ 15 | 'enzyme-to-json/serializer', 16 | ], 17 | }; 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "universal-web-boilerplate", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "repository": "git@github.com:dtonys/universal-web-boilerplate.git", 6 | "author": "dtonys@gmail.com ", 7 | "license": "MIT", 8 | "scripts": { 9 | "dev": "npm-run-all clean dev-server", 10 | "dev-server": "cross-env NODE_ENV=development nodemon --delay 2 --watch src/server --exec babel-node src/server/server.js", 11 | "build": "npm-run-all clean build-client build-server build-node", 12 | "build-client": "cross-env NODE_ENV=production webpack --env production --progress -p --config webpack/webpack.client.config.js", 13 | "build-server": "cross-env NODE_ENV=production webpack --env production --progress -p --config webpack/webpack.server.config.js", 14 | "build-node": "cross-env NODE_ENV=production babel src/server -d build/server", 15 | "start": "cross-env NODE_ENV=production forever start --uid universal-web --append build/server/server.js", 16 | "stop": "forever stop universal-web", 17 | "clean": "rimraf build/client build/server", 18 | "test": "jest --config jest.config.js", 19 | "lint-js": "eslint -c .eslintrc.js ./src" 20 | }, 21 | "dependencies": { 22 | "@material-ui/core": "^1.0.0", 23 | "autodll-webpack4-plugin": "^0.4.1", 24 | "autoprefixer": "^8.5.0", 25 | "babel-cli": "^6.26.0", 26 | "babel-core": "^6.26.0", 27 | "babel-jest": "^23.0.1", 28 | "babel-loader": "^7.1.2", 29 | "babel-plugin-module-resolver": "^3.0.0", 30 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 31 | "babel-plugin-transform-runtime": "^6.23.0", 32 | "babel-plugin-universal-import": "3.0.0", 33 | "babel-polyfill": "^6.26.0", 34 | "babel-preset-env": "^1.6.1", 35 | "babel-preset-react": "^6.24.1", 36 | "babel-preset-stage-1": "^6.24.1", 37 | "babel-runtime": "^6.26.0", 38 | "colors": "^1.3.0", 39 | "compression": "^1.7.1", 40 | "cookie-parser": "^1.4.3", 41 | "cross-env": "^5.1.3", 42 | "css-loader": "^0.28.8", 43 | "cssnano": "^3.10.0", 44 | "dotenv": "^5.0.1", 45 | "enzyme": "^3.3.0", 46 | "enzyme-adapter-react-16": "^1.1.1", 47 | "enzyme-to-json": "^3.3.1", 48 | "express": "^4.16.2", 49 | "fast-sass-loader": "^1.4.0", 50 | "fetch-everywhere": "^1.0.5", 51 | "file-loader": "^1.1.6", 52 | "final-form": "^4.0.3", 53 | "forever": "^0.15.3", 54 | "helmet": "^3.9.0", 55 | "history": "^4.7.2", 56 | "http-proxy": "^1.16.2", 57 | "identity-obj-proxy": "^3.0.0", 58 | "jest": "^23.0.1", 59 | "jest-enzyme": "6.0.0", 60 | "jest-fetch-mock": "^1.4.1", 61 | "lodash": "^4.17.4", 62 | "mini-css-extract-plugin": "^0.4.0", 63 | "morgan": "^1.9.0", 64 | "node-sass": "^4.7.2", 65 | "nodemon": "^1.14.11", 66 | "npm-run-all": "^4.1.2", 67 | "optimize-css-assets-webpack-plugin": "4.0.2", 68 | "postcss-loader": "^2.0.10", 69 | "prop-types": "^15.6.0", 70 | "query-string": "6.1.0", 71 | "querystring": "^0.2.0", 72 | "raf": "^3.4.0", 73 | "react": "^16.2.0", 74 | "react-dom": "^16.2.0", 75 | "react-final-form": "^3.0.4", 76 | "react-jss": "^8.2.1", 77 | "react-redux": "^5.0.6", 78 | "react-test-renderer": "^16.2.0", 79 | "react-universal-component": "^2.8.1", 80 | "redux": "^4.0.0", 81 | "redux-cli-logger": "^2.0.1", 82 | "redux-first-router": "^0.0.16-next", 83 | "redux-first-router-link": "^1.4.2", 84 | "redux-logger": "^3.0.6", 85 | "redux-saga": "^0.16.0", 86 | "rimraf": "^2.6.2", 87 | "stats-webpack-plugin": "^0.6.1", 88 | "style-loader": "^0.21.0", 89 | "time-fix-plugin": "^2.0.1", 90 | "uglifyjs-webpack-plugin": "^1.2.5", 91 | "url-loader": "^1.0.1", 92 | "webpack": "4.9.1", 93 | "webpack-cli": "^2.1.4", 94 | "webpack-flush-chunks": "^1.2.3", 95 | "webpack-merge": "^4.1.1" 96 | }, 97 | "devDependencies": { 98 | "babel-eslint": "^8.2.1", 99 | "eslint": "^4.15.0", 100 | "eslint-config-airbnb": "^16.1.0", 101 | "eslint-plugin-import": "^2.8.0", 102 | "eslint-plugin-jsx-a11y": "^6.0.3", 103 | "eslint-plugin-react": "^7.5.1", 104 | "react-hot-loader": "3.1.x", 105 | "webpack-dev-middleware": "^3.1.3", 106 | "webpack-hot-middleware": "^2.21.0", 107 | "webpack-hot-server-middleware": "0.5.0", 108 | "write-file-webpack-plugin": "^4.2.0" 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /public/README.md: -------------------------------------------------------------------------------- 1 | ### Static assets served here -------------------------------------------------------------------------------- /scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd ~/webapps/universal-web-boilerplate 3 | git pull origin master 4 | yarn 5 | npm run build 6 | npm run start 7 | -------------------------------------------------------------------------------- /scripts/redeploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd ~/webapps/universal-web-boilerplate 3 | git pull origin master 4 | yarn 5 | npm run build 6 | forever restart universal-web 7 | -------------------------------------------------------------------------------- /src/client.js: -------------------------------------------------------------------------------- 1 | // NOTE: This is the entry point for the client render 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import AppContainer from 'react-hot-loader/lib/AppContainer'; 5 | import App from 'components/App/App'; 6 | import CssBaseline from '@material-ui/core/CssBaseline'; 7 | import { MuiThemeProvider } from '@material-ui/core/styles'; 8 | import createTheme from 'helpers/createTheme'; 9 | import configureStore from 'redux/configureStore'; 10 | import { Provider as ReduxStoreProvider } from 'react-redux'; 11 | import makeRequest from 'helpers/request'; 12 | import createBrowserHistory from 'history/createBrowserHistory'; 13 | 14 | 15 | const theme = createTheme(); 16 | const request = makeRequest(); 17 | const history = createBrowserHistory(); 18 | const { store } = configureStore(window.__INITIAL_STATE__, request, history); 19 | 20 | if ( process.env.NODE_ENV !== 'production' ) { 21 | window.request = request; 22 | window.store = store; 23 | } 24 | 25 | function render( App ) { // eslint-disable-line no-shadow 26 | const root = document.getElementById('root'); 27 | ReactDOM.hydrate( 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | , 36 | root, 37 | ); 38 | } 39 | 40 | render(App); 41 | 42 | if (module.hot) { 43 | module.hot.accept('./components/App/App', () => { 44 | const App = require('./components/App/App').default; // eslint-disable-line no-shadow 45 | render(App); 46 | }); 47 | } 48 | -------------------------------------------------------------------------------- /src/components/App/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | import Page from 'components/Page/Page'; 4 | import PageLayout from 'components/PageLayout/PageLayout'; 5 | import styles from 'components/App/App.scss'; 6 | 7 | 8 | class App extends Component { 9 | 10 | componentDidMount() { 11 | // Remove JSS injected for material UI 12 | const jssStyles = document.getElementById('jss-server-side'); 13 | if (jssStyles && jssStyles.parentNode) { 14 | jssStyles.parentNode.removeChild(jssStyles); 15 | } 16 | } 17 | 18 | render() { 19 | return ( 20 |
21 | 22 | 23 | 24 |
25 | ); 26 | } 27 | } 28 | export default App; 29 | -------------------------------------------------------------------------------- /src/components/App/App.scss: -------------------------------------------------------------------------------- 1 | :global(a) { 2 | text-decoration: none; 3 | // color: white; 4 | } 5 | :global(html) { 6 | height: 100%; 7 | } 8 | 9 | .app { 10 | opacity: 1; 11 | } 12 | -------------------------------------------------------------------------------- /src/components/Loading/Loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import CircularProgress from '@material-ui/core/CircularProgress'; 3 | 4 | 5 | const Loading = () => ( 6 | 13 | ); 14 | 15 | export default Loading; 16 | -------------------------------------------------------------------------------- /src/components/Navbar/Navbar.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import Link from 'redux-first-router-link'; 5 | 6 | import { extractUserState } from 'redux/user/reducer'; 7 | import { 8 | LOGOUT_REQUESTED, 9 | } from 'redux/user/actions'; 10 | 11 | import styles from 'components/Navbar/Navbar.scss'; 12 | import AppBar from '@material-ui/core/AppBar'; 13 | import Toolbar from '@material-ui/core/Toolbar'; 14 | import Typography from '@material-ui/core/Typography'; 15 | import Button from '@material-ui/core/Button'; 16 | 17 | 18 | @connect( 19 | ( globalState ) => ({ 20 | user: extractUserState(globalState).user, 21 | }) 22 | ) 23 | class Navbar extends Component { 24 | static propTypes = { 25 | user: PropTypes.object, 26 | dispatch: PropTypes.func.isRequired, 27 | } 28 | static defaultProps = { 29 | user: null, 30 | } 31 | 32 | logout = () => { 33 | this.props.dispatch({ type: LOGOUT_REQUESTED }); 34 | } 35 | 36 | render() { 37 | const { 38 | user, 39 | } = this.props; 40 | 41 | return ( 42 |
43 | 44 | 45 | 50 | 57 | ⚪ 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | { user && 67 |
68 | 69 | 74 | { user.email.substr(0, user.email.indexOf('@')) } 75 | 76 |
77 | } 78 | { !user && 79 |
80 | 81 | 82 | 83 | 84 | 85 | 86 |
87 | } 88 |
89 |
90 |
91 | ); 92 | } 93 | } 94 | export default Navbar; 95 | -------------------------------------------------------------------------------- /src/components/Navbar/Navbar.scss: -------------------------------------------------------------------------------- 1 | .appBar { 2 | // max-width: 1000px; 3 | } 4 | .toolBar { 5 | margin: auto; 6 | width: 100%; 7 | max-width: 960px; 8 | min-height: 60px; 9 | } 10 | 11 | .navbarWrapper { 12 | padding: 0 10px; 13 | } 14 | 15 | .middleContent { 16 | flex: 1; 17 | a { 18 | color: white; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/components/Page/Page.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import { NOT_FOUND } from 'redux-first-router'; 5 | import universal from 'react-universal-component'; 6 | import Loading from 'components/Loading/Loading'; 7 | 8 | import { 9 | ROUTE_HOME, 10 | ROUTE_LOGIN, 11 | ROUTE_SIGNUP, 12 | ROUTE_REDUX_DEMO, 13 | ROUTE_USERS, 14 | ROUTE_USER_DETAIL, 15 | ROUTE_USER_DETAIL_TAB, 16 | ROUTE_ADMIN_USERS, 17 | } from 'redux/routesMap'; 18 | 19 | 20 | const options = { 21 | minDelay: 300, 22 | loading: Loading, 23 | }; 24 | const HomePage = universal(import('pages/Home/Home'), options); 25 | const LoginPage = universal(import('pages/Login/Login'), options); 26 | const SignupPage = universal(import('pages/Signup/Signup'), options); 27 | const NotFoundPage = universal(import('pages/NotFound/NotFound'), options); 28 | const ReduxDemoPage = universal(import('pages/ReduxDemo/ReduxDemo'), options); 29 | const UsersListPage = universal(import('pages/UsersList/UsersList'), options); 30 | const UserDetailPage = universal(import('pages/UserDetail/UserDetail'), options); 31 | 32 | const actionToPage = { 33 | [ROUTE_HOME]: HomePage, 34 | [ROUTE_LOGIN]: LoginPage, 35 | [ROUTE_SIGNUP]: SignupPage, 36 | [ROUTE_REDUX_DEMO]: ReduxDemoPage, 37 | [ROUTE_USERS]: UsersListPage, 38 | [ROUTE_USER_DETAIL]: UserDetailPage, 39 | [ROUTE_USER_DETAIL_TAB]: UserDetailPage, 40 | [ROUTE_ADMIN_USERS]: UsersListPage, 41 | [NOT_FOUND]: NotFoundPage, 42 | }; 43 | const getPageFromRoute = ( routeAction ) => { 44 | let RouteComponent = actionToPage[routeAction]; 45 | if ( !RouteComponent ) { 46 | RouteComponent = NotFoundPage; 47 | } 48 | return RouteComponent; 49 | }; 50 | 51 | @connect( 52 | (state) => ({ 53 | routeAction: state.location.type, 54 | }), 55 | ) 56 | class Page extends Component { 57 | static propTypes = { 58 | routeAction: PropTypes.string.isRequired, 59 | } 60 | render() { 61 | const { routeAction } = this.props; 62 | const RoutedPage = getPageFromRoute(routeAction); 63 | 64 | return ( 65 | 66 | ); 67 | } 68 | 69 | } 70 | 71 | export default Page; 72 | -------------------------------------------------------------------------------- /src/components/PageLayout/PageLayout.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styles from 'components/PageLayout/PageLayout.scss'; 4 | import Navbar from 'components/Navbar/Navbar'; 5 | 6 | 7 | const PageLayout = ({ children }) => { 8 | return ( 9 |
10 | 11 |
12 | {children} 13 |
14 |
15 | ); 16 | }; 17 | PageLayout.propTypes = { 18 | children: PropTypes.node.isRequired, 19 | }; 20 | 21 | export default PageLayout; 22 | -------------------------------------------------------------------------------- /src/components/PageLayout/PageLayout.scss: -------------------------------------------------------------------------------- 1 | .pageContent { 2 | padding: 40px 10px 0 10px; 3 | margin: auto; 4 | margin-top: 56px; 5 | max-width: 960px; 6 | width: 100%; 7 | @media (min-width: 0px) { 8 | margin-top: 48px; 9 | } 10 | @media (min-width: 600px) { 11 | margin-top: 64px; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/components/TextInput/TextInput.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import TextField from '@material-ui/core/TextField'; 4 | 5 | 6 | const TextInput = ({ 7 | input, 8 | meta, 9 | ...rest 10 | }) => { 11 | const showError = Boolean(meta.touched && meta.error); 12 | return ( 13 | input.onChange(event.target.value)} 17 | value={input.value} 18 | error={showError} 19 | helperText={showError ? meta.error : ( rest.helperText || '' )} 20 | /> 21 | ); 22 | }; 23 | TextInput.propTypes = { 24 | input: PropTypes.object.isRequired, 25 | meta: PropTypes.object.isRequired, 26 | }; 27 | 28 | export default TextInput; 29 | -------------------------------------------------------------------------------- /src/components/__tests__/Navbar.js: -------------------------------------------------------------------------------- 1 | import Navbar from 'components/Navbar/Navbar'; 2 | import { mountComponent } from 'pages/__tests__/helpers'; 3 | 4 | 5 | describe('Navbar component', () => { 6 | 7 | 8 | test('Should mount and render', () => { 9 | const navbar = mountComponent(Navbar, {}); 10 | expect(navbar).toEqual(expect.anything()); 11 | }); 12 | 13 | test('Should show the logged in user when logged in', () => { 14 | const loggedInState = { 15 | user: { 16 | user: { 17 | user: { 18 | email: 'testuser@gmail.com', 19 | }, 20 | loading: false, 21 | error: null, 22 | }, 23 | }, 24 | }; 25 | const navbar = mountComponent(Navbar, loggedInState); 26 | expect(navbar.contains('testuser')).toBe(true); 27 | }); 28 | 29 | test('Should show the login and signup buttons when logged out', () => { 30 | const loggedOutState = { 31 | user: { 32 | user: { 33 | user: null, 34 | loading: false, 35 | error: null, 36 | }, 37 | }, 38 | }; 39 | const navbar = mountComponent(Navbar, loggedOutState); 40 | expect(navbar.contains('Login')).toBe(true); 41 | expect(navbar.contains('Signup')).toBe(true); 42 | }); 43 | 44 | }); 45 | -------------------------------------------------------------------------------- /src/components/__tests__/PageLayout.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import PageLayout from 'components/PageLayout/PageLayout'; 4 | import { wrapWithProviders } from 'pages/__tests__/helpers'; 5 | 6 | 7 | describe('PageLayout component', () => { 8 | 9 | let layoutInstance = null; 10 | beforeAll((done) => { 11 | layoutInstance = mount(wrapWithProviders( 12 | Test 13 | )); 14 | done(); 15 | }); 16 | 17 | test('Should mount and render', () => { 18 | expect(layoutInstance).toEqual(expect.anything()); 19 | }); 20 | 21 | test('Should contain a navbar and some inner content', () => { 22 | expect(layoutInstance.find('Navbar').exists()).toBe(true); 23 | expect(layoutInstance.contains('Test')).toBe(true); 24 | }); 25 | 26 | }); 27 | -------------------------------------------------------------------------------- /src/helpers/createTheme.js: -------------------------------------------------------------------------------- 1 | import { createMuiTheme } from '@material-ui/core/styles'; 2 | import red from '@material-ui/core/colors/red'; 3 | 4 | 5 | export default () => { 6 | return createMuiTheme({ 7 | palette: { 8 | primary: { 9 | main: '#000', 10 | }, 11 | secondary: { 12 | main: '#FFF', 13 | }, 14 | error: { 15 | main: red[500], 16 | }, 17 | }, 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /src/helpers/request.js: -------------------------------------------------------------------------------- 1 | import lodashGet from 'lodash/get'; 2 | import querystring from 'querystring'; 3 | 4 | 5 | function getPath( req, url, query ) { 6 | const queryString = query ? ('?' + querystring.stringify(query)) : ''; 7 | // NOTE: Use full url if it starts with http 8 | if ( /http/.test(url) ) { 9 | return url + queryString; 10 | } 11 | const basePath = req ? (`${req.protocol}://${req.get('host')}`) : ''; 12 | return basePath + url + queryString; 13 | } 14 | 15 | const makeRequest = (req) => (url, options = {}) => { 16 | const path = getPath(req, url, options.query); 17 | const fetchOptions = { 18 | credentials: 'include', 19 | headers: { 20 | 'Content-Type': 'application/json', 21 | }, 22 | ...options, 23 | }; 24 | if ( options.body && typeof options.body === 'object' ) { 25 | fetchOptions.body = JSON.stringify(options.body); 26 | } 27 | if ( req && lodashGet(req, 'headers.cookie') ) { 28 | fetchOptions.headers['cookie'] = req.headers.cookie; 29 | } 30 | 31 | let responseStatus; 32 | let responseOk; 33 | return fetch(path, fetchOptions) 34 | .then((response) => { 35 | responseOk = response.ok; 36 | responseStatus = response.status; 37 | return response.json(); 38 | }) 39 | .then((body) => { 40 | if ( !responseOk ) { 41 | return Promise.reject({ 42 | ...body, 43 | status: responseStatus, 44 | }); 45 | } 46 | return body; 47 | }) 48 | .catch((error) => { 49 | return Promise.reject(error); 50 | }); 51 | }; 52 | 53 | export default makeRequest; 54 | 55 | -------------------------------------------------------------------------------- /src/helpers/validators.js: -------------------------------------------------------------------------------- 1 | function isEmpty(value) { 2 | return (value === undefined || value === null || value === ''); 3 | } 4 | 5 | export function required( value ) { 6 | if ( isEmpty(value) ) { 7 | return 'Required field'; 8 | } 9 | return undefined; 10 | } 11 | 12 | export function email(value) { 13 | // http://emailregex.com/ 14 | const emailRE = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; 15 | if (isEmpty(value) || !emailRE.test(value)) { 16 | return 'Invalid email'; 17 | } 18 | return undefined; 19 | } 20 | 21 | export function containsLowerCase(value) { 22 | const test = /[a-z]/.test(value); 23 | if ( !test ) { 24 | return 'Must contain one lowercase letter'; 25 | } 26 | return undefined; 27 | } 28 | 29 | export function containsUpperCase(value) { 30 | const test = /[A-Z]/.test(value); 31 | if ( !test ) { 32 | return 'Must contain one uppercase letter'; 33 | } 34 | return undefined; 35 | } 36 | 37 | export function containsInteger(value) { 38 | const test = /[0-9]/.test(value); 39 | if ( !test ) { 40 | return 'Must contain one number'; 41 | } 42 | return undefined; 43 | } 44 | 45 | export function truthy( value ) { 46 | if ( !value ) { 47 | return 'Must not be blank'; 48 | } 49 | return undefined; 50 | } 51 | 52 | export function minLength(min, filterRegex) { 53 | return (value) => { 54 | if ( isEmpty(value) ) return value; 55 | let result = value; 56 | if ( filterRegex ) { 57 | result = result.replace(filterRegex, ''); 58 | } 59 | if (result.length < min) { 60 | return `Must contain ${min} or more characters`; 61 | } 62 | return undefined; 63 | }; 64 | } 65 | 66 | export function maxLength(max) { 67 | return (value) => { 68 | if (!isEmpty(value) && value.length > max) { 69 | return `Must contain ${max} or fewer characters`; 70 | } 71 | return undefined; 72 | }; 73 | } 74 | 75 | export function exactLength(len, filterRegex) { 76 | return (value) => { 77 | if ( isEmpty(value) ) return value; 78 | let result = value; 79 | if ( filterRegex ) { 80 | result = result.replace(filterRegex, ''); 81 | } 82 | if (result.length !== len) { 83 | return `Must contain exactly ${len} characters`; 84 | } 85 | return undefined; 86 | }; 87 | } 88 | 89 | // check if valid integer or double 90 | export function isNumber( value ) { 91 | if ( isNaN( value ) ) { 92 | return 'Must be a valid number'; 93 | } 94 | // check for a case such as `22.`, or `.` 95 | if ( /^([0-9]*\.)$/.test( value ) ) { 96 | return 'Must be a valid number'; 97 | } 98 | return undefined; 99 | } 100 | 101 | export function integer(value) { 102 | if (!Number.isInteger(Number(value))) { 103 | return 'Must be a whole number'; 104 | } 105 | return undefined; 106 | } 107 | 108 | export function zipcode(value) { 109 | const valueString = ( value && String(value) ); 110 | if ( !/^[0-9]{5}$/.test(valueString) ) { 111 | return 'Must be a valid zipcode'; 112 | } 113 | return undefined; 114 | } 115 | 116 | // Run one validator after another, return the first error found 117 | export const composeValidators = (...validators) => (value) => { 118 | for ( let i = 0; i < validators.length; i++ ) { 119 | const error = validators[i](value); 120 | if ( error ) { 121 | return error; 122 | } 123 | } 124 | return undefined; 125 | }; 126 | -------------------------------------------------------------------------------- /src/pages/Home/Home.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import Link from 'redux-first-router-link'; 5 | import { extractUserState } from 'redux/user/reducer'; 6 | 7 | import styles from 'pages/Home/Home.scss'; 8 | import Typography from '@material-ui/core/Typography'; 9 | import Button from '@material-ui/core/Button'; 10 | 11 | @connect( 12 | ( globalState ) => ({ 13 | user: extractUserState(globalState).user, 14 | }) 15 | ) 16 | class HomePage extends Component { // eslint-disable-line react/prefer-stateless-function 17 | static propTypes = { 18 | user: PropTypes.object, 19 | } 20 | static defaultProps = { 21 | user: null, 22 | } 23 | 24 | render() { 25 | const { user } = this.props; 26 | 27 | return ( 28 |
29 | 30 | ⚫ Universal web boilerplate 31 | 32 | 33 | { user && 34 | `You are logged in as ${user.email}.` 35 | } 36 | { !user && 37 | 38 | Sign up to get started 39 | 40 | } 41 | 42 |
43 | 44 | 47 | 48 |
49 | ); 50 | } 51 | } 52 | 53 | export default HomePage; 54 | -------------------------------------------------------------------------------- /src/pages/Home/Home.scss: -------------------------------------------------------------------------------- 1 | .homePage { 2 | opacity: 1; 3 | } 4 | 5 | .wrap { 6 | text-align: center; 7 | } 8 | -------------------------------------------------------------------------------- /src/pages/Login/Login.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import { Form, Field } from 'react-final-form'; 5 | 6 | import styles from 'pages/Login/Login.scss'; 7 | import TextInput from 'components/TextInput/TextInput'; 8 | import Typography from '@material-ui/core/Typography'; 9 | import Button from '@material-ui/core/Button'; 10 | 11 | import { 12 | required as isRequired, 13 | email as isEmail, 14 | minLength as isMinLength, 15 | composeValidators, 16 | } from 'helpers/validators'; 17 | import { 18 | LOGIN_REQUESTED, 19 | } from 'redux/user/actions'; 20 | import { 21 | extractLoginState, 22 | } from 'redux/user/reducer'; 23 | 24 | 25 | @connect( 26 | ( globalState ) => ({ 27 | login: extractLoginState(globalState), 28 | }), 29 | ) 30 | class LoginPage extends Component { 31 | static propTypes = { 32 | login: PropTypes.object.isRequired, 33 | dispatch: PropTypes.func.isRequired, 34 | }; 35 | 36 | submitForm = ( values ) => { 37 | this.props.dispatch({ type: LOGIN_REQUESTED, payload: values }); 38 | } 39 | 40 | render() { 41 | const { 42 | login: { error, loading }, 43 | } = this.props; 44 | 45 | return ( 46 |
47 |
50 | {({ handleSubmit }) => ( 51 | 56 | 57 | Login 58 | 59 | { error && 60 |
61 |
62 | 69 | {error} 70 | 71 |
72 | } 73 | 82 | 91 |


92 | 103 | 104 | )} 105 | 106 |
107 | ); 108 | } 109 | } 110 | 111 | export default LoginPage; 112 | -------------------------------------------------------------------------------- /src/pages/Login/Login.scss: -------------------------------------------------------------------------------- 1 | .loginPage { 2 | opacity: 1; 3 | } 4 | 5 | .formWrap { 6 | max-width: 400px; 7 | margin: auto; 8 | margin-top: 40px; 9 | margin-bottom: 40px; 10 | padding: 10px; 11 | background: #FAFAFA; 12 | } 13 | 14 | .textField { 15 | width: 100%; 16 | } -------------------------------------------------------------------------------- /src/pages/NotFound/NotFound.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Typography from '@material-ui/core/Typography'; 3 | 4 | 5 | const NotFoundPage = () => { 6 | return ( 7 | 8 | Page Not Found 9 | 10 | ); 11 | }; 12 | 13 | export default NotFoundPage; 14 | -------------------------------------------------------------------------------- /src/pages/ReduxDemo/ReduxDemo.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styles from 'pages/ReduxDemo/ReduxDemo.scss'; 4 | import Typography from '@material-ui/core/Typography'; 5 | import Button from '@material-ui/core/Button'; 6 | import Paper from '@material-ui/core/Paper'; 7 | import Grid from '@material-ui/core/Grid'; 8 | import CircularProgress from '@material-ui/core/CircularProgress'; 9 | import { connect } from 'react-redux'; 10 | import { 11 | INCREMENT_COUNTER, 12 | DECREMENT_COUNTER, 13 | INCREMENT_COUNTER_ASYNC, 14 | DECREMENT_COUNTER_ASYNC, 15 | LOAD_DATA_REQUESTED, 16 | } from 'redux/demo/actions'; 17 | 18 | 19 | const CounterView = ({ 20 | _inc, 21 | _dec, 22 | _incAsync, 23 | _decAsync, 24 | count, 25 | }) => { 26 | return ( 27 | 28 | 29 | Count: {count} 30 | 31 | 32 | 35 | 36 | 39 | 40 | 43 | 44 | 47 | 48 | ); 49 | }; 50 | CounterView.propTypes = { 51 | _inc: PropTypes.func.isRequired, 52 | _dec: PropTypes.func.isRequired, 53 | _incAsync: PropTypes.func.isRequired, 54 | _decAsync: PropTypes.func.isRequired, 55 | count: PropTypes.number.isRequired, 56 | }; 57 | 58 | 59 | const LoadDataView = ({ 60 | loadData, 61 | posts, 62 | postsLoading, 63 | }) => { 64 | return ( 65 | 66 | 69 |
70 | { postsLoading && 71 |
72 | 73 |
74 | } 75 | { !postsLoading && 76 |
77 | {JSON.stringify(posts)} 78 |
79 | } 80 |
81 | ); 82 | }; 83 | LoadDataView.propTypes = { 84 | loadData: PropTypes.func.isRequired, 85 | posts: PropTypes.array.isRequired, 86 | postsLoading: PropTypes.bool.isRequired, 87 | }; 88 | 89 | @connect( 90 | (globalState) => ({ 91 | count: globalState.demo.count, 92 | posts: globalState.demo.posts, 93 | postsLoading: globalState.demo.postsLoading, 94 | }) 95 | ) 96 | class ReduxDemo extends Component { 97 | static propTypes = { 98 | count: PropTypes.number.isRequired, 99 | posts: PropTypes.array.isRequired, 100 | postsLoading: PropTypes.bool.isRequired, 101 | dispatch: PropTypes.func.isRequired, 102 | } 103 | 104 | _inc = () => { 105 | this.props.dispatch({ type: INCREMENT_COUNTER }); 106 | } 107 | 108 | _dec = () => { 109 | this.props.dispatch({ type: DECREMENT_COUNTER }); 110 | } 111 | 112 | _incAsync = () => { 113 | this.props.dispatch({ type: INCREMENT_COUNTER_ASYNC }); 114 | } 115 | 116 | _decAsync = () => { 117 | this.props.dispatch({ type: DECREMENT_COUNTER_ASYNC }); 118 | } 119 | 120 | loadData = () => { 121 | this.props.dispatch({ type: LOAD_DATA_REQUESTED }); 122 | } 123 | 124 | render() { 125 | const { 126 | count, 127 | posts, 128 | postsLoading, 129 | } = this.props; 130 | 131 | return ( 132 |
133 | 134 | Redux Demo Page 135 | 136 | 137 | Click the buttons below to change the state 138 | 139 |
140 | 141 | 142 | 149 | 150 | 151 | 156 | 157 | 158 |
159 | ); 160 | } 161 | } 162 | 163 | export default ReduxDemo; 164 | -------------------------------------------------------------------------------- /src/pages/ReduxDemo/ReduxDemo.scss: -------------------------------------------------------------------------------- 1 | .reduxDemo { 2 | opacity: 1; 3 | } 4 | 5 | .wrap { 6 | text-align: center; 7 | } 8 | 9 | .column { 10 | padding: 20px 10px; 11 | } 12 | -------------------------------------------------------------------------------- /src/pages/Signup/Signup.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import { Form, Field } from 'react-final-form'; 5 | 6 | import styles from 'pages/Signup/Signup.scss'; 7 | import TextInput from 'components/TextInput/TextInput'; 8 | import Typography from '@material-ui/core/Typography'; 9 | import Button from '@material-ui/core/Button'; 10 | 11 | import { 12 | required as isRequired, 13 | email as isEmail, 14 | minLength as isMinLength, 15 | composeValidators, 16 | } from 'helpers/validators'; 17 | import { 18 | SIGNUP_REQUESTED, 19 | } from 'redux/user/actions'; 20 | import { 21 | extractSignupState, 22 | } from 'redux/user/reducer'; 23 | 24 | 25 | @connect( 26 | ( globalState ) => ({ 27 | signup: extractSignupState(globalState), 28 | }), 29 | ) 30 | class SignupPage extends Component { 31 | static propTypes = { 32 | signup: PropTypes.object.isRequired, 33 | dispatch: PropTypes.func.isRequired, 34 | }; 35 | 36 | submitForm = ( values ) => { 37 | this.props.dispatch({ type: SIGNUP_REQUESTED, payload: values }); 38 | } 39 | 40 | render() { 41 | const { 42 | signup: { error, loading }, 43 | } = this.props; 44 | 45 | return ( 46 |
47 |
50 | {({ handleSubmit }) => ( 51 | 56 | 57 | Sign up 58 | 59 | { error && 60 |
61 |
62 | 69 | {error} 70 | 71 |
72 | } 73 | 82 | 91 |


92 | 101 | 102 | )} 103 | 104 |
105 | ); 106 | } 107 | } 108 | 109 | export default SignupPage; 110 | -------------------------------------------------------------------------------- /src/pages/Signup/Signup.scss: -------------------------------------------------------------------------------- 1 | .signupPage { 2 | opacity: 1; 3 | } 4 | 5 | .formWrap { 6 | max-width: 400px; 7 | margin: auto; 8 | margin-top: 40px; 9 | margin-bottom: 40px; 10 | padding: 10px; 11 | background: #FAFAFA; 12 | } 13 | 14 | .textField { 15 | width: 100%; 16 | } 17 | 18 | .submitBtn { 19 | width: 100%; 20 | } 21 | -------------------------------------------------------------------------------- /src/pages/UserDetail/UserDetail.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import { 5 | extractUserById, 6 | extractUsersState, 7 | } from 'redux/user/reducer'; 8 | import { ROUTE_USER_DETAIL_TAB } from 'redux/routesMap'; 9 | import Typography from '@material-ui/core/Typography'; 10 | import Tabs, { Tab } from '@material-ui/core/Tabs'; 11 | import Paper from '@material-ui/core/Paper'; 12 | import Loading from 'components/Loading/Loading'; 13 | 14 | 15 | @connect( 16 | (globalState) => ({ 17 | users: extractUsersState(globalState), 18 | userDetail: extractUserById(globalState, globalState.location.payload.id ), 19 | tabValue: globalState.location.payload.tab || false, 20 | }) 21 | ) 22 | class UserDetail extends Component { 23 | static propTypes = { 24 | dispatch: PropTypes.func.isRequired, 25 | userDetail: PropTypes.object.isRequired, 26 | users: PropTypes.array.isRequired, 27 | tabValue: PropTypes.oneOf([ 28 | 'id', 'email', 'roles', false, 29 | ]).isRequired, 30 | } 31 | 32 | onTabChange = ( event, tabValue ) => { 33 | const { 34 | dispatch, 35 | userDetail, 36 | } = this.props; 37 | dispatch({ 38 | type: ROUTE_USER_DETAIL_TAB, 39 | payload: { 40 | id: userDetail._id, 41 | tab: tabValue, 42 | }, 43 | }); 44 | } 45 | 46 | render() { 47 | const { 48 | userDetail, 49 | users, 50 | tabValue, 51 | } = this.props; 52 | 53 | return ( 54 |
55 | { !users && 56 | 57 | } 58 | { users && 59 |
60 | 61 | User Detail 62 | 63 |
64 | 65 | { JSON.stringify(userDetail) } 66 | 67 |
68 | 75 | 76 | 77 | 78 | 79 | {tabValue === 'id' &&

{userDetail._id}
} 80 | {tabValue === 'email' &&

{userDetail.email}
} 81 | {tabValue === 'roles' &&

{userDetail.roles.join(', ')}
} 82 |
83 | } 84 |
85 | ); 86 | } 87 | } 88 | 89 | export default UserDetail; 90 | -------------------------------------------------------------------------------- /src/pages/UsersList/UsersList.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import Link from 'redux-first-router-link'; 5 | 6 | import List from '@material-ui/core/List'; 7 | import ListItem from '@material-ui/core/ListItem'; 8 | import ListItemText from '@material-ui/core/ListItemText'; 9 | import Typography from '@material-ui/core/Typography'; 10 | import Divider from '@material-ui/core/Divider'; 11 | import { 12 | extractUsersState, 13 | } from 'redux/user/reducer'; 14 | import Loading from 'components/Loading/Loading'; 15 | 16 | 17 | @connect( 18 | (globalState) => ({ 19 | users: extractUsersState(globalState), 20 | }) 21 | ) 22 | class UsersList extends Component { 23 | static propTypes = { 24 | users: PropTypes.object.isRequired, 25 | }; 26 | 27 | render() { 28 | const { 29 | users, 30 | } = this.props.users; 31 | 32 | return ( 33 |
34 | { !users && 35 | 36 | } 37 | { users && 38 |
39 | 40 | Users List 41 | 42 | 43 | 44 | { users && users.map((user) => ( 45 |
46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 |
54 | ))} 55 |
56 |
57 | } 58 |
59 | ); 60 | } 61 | } 62 | 63 | export default UsersList; 64 | -------------------------------------------------------------------------------- /src/pages/__tests__/Home.js: -------------------------------------------------------------------------------- 1 | import HomePage from '../Home/Home'; 2 | import { mountPage } from './helpers'; 3 | 4 | 5 | describe('Home page', () => { 6 | 7 | test('Should mount and render', () => { 8 | const homePage = mountPage(HomePage, {}); 9 | expect(homePage).toEqual(expect.anything()); 10 | }); 11 | 12 | }); 13 | -------------------------------------------------------------------------------- /src/pages/__tests__/Login.js: -------------------------------------------------------------------------------- 1 | import LoginPage from '../Login/Login'; 2 | import { mountPage } from './helpers'; 3 | 4 | 5 | describe('Login page', () => { 6 | 7 | let loginPage = null; 8 | beforeAll((done) => { 9 | loginPage = mountPage(LoginPage, {}); 10 | done(); 11 | }); 12 | 13 | test('Should mount and render', () => { 14 | expect(loginPage).toEqual(expect.anything()); 15 | }); 16 | 17 | test('Shows a form', () => { 18 | expect(loginPage.find('form[data-test="loginForm"]').exists()).toBe(true); 19 | }); 20 | 21 | test('Shows an error message on submit fail', ( done ) => { 22 | const errorResponse = [ 23 | JSON.stringify({ 24 | error: { 25 | message: 'Email not found', 26 | }, 27 | }), 28 | { status: 404 }, 29 | ]; 30 | fetch.mockResponseOnce(...errorResponse); 31 | 32 | loginPage.find('input[name="email"]') 33 | .simulate('change', { target: { value: 'abcdefg@gmail.com' } }); 34 | loginPage.find('input[name="password"]') 35 | .simulate('change', { target: { value: '12345678' } }); 36 | loginPage.find('form[data-test="loginForm"]').simulate('submit'); 37 | setTimeout(() => { 38 | loginPage.update(); 39 | expect( 40 | loginPage 41 | .find('[data-test="serverError"]').first() 42 | .contains('Email not found') 43 | ).toBe(true); 44 | done(); 45 | }, 100); 46 | }); 47 | 48 | }); 49 | -------------------------------------------------------------------------------- /src/pages/__tests__/Signup.js: -------------------------------------------------------------------------------- 1 | import SignupPage from '../Signup/Signup'; 2 | import { mountPage } from './helpers'; 3 | 4 | 5 | describe('Signup page', () => { 6 | 7 | let signupPage = null; 8 | beforeAll((done) => { 9 | signupPage = mountPage(SignupPage, {}); 10 | done(); 11 | }); 12 | 13 | test('Should mount and render', () => { 14 | expect(signupPage).toEqual(expect.anything()); 15 | }); 16 | 17 | test('Shows a form', () => { 18 | expect(signupPage.find('form[data-test="signupForm"]').exists()).toBe(true); 19 | }); 20 | 21 | test('Shows an error message on submit fail', (done) => { 22 | const errorResponse = [ 23 | JSON.stringify({ 24 | error: { 25 | message: 'User email already in use', 26 | }, 27 | }), 28 | { status: 404 }, 29 | ]; 30 | fetch.mockResponseOnce(...errorResponse); 31 | signupPage.find('input[name="email"]') 32 | .simulate('change', { target: { value: 'abcdefg@gmail.com' } }); 33 | signupPage.find('input[name="password"]') 34 | .simulate('change', { target: { value: '12345678' } }); 35 | signupPage.find('form[data-test="signupForm"]').simulate('submit'); 36 | 37 | setTimeout(() => { 38 | signupPage.update(); 39 | expect( 40 | signupPage 41 | .find('[data-test="serverError"]').first() 42 | .contains('User email already in use') 43 | ).toBe(true); 44 | done(); 45 | }, 100); 46 | 47 | }); 48 | 49 | }); 50 | -------------------------------------------------------------------------------- /src/pages/__tests__/UsersList.js: -------------------------------------------------------------------------------- 1 | import UsersListPage from '../UsersList/UsersList'; 2 | import { mountPage } from './helpers'; 3 | 4 | 5 | describe('UsersList page', () => { 6 | 7 | const populatedState = { 8 | user: { 9 | users: { 10 | users: [ 11 | { email: 'test1@gmail.com' }, 12 | { email: 'test2@gmail.com' }, 13 | ], 14 | }, 15 | }, 16 | }; 17 | let usersListPage = null; 18 | beforeAll((done) => { 19 | usersListPage = mountPage(UsersListPage, populatedState); 20 | done(); 21 | }); 22 | 23 | test('Should mount and render', () => { 24 | expect(usersListPage).toEqual(expect.anything()); 25 | }); 26 | 27 | test('Shows a list of users', () => { 28 | const items = [ 29 | usersListPage.find('[data-test="userListItem"]').at(0), 30 | usersListPage.find('[data-test="userListItem"]').at(1), 31 | ]; 32 | expect(items[0].contains('test1@gmail.com')).toBe(true); 33 | expect(items[1].contains('test2@gmail.com')).toBe(true); 34 | }); 35 | 36 | }); 37 | -------------------------------------------------------------------------------- /src/pages/__tests__/helpers/index.js: -------------------------------------------------------------------------------- 1 | import { mount } from 'enzyme'; 2 | import React from 'react'; 3 | import createMemoryHistory from 'history/createMemoryHistory'; 4 | import configureStore from 'redux/configureStore'; 5 | import createTheme from 'helpers/createTheme'; 6 | import makeRequest from 'helpers/request'; 7 | import { MuiThemeProvider } from '@material-ui/core/styles'; 8 | import CssBaseline from '@material-ui/core/CssBaseline'; 9 | import { Provider as ReduxStoreProvider } from 'react-redux'; 10 | import PageLayout from 'components/PageLayout/PageLayout'; 11 | 12 | 13 | export function wrapWithProviders( Component, initialState ) { 14 | const theme = createTheme(); 15 | const request = makeRequest(); 16 | const history = createMemoryHistory({ initialEntries: [ '/' ] }); 17 | const { 18 | store, 19 | routeInitialDispatch, 20 | } = configureStore(initialState, request, history); 21 | routeInitialDispatch(); 22 | 23 | return ( 24 | 25 | 26 | 27 | { Component } 28 | 29 | 30 | ); 31 | } 32 | 33 | export function mountComponent( RawComponent, initialState ) { // eslint-disable-line 34 | return mount(wrapWithProviders( 35 | , 36 | initialState, 37 | )); 38 | } 39 | 40 | export function mountPage( PageComponent, initialState ) { // eslint-disable-line 41 | return mount(wrapWithProviders( 42 | 43 | 44 | , 45 | initialState, 46 | )); 47 | } 48 | -------------------------------------------------------------------------------- /src/redux/configureStore.js: -------------------------------------------------------------------------------- 1 | import { 2 | createStore, 3 | applyMiddleware, 4 | compose, 5 | } from 'redux'; 6 | import createSagaMiddleware from 'redux-saga'; 7 | import createRootReducer from 'redux/rootReducer'; 8 | import rootSaga from 'redux/rootSaga'; 9 | import { connectRoutes } from 'redux-first-router'; 10 | import routesMap, { routeOptions } from 'redux/routesMap'; 11 | 12 | 13 | function createReduxLogger() { 14 | let logger = null; 15 | if ( __SERVER__ || __TEST__ ) { 16 | const createLogger = require('redux-cli-logger').default; 17 | logger = createLogger({ 18 | downArrow: '▼', 19 | rightArrow: '▶', 20 | log: console.log, // eslint-disable-line no-console 21 | // when non-null, only prints if predicate(getState, action) is truthy 22 | predicate: null, 23 | // useful to trim parts of the state atom that are too verbose 24 | stateTransformer: () => [], 25 | // useful to censor private messages (containing password, etc.) 26 | actionTransformer: (action) => { 27 | // truncate large arrays 28 | if ( 29 | Array.isArray(action.payload) && 30 | action.payload.length > 0 31 | ) { 32 | return { 33 | ...action, 34 | payload: [ action.payload[0], `...${action.payload.length - 1} MORE ITEMS OMITTED` ], 35 | }; 36 | } 37 | return action; 38 | }, 39 | }); 40 | } 41 | if ( __CLIENT__ ) { 42 | const reduxLogger = require('redux-logger'); 43 | logger = reduxLogger.createLogger(); 44 | } 45 | return logger; 46 | } 47 | 48 | export default (initialState = {}, request, history) => { 49 | const { 50 | reducer: routeReducer, 51 | middleware: routeMiddleware, 52 | enhancer: routeEnhancer, 53 | thunk: routeThunk, 54 | initialDispatch: routeInitialDispatch, 55 | } = connectRoutes( 56 | history, 57 | routesMap, 58 | routeOptions, 59 | ); 60 | 61 | const middleware = []; 62 | if ( 63 | process.env.NODE_ENV !== 'production' && !__TEST__ 64 | ) { 65 | const logger = createReduxLogger(); 66 | middleware.push( logger ); 67 | } 68 | const sagaMiddleware = createSagaMiddleware(); 69 | middleware.push( sagaMiddleware ); 70 | middleware.push( routeMiddleware ); 71 | const appliedMiddleware = applyMiddleware(...middleware); 72 | const enhancers = compose( routeEnhancer, appliedMiddleware ); 73 | const rootReducer = createRootReducer( routeReducer ); 74 | const store = createStore(rootReducer, initialState, enhancers); 75 | const rootSagaTask = sagaMiddleware.run(rootSaga, { request }); 76 | 77 | if ( module.hot ) { 78 | module.hot.accept('redux/rootReducer', () => { 79 | const _createRootReducer = require('redux/rootReducer').default; 80 | const _rootReducer = _createRootReducer( routeReducer ); 81 | store.replaceReducer(_rootReducer); 82 | }); 83 | } 84 | 85 | return { 86 | store, 87 | rootSagaTask, 88 | routeThunk, 89 | routeInitialDispatch, 90 | }; 91 | }; 92 | -------------------------------------------------------------------------------- /src/redux/demo/actions.js: -------------------------------------------------------------------------------- 1 | export const INCREMENT_COUNTER = 'INCREMENT_COUNTER'; 2 | export const DECREMENT_COUNTER = 'DECREMENT_COUNTER'; 3 | export const INCREMENT_COUNTER_ASYNC = 'INCREMENT_COUNTER_ASYNC'; 4 | export const DECREMENT_COUNTER_ASYNC = 'DECREMENT_COUNTER_ASYNC'; 5 | 6 | export const LOAD_DATA_REQUESTED = 'LOAD_DATA_REQUESTED'; 7 | export const LOAD_DATA_STARTED = 'LOAD_DATA_STARTED'; 8 | export const LOAD_DATA_SUCCESS = 'LOAD_DATA_SUCCESS'; 9 | export const LOAD_DATA_ERROR = 'LOAD_DATA_ERROR'; 10 | -------------------------------------------------------------------------------- /src/redux/demo/reducer.js: -------------------------------------------------------------------------------- 1 | import { 2 | INCREMENT_COUNTER, DECREMENT_COUNTER, 3 | LOAD_DATA_STARTED, LOAD_DATA_SUCCESS, LOAD_DATA_ERROR, 4 | } from 'redux/demo/actions'; 5 | 6 | 7 | export const STORE_KEY = 'demo'; 8 | const initialState = { 9 | count: 0, 10 | posts: [], 11 | postsLoading: false, 12 | postsError: null, 13 | }; 14 | 15 | export default ( state = initialState, action ) => { 16 | switch ( action.type ) { 17 | case INCREMENT_COUNTER: { 18 | return { 19 | ...state, 20 | count: state.count + 1, 21 | }; 22 | } 23 | case DECREMENT_COUNTER: { 24 | return { 25 | ...state, 26 | count: state.count - 1, 27 | }; 28 | } 29 | case LOAD_DATA_STARTED: { 30 | return { 31 | ...state, 32 | postsLoading: true, 33 | }; 34 | } 35 | case LOAD_DATA_SUCCESS: { 36 | return { 37 | ...state, 38 | postsLoading: false, 39 | posts: action.payload, 40 | }; 41 | } 42 | case LOAD_DATA_ERROR: { 43 | return { 44 | ...state, 45 | postsLoading: false, 46 | postsError: action.payload, 47 | }; 48 | } 49 | default: { 50 | return state; 51 | } 52 | } 53 | }; 54 | 55 | -------------------------------------------------------------------------------- /src/redux/demo/saga.js: -------------------------------------------------------------------------------- 1 | import { put, call, fork, all } from 'redux-saga/effects'; 2 | import { delay, takeOne } from 'redux/sagaHelpers'; 3 | import { 4 | INCREMENT_COUNTER, DECREMENT_COUNTER, 5 | INCREMENT_COUNTER_ASYNC, DECREMENT_COUNTER_ASYNC, 6 | LOAD_DATA_REQUESTED, LOAD_DATA_STARTED, LOAD_DATA_SUCCESS, LOAD_DATA_ERROR, 7 | } from 'redux/demo/actions'; 8 | 9 | 10 | function* incrementCounterAsync(/* ...args */) { 11 | yield call(delay(1000)); 12 | yield put({ type: INCREMENT_COUNTER }); 13 | } 14 | 15 | function* decrementCounterAsync(/* ...args */) { 16 | yield call(delay(1000)); 17 | yield put({ type: DECREMENT_COUNTER }); 18 | } 19 | 20 | function* loadData(action, context) { 21 | yield put({ type: LOAD_DATA_STARTED }); 22 | try { 23 | const posts = yield call( 24 | context.request, 25 | 'https://jsonplaceholder.typicode.com/posts', 26 | { query: { _limit: 5 } }, 27 | ); 28 | yield put({ type: LOAD_DATA_SUCCESS, payload: posts }); 29 | } 30 | catch ( httpError ) { 31 | const errorMessage = httpError.error ? httpError.error : httpError.message; 32 | yield put({ type: LOAD_DATA_ERROR, payload: errorMessage }); 33 | } 34 | } 35 | 36 | export default function* ( context ) { 37 | yield all([ 38 | fork( takeOne(INCREMENT_COUNTER_ASYNC, incrementCounterAsync, context) ), 39 | fork( takeOne(DECREMENT_COUNTER_ASYNC, decrementCounterAsync, context) ), 40 | fork( takeOne(LOAD_DATA_REQUESTED, loadData, context) ), 41 | ]); 42 | } 43 | -------------------------------------------------------------------------------- /src/redux/rootReducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import demoReducer, { STORE_KEY as DEMO_STORE_KEY } from 'redux/demo/reducer'; 3 | import userReducer, { STORE_KEY as USER_STORE_KEY } from 'redux/user/reducer'; 4 | 5 | export default ( routeReducer ) => { 6 | return combineReducers({ 7 | [DEMO_STORE_KEY]: demoReducer, 8 | [USER_STORE_KEY]: userReducer, 9 | location: routeReducer, 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /src/redux/rootSaga.js: -------------------------------------------------------------------------------- 1 | import { 2 | fork, 3 | all, 4 | } from 'redux-saga/effects'; 5 | import demoSaga from 'redux/demo/saga'; 6 | import userSaga from 'redux/user/saga'; 7 | 8 | 9 | export default function* rootSaga(context) { 10 | yield all([ 11 | fork(demoSaga, context), 12 | fork(userSaga, context), 13 | ]); 14 | } 15 | -------------------------------------------------------------------------------- /src/redux/routesMap.js: -------------------------------------------------------------------------------- 1 | import lodashDifference from 'lodash/difference'; 2 | import lodashGet from 'lodash/get'; 3 | import querySerializer from 'query-string'; 4 | import { redirect, NOT_FOUND } from 'redux-first-router'; 5 | 6 | export const ROUTE_HOME = 'ROUTE_HOME'; 7 | export const ROUTE_LOGIN = 'ROUTE_LOGIN'; 8 | export const ROUTE_SIGNUP = 'ROUTE_SIGNUP'; 9 | export const ROUTE_REDUX_DEMO = 'ROUTE_REDUX_DEMO'; 10 | export const ROUTE_USERS = 'ROUTE_USERS'; 11 | export const ROUTE_USER_DETAIL = 'ROUTE_USER_DETAIL'; 12 | export const ROUTE_USER_DETAIL_TAB = 'ROUTE_USER_DETAIL_TAB'; 13 | export const ROUTE_ADMIN_USERS = 'ROUTE_ADMIN_USERS'; 14 | 15 | import { 16 | extractUserState, 17 | } from 'redux/user/reducer'; 18 | import { 19 | LOAD_USERS_REQUESTED, 20 | } from 'redux/user/actions'; 21 | 22 | 23 | const routesMap = { 24 | [ROUTE_HOME]: { 25 | path: '/', 26 | }, 27 | [ROUTE_LOGIN]: { 28 | path: '/login', 29 | loggedOutOnly: true, 30 | }, 31 | [ROUTE_SIGNUP]: { 32 | path: '/signup', 33 | loggedOutOnly: true, 34 | }, 35 | [ROUTE_REDUX_DEMO]: { 36 | path: '/redux-demo', 37 | }, 38 | [ROUTE_USERS]: { 39 | path: '/users', 40 | loggedInOnly: true, 41 | thunk: async (dispatch) => { 42 | dispatch({ type: LOAD_USERS_REQUESTED }); 43 | }, 44 | }, 45 | [ROUTE_USER_DETAIL]: { 46 | path: '/users/:id', 47 | loggedInOnly: true, 48 | thunk: async (dispatch, getState) => { 49 | if ( !getState().user.users.users ) { 50 | dispatch({ type: LOAD_USERS_REQUESTED }); 51 | } 52 | }, 53 | }, 54 | [ROUTE_USER_DETAIL_TAB]: { 55 | path: '/users/:id/:tab', 56 | loggedInOnly: true, 57 | thunk: async (dispatch, getState) => { 58 | if ( !getState().user.users.users ) { 59 | dispatch({ type: LOAD_USERS_REQUESTED }); 60 | } 61 | }, 62 | }, 63 | [ROUTE_ADMIN_USERS]: { 64 | path: '/admin/users', 65 | requireRoles: [ 'admin' ], 66 | thunk: async (dispatch) => { 67 | dispatch({ type: LOAD_USERS_REQUESTED }); 68 | }, 69 | }, 70 | [NOT_FOUND]: { 71 | path: '/not-found', 72 | }, 73 | }; 74 | 75 | export const routeOptions = { 76 | querySerializer, 77 | // Defer route initial dispatch until after saga is running 78 | initialDispatch: !__SERVER__, 79 | // Check permissions and redirect if not authorized for given route 80 | onBeforeChange: ( dispatch, getState, { action }) => { 81 | const { user } = extractUserState(getState()); 82 | const { loggedOutOnly, loggedInOnly, requireRoles } = routesMap[action.type]; 83 | const requiresLogin = Boolean( loggedInOnly || requireRoles ); 84 | 85 | // redirect to home page if logged in and visiting logged out only routes 86 | if ( loggedOutOnly && user ) { 87 | dispatch( redirect({ type: ROUTE_HOME }) ); 88 | return; 89 | } 90 | 91 | if ( requiresLogin && !user ) { 92 | const nextAction = JSON.stringify({ 93 | type: action.type, 94 | payload: action.payload, 95 | query: action.meta.location.current.query, 96 | }); 97 | dispatch( redirect({ 98 | type: ROUTE_LOGIN, 99 | payload: { 100 | query: { next: nextAction }, 101 | }, 102 | }) ); 103 | return; 104 | } 105 | 106 | // redirect to 404 if logged in but invalid role 107 | if ( requireRoles ) { 108 | const userRoles = lodashGet( user, 'roles' ); 109 | const hasRequiredRoles = userRoles && lodashDifference(requireRoles, userRoles).length === 0; 110 | if ( !hasRequiredRoles ) { 111 | dispatch( redirect({ type: NOT_FOUND }) ); 112 | } 113 | } 114 | }, 115 | }; 116 | 117 | 118 | export default routesMap; 119 | -------------------------------------------------------------------------------- /src/redux/sagaHelpers.js: -------------------------------------------------------------------------------- 1 | import { 2 | take, 3 | } from 'redux-saga/effects'; 4 | 5 | 6 | export function delay( ms ) { 7 | return () => ( 8 | new Promise((resolve) => { 9 | setTimeout(resolve, ms); 10 | }) 11 | ); 12 | } 13 | 14 | export function takeOne( actionType, fn, ...args ) { 15 | return function* () { // eslint-disable-line func-names 16 | while (true) { // eslint-disable-line no-constant-condition 17 | const action = yield take(actionType); 18 | yield* fn(action, ...args); 19 | } 20 | }; 21 | } 22 | 23 | export const obj = {}; 24 | -------------------------------------------------------------------------------- /src/redux/user/actions.js: -------------------------------------------------------------------------------- 1 | export const LOAD_USER_REQUESTED = 'LOAD_USER_REQUESTED'; 2 | export const LOAD_USER_STARTED = 'LOAD_USER_STARTED'; 3 | export const LOAD_USER_SUCCESS = 'LOAD_USER_SUCCESS'; 4 | export const LOAD_USER_ERROR = 'LOAD_USER_ERROR'; 5 | 6 | export const LOGOUT_REQUESTED = 'LOGOUT_REQUESTED'; 7 | export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS'; 8 | 9 | export const SIGNUP_REQUESTED = 'SIGNUP_REQUESTED'; 10 | export const SIGNUP_STARTED = 'SIGNUP_STARTED'; 11 | export const SIGNUP_SUCCESS = 'SIGNUP_SUCCESS'; 12 | export const SIGNUP_ERROR = 'SIGNUP_ERROR'; 13 | 14 | export const LOGIN_REQUESTED = 'LOGIN_REQUESTED'; 15 | export const LOGIN_STARTED = 'LOGIN_STARTED'; 16 | export const LOGIN_SUCCESS = 'LOGIN_SUCCESS'; 17 | export const LOGIN_ERROR = 'LOGIN_ERROR'; 18 | 19 | export const LOAD_USERS_REQUESTED = 'LOAD_USERS_REQUESTED'; 20 | export const LOAD_USERS_STARTED = 'LOAD_USERS_STARTED'; 21 | export const LOAD_USERS_SUCCESS = 'LOAD_USERS_SUCCESS'; 22 | export const LOAD_USERS_ERROR = 'LOAD_USERS_ERROR'; 23 | -------------------------------------------------------------------------------- /src/redux/user/reducer.js: -------------------------------------------------------------------------------- 1 | import { 2 | LOAD_USER_STARTED, LOAD_USER_SUCCESS, LOAD_USER_ERROR, 3 | LOGOUT_SUCCESS, 4 | SIGNUP_STARTED, SIGNUP_SUCCESS, SIGNUP_ERROR, 5 | LOGIN_STARTED, LOGIN_SUCCESS, LOGIN_ERROR, 6 | LOAD_USERS_STARTED, LOAD_USERS_SUCCESS, LOAD_USERS_ERROR, 7 | } from './actions'; 8 | 9 | 10 | export const STORE_KEY = 'user'; 11 | 12 | export function extractSignupState( globalState ) { 13 | return globalState[STORE_KEY].signup; 14 | } 15 | const signupInitialState = { 16 | loading: false, 17 | error: null, 18 | }; 19 | function signupReducer( state = signupInitialState, action ) { 20 | switch ( action.type ) { 21 | case SIGNUP_STARTED: { 22 | return { 23 | ...state, 24 | loading: true, 25 | }; 26 | } 27 | case SIGNUP_SUCCESS: { 28 | return { 29 | ...state, 30 | loading: false, 31 | error: null, 32 | }; 33 | } 34 | case SIGNUP_ERROR: { 35 | return { 36 | ...state, 37 | loading: false, 38 | error: action.payload, 39 | }; 40 | } 41 | default: { 42 | return state; 43 | } 44 | } 45 | } 46 | 47 | export function extractLoginState( globalState ) { 48 | return globalState[STORE_KEY].login; 49 | } 50 | const loginInitialState = { 51 | loading: false, 52 | error: null, 53 | }; 54 | function loginReducer( state = loginInitialState, action ) { 55 | switch ( action.type ) { 56 | case LOGIN_STARTED: { 57 | return { 58 | ...state, 59 | loading: true, 60 | }; 61 | } 62 | case LOGIN_SUCCESS: { 63 | return { 64 | ...state, 65 | loading: false, 66 | error: null, 67 | }; 68 | } 69 | case LOGIN_ERROR: { 70 | return { 71 | ...state, 72 | loading: false, 73 | error: action.payload, 74 | }; 75 | } 76 | default: { 77 | return state; 78 | } 79 | } 80 | } 81 | 82 | export function extractUserState( globalState ) { 83 | return globalState[STORE_KEY].user; 84 | } 85 | const initialUserState = { 86 | user: null, 87 | loading: false, 88 | error: null, 89 | }; 90 | function userReducer( state = initialUserState, action ) { 91 | switch ( action.type ) { 92 | case LOAD_USER_STARTED: { 93 | return { 94 | ...state, 95 | loading: true, 96 | }; 97 | } 98 | case LOAD_USER_SUCCESS: { 99 | return { 100 | ...state, 101 | user: action.payload, 102 | error: null, 103 | loading: false, 104 | }; 105 | } 106 | case LOAD_USER_ERROR: { 107 | return { 108 | ...state, 109 | error: action.payload, 110 | loading: false, 111 | }; 112 | } 113 | case LOGOUT_SUCCESS: { 114 | return { 115 | ...state, 116 | user: null, 117 | error: null, 118 | }; 119 | } 120 | default: { 121 | return state; 122 | } 123 | } 124 | } 125 | 126 | 127 | export function extractUsersState( globalState ) { 128 | return globalState[STORE_KEY].users; 129 | } 130 | export function extractUserById( globalState, id ) { 131 | const users = extractUsersState(globalState).users; 132 | return users.filter((user) => user._id === id)[0]; 133 | } 134 | const usersInitialState = { 135 | users: null, 136 | loading: false, 137 | error: null, 138 | }; 139 | function usersReducer( state = usersInitialState, action ) { 140 | switch ( action.type ) { 141 | case LOAD_USERS_STARTED: { 142 | return { 143 | ...state, 144 | loading: true, 145 | }; 146 | } 147 | case LOAD_USERS_SUCCESS: { 148 | return { 149 | ...state, 150 | loading: false, 151 | users: action.payload, 152 | }; 153 | } 154 | case LOAD_USERS_ERROR: { 155 | return { 156 | ...state, 157 | loading: false, 158 | error: action.payload, 159 | }; 160 | } 161 | default: { 162 | return state; 163 | } 164 | } 165 | } 166 | 167 | 168 | const initialState = { 169 | user: initialUserState, 170 | signup: signupInitialState, 171 | login: loginInitialState, 172 | users: usersInitialState, 173 | }; 174 | export default ( state = initialState, action ) => { 175 | return { 176 | user: userReducer( state.user, action ), 177 | signup: signupReducer( state.signup, action ), 178 | login: loginReducer( state.login, action ), 179 | users: usersReducer( state.users, action ), 180 | }; 181 | }; 182 | -------------------------------------------------------------------------------- /src/redux/user/saga.js: -------------------------------------------------------------------------------- 1 | import lodashGet from 'lodash/get'; 2 | import { put, call, fork, all, select } from 'redux-saga/effects'; 3 | import { takeOne } from 'redux/sagaHelpers'; 4 | import { redirect } from 'redux-first-router'; 5 | 6 | import { 7 | LOAD_USER_REQUESTED, LOAD_USER_STARTED, LOAD_USER_SUCCESS, LOAD_USER_ERROR, 8 | LOGOUT_REQUESTED, LOGOUT_SUCCESS, 9 | LOGIN_REQUESTED, LOGIN_STARTED, LOGIN_SUCCESS, LOGIN_ERROR, 10 | SIGNUP_REQUESTED, SIGNUP_STARTED, SIGNUP_SUCCESS, SIGNUP_ERROR, 11 | LOAD_USERS_REQUESTED, LOAD_USERS_STARTED, LOAD_USERS_SUCCESS, LOAD_USERS_ERROR, 12 | } from './actions'; 13 | import { 14 | ROUTE_HOME, 15 | } from 'redux/routesMap'; 16 | 17 | 18 | function* loadUser(action, context) { 19 | yield put({ type: LOAD_USER_STARTED }); 20 | try { 21 | const userData = yield call(context.request, '/api/session'); 22 | const userPayload = lodashGet(userData, 'data.currentUser', null); 23 | yield put({ type: LOAD_USER_SUCCESS, payload: userPayload }); 24 | } 25 | catch ( httpError ) { 26 | const httpErrorMessage = lodashGet( httpError, 'error.message' ); 27 | const errorMessage = httpErrorMessage || httpError.message; 28 | yield put({ type: LOAD_USER_ERROR, payload: errorMessage }); 29 | } 30 | } 31 | 32 | function* logout(action, context) { 33 | yield call(context.request, '/api/logout'); 34 | yield put({ type: LOGOUT_SUCCESS }); 35 | yield put( redirect({ type: ROUTE_HOME }) ); 36 | } 37 | 38 | function* login(action, context) { 39 | yield put({ type: LOGIN_STARTED }); 40 | try { 41 | yield call( context.request, '/api/login', { 42 | method: 'POST', 43 | body: action.payload, 44 | }); 45 | yield put({ type: LOGIN_SUCCESS }); 46 | yield* loadUser(null, context); 47 | const globalState = yield select(); 48 | const nextAction = lodashGet(globalState, 'location.query.next'); 49 | const nextActionParsed = nextAction ? JSON.parse(nextAction) : { type: ROUTE_HOME }; 50 | yield put( redirect( nextActionParsed ) ); 51 | } 52 | catch ( httpError ) { 53 | const httpErrorMessage = lodashGet( httpError, 'error.message' ); 54 | const errorMessage = httpErrorMessage || httpError.message; 55 | yield put({ type: LOGIN_ERROR, payload: errorMessage }); 56 | } 57 | } 58 | 59 | function* signup(action, context) { 60 | yield put({ type: SIGNUP_STARTED }); 61 | try { 62 | yield call( context.request, '/api/signup', { 63 | method: 'POST', 64 | body: action.payload, 65 | }); 66 | yield put({ type: SIGNUP_SUCCESS }); 67 | yield* loadUser(null, context); 68 | yield put( redirect({ type: ROUTE_HOME }) ); 69 | } 70 | catch ( httpError ) { 71 | const httpErrorMessage = lodashGet( httpError, 'error.message' ); 72 | const errorMessage = httpErrorMessage || httpError.message; 73 | yield put({ type: SIGNUP_ERROR, payload: errorMessage }); 74 | } 75 | } 76 | 77 | function* loadUsers(action, context) { 78 | yield put({ type: LOAD_USERS_STARTED }); 79 | try { 80 | const users = yield call( context.request, '/api/users'); 81 | const userList = lodashGet( users, 'data.items' ); 82 | yield put({ type: LOAD_USERS_SUCCESS, payload: userList }); 83 | } 84 | catch ( httpError ) { 85 | const httpErrorMessage = lodashGet( httpError, 'data.message' ); 86 | const errorMessage = httpErrorMessage || httpError.message; 87 | yield put({ type: LOAD_USERS_ERROR, payload: errorMessage }); 88 | } 89 | } 90 | 91 | export default function* ( context ) { 92 | yield all([ 93 | fork( takeOne( LOAD_USER_REQUESTED, loadUser, context ) ), 94 | fork( takeOne( LOGOUT_REQUESTED, logout, context ) ), 95 | fork( takeOne( LOGIN_REQUESTED, login, context ) ), 96 | fork( takeOne( SIGNUP_REQUESTED, signup, context ) ), 97 | fork( takeOne( LOAD_USERS_REQUESTED, loadUsers, context ) ), 98 | ]); 99 | } 100 | -------------------------------------------------------------------------------- /src/server/apiProxy.js: -------------------------------------------------------------------------------- 1 | import httpProxy from 'http-proxy'; 2 | 3 | 4 | function createProxy( target ) { 5 | const proxy = httpProxy.createProxyServer(); 6 | 7 | return (req, res, next) => { 8 | proxy.web(req, res, { 9 | target, 10 | changeOrigin: true, 11 | }, ( error ) => { 12 | next(error); 13 | }); 14 | }; 15 | } 16 | 17 | export default createProxy; 18 | -------------------------------------------------------------------------------- /src/server/render.js: -------------------------------------------------------------------------------- 1 | // NOTE: This is the entry point for the server render 2 | import lodashGet from 'lodash/get'; 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom/server'; 5 | import { flushChunkNames } from 'react-universal-component/server'; 6 | import flushChunks from 'webpack-flush-chunks'; 7 | 8 | import { MuiThemeProvider, createGenerateClassName } from '@material-ui/core/styles'; 9 | import CssBaseline from '@material-ui/core/CssBaseline'; 10 | import createTheme from 'helpers/createTheme'; 11 | import JssProvider from 'react-jss/lib/JssProvider'; 12 | import { SheetsRegistry } from 'react-jss/lib/jss'; 13 | 14 | import configureStore from 'redux/configureStore'; 15 | import createMemoryHistory from 'history/createMemoryHistory'; 16 | import { Provider as ReduxStoreProvider } from 'react-redux'; 17 | import { END as REDUX_SAGA_END } from 'redux-saga'; 18 | import makeRequest from 'helpers/request'; 19 | import { LOAD_USER_SUCCESS } from 'redux/user/actions'; 20 | 21 | import App from 'components/App/App'; 22 | 23 | 24 | function createHtml({ 25 | js, 26 | styles, 27 | cssHash, 28 | appString, 29 | muiCss, 30 | initialState, 31 | }) { 32 | 33 | const dllScript = process.env.NODE_ENV !== 'production' ? 34 | '' : 35 | ''; 36 | 37 | return ` 38 | 39 | 40 | 41 | 42 | universal-web-boilerplate 43 | 44 | 45 | 46 | ${styles} 47 | 48 | 49 | 50 |
${appString}
51 | ${cssHash} 52 | ${js} 53 | 54 | 55 | `; 56 | } 57 | 58 | function renderApp( sheetsRegistry, store ) { 59 | const generateClassName = createGenerateClassName(); 60 | const theme = createTheme(); 61 | const appRoot = ( 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | ); 71 | return appRoot; 72 | } 73 | 74 | function createServerRenderMiddleware({ clientStats }) { 75 | return async (req, res, next) => { 76 | const sheetsRegistry = new SheetsRegistry(); 77 | const request = makeRequest(req); 78 | const history = createMemoryHistory({ initialEntries: [ req.originalUrl ] }); 79 | const { 80 | store, 81 | rootSagaTask, 82 | routeThunk, 83 | routeInitialDispatch, 84 | } = configureStore({}, request, history); 85 | 86 | // load initial data - fetch user 87 | try { 88 | const userData = await request('/api/session'); 89 | const userPayload = lodashGet(userData, 'data.currentUser', null); 90 | store.dispatch({ type: LOAD_USER_SUCCESS, payload: userPayload }); 91 | } 92 | catch ( error ) { 93 | next(error); 94 | return; 95 | } 96 | 97 | // fire off the routing dispatches, including routeThunk 98 | routeInitialDispatch(); 99 | 100 | // check for immediate, synchronous redirect 101 | let location = store.getState().location; 102 | if ( location.kind === 'redirect' ) { 103 | res.redirect(302, location.pathname + ( location.search ? `?${location.search}` : '' ) ); 104 | return; 105 | } 106 | 107 | // await on route thunk 108 | if ( routeThunk ) { 109 | await routeThunk(store); 110 | } 111 | 112 | // check for redirect triggered later 113 | location = store.getState().location; 114 | if ( location.kind === 'redirect' ) { 115 | res.redirect(302, location.pathname + ( location.search ? `?${location.search}` : '' ) ); 116 | return; 117 | } 118 | 119 | // End sagas and wait until done 120 | store.dispatch(REDUX_SAGA_END); 121 | await rootSagaTask.done; 122 | 123 | let appString = null; 124 | try { 125 | const appInstance = renderApp(sheetsRegistry, store); 126 | appString = ReactDOM.renderToString( appInstance ); 127 | } 128 | catch ( err ) { 129 | console.log('ReactDOM.renderToString error'); // eslint-disable-line no-console 130 | console.log(err); // eslint-disable-line no-console 131 | next(err); 132 | return; 133 | } 134 | const initialState = store.getState(); 135 | 136 | const muiCss = sheetsRegistry.toString(); 137 | const chunkNames = flushChunkNames(); 138 | const flushed = flushChunks(clientStats, { chunkNames }); 139 | const { js, styles, cssHash } = flushed; 140 | 141 | const htmlString = createHtml({ 142 | js, 143 | styles, 144 | cssHash, 145 | appString, 146 | muiCss, 147 | initialState, 148 | }); 149 | res.send(htmlString); 150 | }; 151 | } 152 | 153 | export default createServerRenderMiddleware; 154 | 155 | -------------------------------------------------------------------------------- /src/server/server.js: -------------------------------------------------------------------------------- 1 | import 'fetch-everywhere'; 2 | import colors from 'colors/safe'; 3 | import express from 'express'; 4 | import cookieParser from 'cookie-parser'; 5 | import helmet from 'helmet'; 6 | import compression from 'compression'; 7 | import morgan from 'morgan'; 8 | 9 | import webpack from 'webpack'; 10 | import webpackDevMiddleware from 'webpack-dev-middleware'; 11 | import webpackHotMiddleware from 'webpack-hot-middleware'; 12 | import webpackHotServerMiddleware from 'webpack-hot-server-middleware'; 13 | import clientConfigFactory from '../../webpack/webpack.client.config'; 14 | import serverConfigFactory from '../../webpack/webpack.server.config'; 15 | import dotenv from 'dotenv'; 16 | import path from 'path'; 17 | import createProxy from 'server/apiProxy'; 18 | 19 | 20 | const DEV = process.env.NODE_ENV !== 'production'; 21 | 22 | function setupWebackDevMiddleware(app) { 23 | const clientConfig = clientConfigFactory('development'); 24 | const serverConfig = serverConfigFactory('development'); 25 | const multiCompiler = webpack([ clientConfig, serverConfig ]); 26 | 27 | const clientCompiler = multiCompiler.compilers[0]; 28 | app.use(webpackDevMiddleware(multiCompiler, { 29 | publicPath: clientConfig.output.publicPath, 30 | })); 31 | app.use(webpackHotMiddleware(clientCompiler)); 32 | app.use(webpackHotServerMiddleware(multiCompiler)); 33 | 34 | return new Promise((resolve /* ,reject */) => { 35 | multiCompiler.hooks.done.tap('done', resolve); 36 | }); 37 | } 38 | 39 | async function setupWebpack( app ) { 40 | const clientConfig = clientConfigFactory('development'); 41 | const publicPath = clientConfig.output.publicPath; 42 | const outputPath = clientConfig.output.path; 43 | if ( DEV ) { 44 | await setupWebackDevMiddleware(app); 45 | } 46 | else { 47 | const clientStats = require('../client/stats.json'); 48 | const serverRender = require('../server/main.js').default; 49 | 50 | app.use(publicPath, express.static(outputPath)); 51 | app.use(serverRender({ clientStats, outputPath })); 52 | } 53 | } 54 | 55 | function handleErrorMiddleware( err, req, res, next ) { 56 | // NOTE: Add additional handling for errors here 57 | console.log(err); // eslint-disable-line no-console 58 | // Pass to express' default error handler, which will return 59 | // `Internal Server Error` when `process.env.NODE_ENV === production` and 60 | // a stack trace otherwise 61 | next(err); 62 | } 63 | 64 | function handleUncaughtErrors() { 65 | process.on('uncaughtException', ( error ) => { 66 | // NOTE: Add additional handling for uncaught exceptions here 67 | console.log('uncaughtException'); // eslint-disable-line no-console 68 | console.log(error); // eslint-disable-line no-console 69 | process.exit(1); 70 | }); 71 | // NOTE: Treat promise rejections the same as an uncaught error, 72 | // as both can be invoked by a JS error 73 | process.on('unhandledRejection', ( error ) => { 74 | // NOTE: Add handling for uncaught rejections here 75 | console.log('unhandledRejection'); // eslint-disable-line no-console 76 | console.log(error); // eslint-disable-line no-console 77 | process.exit(1); 78 | }); 79 | } 80 | 81 | function startServer( app ) { 82 | return new Promise((resolve, reject) => { 83 | app.listen(process.env.SERVER_PORT, (err) => { 84 | if ( err ) { 85 | console.log(err); // eslint-disable-line no-console 86 | reject(err); 87 | } 88 | handleUncaughtErrors(); 89 | console.log(colors.black.bold('⚫⚫')); // eslint-disable-line no-console 90 | console.log(colors.black.bold(`⚫⚫ Web server listening on port ${process.env.SERVER_PORT}...`)); // eslint-disable-line no-console 91 | console.log(colors.black.bold('⚫⚫\n')); // eslint-disable-line no-console 92 | }); 93 | }); 94 | } 95 | 96 | function loadEnv() { 97 | dotenv.config({ 98 | path: path.resolve(__dirname, '../../.env'), 99 | }); 100 | 101 | if ( !process.env.SERVER_PORT ) { 102 | console.log('SERVER_PORT not set in .env file, defaulting to 3000'); // eslint-disable-line no-console 103 | process.env.SERVER_PORT = 3000; 104 | } 105 | 106 | if ( !process.env.API_URL ) { 107 | console.log('API_URL not set in .env file'); // eslint-disable-line no-console 108 | } 109 | } 110 | 111 | async function pingApi() { 112 | // Ping API Server 113 | const response = await fetch( process.env.API_URL ); 114 | if ( response && response.ok ) { 115 | console.log(colors.black.bold('⚫⚫')); // eslint-disable-line no-console 116 | console.log(colors.black.bold(`⚫⚫ Connected to API server at ${process.env.API_URL}`)); // eslint-disable-line no-console 117 | console.log(colors.black.bold('⚫⚫\n')); // eslint-disable-line no-console 118 | } 119 | else { 120 | throw new Error(`Cannot ping API server at ${process.env.API_URL}. Status: ${response.status}`); 121 | } 122 | } 123 | 124 | async function bootstrap() { 125 | let offlineMode = false; 126 | loadEnv(); 127 | 128 | try { 129 | await pingApi(); 130 | } 131 | catch ( error ) { 132 | console.log(colors.red.bold('🔴🔴')); // eslint-disable-line no-console 133 | console.log(colors.red.bold('🔴🔴 API not configured, proceeding with offline mode')); // eslint-disable-line no-console 134 | console.log(colors.red.bold('🔴🔴\n')); // eslint-disable-line no-console 135 | offlineMode = true; 136 | } 137 | 138 | const app = express(); 139 | 140 | // middleware 141 | app.use( express.static('public') ); 142 | app.all('/favicon.*', (req, res) => { 143 | res.status(404).end(); 144 | }); 145 | app.use(morgan('[:date[iso]] :method :url :status :response-time ms - :res[content-length]')); 146 | app.use(helmet.noSniff()); 147 | app.use(helmet.ieNoOpen()); 148 | app.use(helmet.hidePoweredBy()); 149 | app.use(compression()); 150 | app.use(cookieParser()); 151 | 152 | app.use(handleErrorMiddleware); 153 | 154 | // Send dummy JSON response if offline 155 | if ( offlineMode ) { 156 | app.all('/api/*', (req, res) => res.send({})); 157 | } 158 | // Proxy to API 159 | app.all('/api/*', createProxy( process.env.API_URL )); 160 | 161 | await setupWebpack(app); 162 | await startServer(app); 163 | } 164 | 165 | bootstrap() 166 | .catch((error) => { 167 | console.log(error); // eslint-disable-line no-console 168 | }); 169 | 170 | -------------------------------------------------------------------------------- /webpack/webpack.client.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Webpack setup based on 3 | * 4 | * survivejs 5 | * https://github.com/survivejs-demos/webpack-demo 6 | * 7 | * react-universal component 8 | * https://github.com/faceyspacey/redux-first-router-demo 9 | * 10 | */ 11 | 12 | const path = require('path'); 13 | const webpack = require('webpack'); 14 | const webpackMerge = require('webpack-merge'); 15 | const cssnano = require('cssnano'); 16 | const WriteFilePlugin = require('write-file-webpack-plugin'); 17 | // const AutoDllPlugin = require('autodll-webpack4-plugin'); 18 | const StatsPlugin = require('stats-webpack-plugin'); 19 | const TimeFixPlugin = require('time-fix-plugin'); 20 | const UglifyWebpackPlugin = require('uglifyjs-webpack-plugin'); 21 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); 22 | const parts = require('./webpack.parts'); 23 | 24 | 25 | const PATHS = { 26 | src: path.resolve(__dirname, '..', 'src'), 27 | clientEntry: path.resolve(__dirname, '..', 'src', 'client.js'), 28 | clientBuild: path.resolve(__dirname, '..', 'build', 'client'), 29 | node_modules: path.resolve(__dirname, '..', 'node_modules'), 30 | }; 31 | 32 | const commonConfig = webpackMerge([ 33 | { 34 | // Avoid `mode` option, let's explicitly opt in to all of webpack's settings 35 | // using the defaults that `mode` would set for development and production. 36 | mode: 'none', 37 | // 'client' name required by webpack-hot-server-middleware, see 38 | // https://github.com/60frames/webpack-hot-server-middleware#usage 39 | name: 'client', 40 | target: 'web', 41 | bail: true, 42 | output: { 43 | path: PATHS.clientBuild, 44 | publicPath: '/static/', 45 | }, 46 | optimization: { 47 | removeAvailableModules: true, 48 | removeEmptyChunks: true, 49 | mergeDuplicateChunks: true, 50 | providedExports: true, 51 | // This config mimics the behavior of webpack 3 w/universal 52 | splitChunks: { 53 | chunks: 'initial', 54 | cacheGroups: { 55 | default: false, 56 | vendors: { 57 | test: /[\\/]node_modules[\\/]/, 58 | name: 'vendor', 59 | }, 60 | }, 61 | }, 62 | // This config mimics the behavior of webpack 3 w/universal 63 | runtimeChunk: { 64 | name: 'bootstrap', 65 | }, 66 | }, 67 | resolve: { 68 | modules: [ 69 | PATHS.node_modules, 70 | PATHS.src, 71 | ], 72 | }, 73 | }, 74 | parts.loadFonts({ 75 | options: { 76 | name: '[name].[hash:8].[ext]', 77 | }, 78 | }), 79 | ]); 80 | 81 | const developmentConfig = webpackMerge([ 82 | { 83 | cache: true, 84 | entry: [ 85 | 'babel-polyfill', 86 | 'fetch-everywhere', 87 | 'webpack-hot-middleware/client?path=/__webpack_hmr&timeout=20000&reload=false&quiet=false&noInfo=false', 88 | 'react-hot-loader/patch', 89 | PATHS.clientEntry, 90 | ], 91 | output: { 92 | filename: '[name].js', 93 | chunkFilename: '[name].js', 94 | pathinfo: true, 95 | }, 96 | optimization: { 97 | namedModules: true, 98 | namedChunks: true, 99 | }, 100 | plugins: [ 101 | new WriteFilePlugin(), 102 | new TimeFixPlugin(), 103 | new webpack.HotModuleReplacementPlugin(), 104 | new webpack.DefinePlugin({ 105 | 'process.env': { 106 | NODE_ENV: JSON.stringify('development'), 107 | }, 108 | __SERVER__: 'false', 109 | __CLIENT__: 'true', 110 | __TEST__: 'false', 111 | }), 112 | ], 113 | }, 114 | parts.loadStyles(), 115 | parts.loadJavascript({ 116 | include: PATHS.src, 117 | cacheDirectory: false, 118 | }), 119 | parts.loadImages(), 120 | ]); 121 | 122 | const productionConfig = webpackMerge([ 123 | { 124 | devtool: 'source-map', 125 | entry: [ 126 | 'babel-polyfill', 127 | 'fetch-everywhere', 128 | PATHS.clientEntry, 129 | ], 130 | output: { 131 | filename: '[name].[chunkhash].js', 132 | chunkFilename: '[name].[chunkhash].js', 133 | }, 134 | optimization: { 135 | flagIncludedChunks: true, 136 | occurrenceOrder: true, 137 | usedExports: true, 138 | sideEffects: true, 139 | concatenateModules: true, 140 | noEmitOnErrors: true, 141 | minimizer: [ 142 | new UglifyWebpackPlugin({ 143 | sourceMap: true, 144 | }), 145 | ], 146 | }, 147 | performance: { 148 | hints: 'warning', 149 | }, 150 | plugins: [ 151 | new StatsPlugin('stats.json'), 152 | new webpack.DefinePlugin({ 153 | 'process.env': { 154 | NODE_ENV: JSON.stringify('production'), 155 | }, 156 | __SERVER__: 'false', 157 | __CLIENT__: 'true', 158 | __TEST__: 'false', 159 | }), 160 | new webpack.HashedModuleIdsPlugin(), 161 | new OptimizeCSSAssetsPlugin({ 162 | cssProcessor: cssnano, 163 | cssProcessorOptions: { 164 | discardComments: { 165 | removeAll: true, 166 | // Run cssnano in safe mode to avoid potentially unsafe transformations. 167 | safe: true, 168 | }, 169 | }, 170 | canPrint: false, 171 | }), 172 | ], 173 | recordsPath: path.join(__dirname, 'records.json' ), 174 | }, 175 | parts.extractCSS({ 176 | cssModules: true, 177 | }), 178 | parts.loadJavascript({ 179 | include: PATHS.src, 180 | cacheDirectory: false, 181 | }), 182 | parts.loadImages({ 183 | options: { 184 | limit: 15000, 185 | name: '[name].[hash:8].[ext]', 186 | }, 187 | }), 188 | ]); 189 | 190 | module.exports = ( env ) => { 191 | if ( env === 'production' ) { 192 | return webpackMerge( commonConfig, productionConfig ); 193 | } 194 | return webpackMerge( commonConfig, developmentConfig ); 195 | }; 196 | -------------------------------------------------------------------------------- /webpack/webpack.parts.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Webpack parts based on survivejs book and demo repo 3 | * 4 | * survivejs 5 | * https://github.com/survivejs-demos/webpack-demo 6 | * 7 | */ 8 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 9 | 10 | 11 | exports.loadJavascript = ({ include, exclude, cacheDirectory } = {}) => ({ 12 | module: { 13 | rules: [ 14 | { 15 | test: /\.js$/, 16 | include, 17 | exclude, 18 | 19 | use: { 20 | loader: 'babel-loader', 21 | options: { 22 | babelrc: true, 23 | // Enable caching for improved performance during 24 | // development. 25 | // It uses default OS directory by default. If you need 26 | // something more custom, pass a path to it. 27 | // I.e., { cacheDirectory: '' } 28 | cacheDirectory, 29 | }, 30 | }, 31 | }, 32 | ], 33 | }, 34 | }); 35 | 36 | exports.serverRenderCSS = ({ include, exclude, cssModules } = {}) => ({ 37 | module: { 38 | rules: [ 39 | { 40 | test: /\.scss$/, 41 | include, 42 | exclude, 43 | use: [ 44 | { 45 | loader: 'css-loader/locals', 46 | options: { 47 | modules: cssModules, 48 | localIdentName: '[name]__[local]--[hash:base64:5]', 49 | }, 50 | }, 51 | { 52 | loader: 'postcss-loader', 53 | options: { 54 | plugins: () => ([ 55 | require('autoprefixer')({ 56 | browsers: [ 57 | '>1%', 58 | 'last 4 versions', 59 | 'Firefox ESR', 60 | 'not ie < 9', // React doesn't support IE8 anyway 61 | ], 62 | flexbox: 'no-2009', 63 | }), 64 | ]), 65 | }, 66 | }, 67 | { 68 | loader: 'fast-sass-loader', 69 | }, 70 | ], 71 | }, 72 | ], 73 | }, 74 | }); 75 | 76 | exports.loadStyles = ({ include, exclude, cssModules } = {}) => ({ 77 | module: { 78 | rules: [ 79 | { 80 | test: /\.scss$/, 81 | use: [ 82 | { 83 | loader: 'style-loader', 84 | }, 85 | { 86 | loader: 'css-loader', 87 | options: { 88 | modules: true, 89 | localIdentName: '[name]__[local]--[hash:base64:5]', 90 | }, 91 | }, 92 | { 93 | loader: 'fast-sass-loader', 94 | }, 95 | ], 96 | }, 97 | ], 98 | }, 99 | }); 100 | 101 | exports.extractCSS = ({ include, exclude, cssModules } = {}) => ({ 102 | module: { 103 | rules: [ 104 | { 105 | test: /\.scss$/, 106 | include, 107 | exclude, 108 | use: [ 109 | { 110 | loader: MiniCssExtractPlugin.loader, 111 | }, 112 | { 113 | loader: 'css-loader', 114 | options: { 115 | modules: cssModules, 116 | localIdentName: '[name]__[local]--[hash:base64:5]', 117 | }, 118 | }, 119 | { 120 | loader: 'postcss-loader', 121 | options: { 122 | plugins: () => ([ 123 | require('autoprefixer')({ 124 | browsers: [ 125 | '>1%', 126 | 'last 4 versions', 127 | 'Firefox ESR', 128 | 'not ie < 9', // React doesn't support IE8 anyway 129 | ], 130 | flexbox: 'no-2009', 131 | }), 132 | ]), 133 | }, 134 | }, 135 | { 136 | loader: 'fast-sass-loader', 137 | }, 138 | ], 139 | }, 140 | ], 141 | }, 142 | plugins: [ 143 | new MiniCssExtractPlugin({ 144 | filename: '[name].[chunkhash].css', 145 | chunkFilename: '[name].[chunkhash].css', 146 | }), 147 | ], 148 | }); 149 | 150 | exports.loadFonts = ({ include, exclude, options } = {}) => ({ 151 | module: { 152 | rules: [ 153 | { 154 | // Capture eot, ttf, woff, and woff2 155 | test: /\.(eot|ttf|woff|woff2)(\?v=\d+\.\d+\.\d+)?$/, 156 | include, 157 | exclude, 158 | 159 | use: { 160 | loader: 'file-loader', 161 | options, 162 | }, 163 | }, 164 | ], 165 | }, 166 | }); 167 | 168 | exports.loadImages = ({ include, exclude, options } = {}) => ({ 169 | module: { 170 | rules: [ 171 | { 172 | test: /\.(png|jpg|svg)$/, 173 | include, 174 | exclude, 175 | 176 | use: { 177 | loader: 'url-loader', 178 | options, 179 | }, 180 | }, 181 | ], 182 | }, 183 | }); 184 | 185 | -------------------------------------------------------------------------------- /webpack/webpack.server.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Webpack setup based on 3 | * 4 | * survivejs 5 | * https://github.com/survivejs-demos/webpack-demo 6 | * 7 | * react-universal-component 8 | * https://github.com/faceyspacey/redux-first-router-demo 9 | * 10 | * backpack 11 | * https://github.com/jaredpalmer/backpack 12 | * 13 | */ 14 | 15 | const fs = require('fs'); 16 | const path = require('path'); 17 | const webpack = require('webpack'); 18 | const webpackMerge = require('webpack-merge'); 19 | const parts = require('./webpack.parts'); 20 | 21 | 22 | const PATHS = { 23 | src: path.resolve(__dirname, '..', 'src'), 24 | serverEntry: path.resolve(__dirname, '..', 'src', 'server', 'render.js'), 25 | serverBuild: path.resolve(__dirname, '..', 'build', 'server'), 26 | node_modules: path.resolve(__dirname, '..', 'node_modules'), 27 | }; 28 | 29 | // if you're specifying externals to leave unbundled, you need to tell Webpack 30 | // to still bundle `react-universal-component`, `webpack-flush-chunks` and 31 | // `require-universal-module` so that they know they are running 32 | // within Webpack and can properly make connections to client modules: 33 | const whitelist = [ 34 | '\\.bin', 35 | 'react-universal-component', 36 | 'require-universal-module', 37 | 'webpack-flush-chunks', 38 | ]; 39 | const whiteListRE = new RegExp(whitelist.join('|')); 40 | const externals = fs 41 | .readdirSync(PATHS.node_modules) 42 | .filter((x) => !whiteListRE.test(x)) 43 | .reduce((_externals, mod) => { 44 | _externals[mod] = `commonjs ${mod}`; 45 | return _externals; 46 | }, {}); 47 | 48 | const commonConfig = webpackMerge([ 49 | { 50 | // 'server' name required by webpack-hot-server-middleware, see 51 | // https://github.com/60frames/webpack-hot-server-middleware#usage 52 | name: 'server', 53 | target: 'node', 54 | mode: 'none', 55 | bail: true, 56 | entry: [ 57 | 'babel-polyfill', 58 | 'fetch-everywhere', 59 | PATHS.serverEntry, 60 | ], 61 | externals, 62 | output: { 63 | path: PATHS.serverBuild, 64 | filename: '[name].js', 65 | libraryTarget: 'commonjs2', 66 | }, 67 | resolve: { 68 | modules: [ 69 | PATHS.node_modules, 70 | PATHS.src, 71 | ], 72 | }, 73 | plugins: [ 74 | new webpack.optimize.LimitChunkCountPlugin({ 75 | maxChunks: 1, 76 | }), 77 | ], 78 | }, 79 | parts.loadJavascript({ 80 | include: PATHS.src, 81 | cacheDirectory: false, 82 | }), 83 | parts.serverRenderCSS({ 84 | cssModules: true, 85 | exclude: /node_modules/, 86 | }), 87 | ]); 88 | 89 | const developmentConfig = webpackMerge([ 90 | { 91 | devtool: 'eval', 92 | output: { 93 | publicPath: '/static/', 94 | }, 95 | plugins: [ 96 | new webpack.NamedModulesPlugin(), 97 | new webpack.DefinePlugin({ 98 | 'process.env': { 99 | NODE_ENV: JSON.stringify('development'), 100 | }, 101 | __SERVER__: 'true', 102 | __CLIENT__: 'false', 103 | __TEST__: 'false', 104 | }), 105 | ], 106 | }, 107 | ]); 108 | 109 | const productionConfig = webpackMerge([ 110 | { 111 | devtool: 'source-map', 112 | plugins: [ 113 | new webpack.DefinePlugin({ 114 | 'process.env': { 115 | NODE_ENV: JSON.stringify('production'), 116 | }, 117 | __SERVER__: 'true', 118 | __CLIENT__: 'false', 119 | __TEST__: 'false', 120 | }), 121 | ], 122 | }, 123 | ]); 124 | 125 | 126 | module.exports = ( env ) => { 127 | if ( env === 'production' ) { 128 | return webpackMerge( commonConfig, productionConfig ); 129 | } 130 | return webpackMerge( commonConfig, developmentConfig ); 131 | }; 132 | --------------------------------------------------------------------------------