From b36cf3eb2c1ec0cd0c45cd4a448c956f71695c87 Mon Sep 17 00:00:00 2001 From: Sepehr Hosseini Date: Fri, 31 Jan 2020 19:40:15 +0400 Subject: [PATCH] client: init project --- client/.editorconfig | 14 + client/.eslintrc.js | 89 + client/.gitattributes | 107 + client/.gitignore | 10 + client/.nvmrc | 1 + client/.prettierignore | 7 + client/.prettierrc | 8 + client/.stylelintrc | 7 + client/.travis.yml | 25 + client/app/.htaccess | 52 + client/app/.nginx.conf | 112 + client/app/app.js | 83 + client/app/configureStore.js | 59 + client/app/containers/App/constants.js | 10 + client/app/containers/App/index.js | 28 + client/app/containers/App/selectors.js | 11 + .../tests/__snapshots__/index.test.js.snap | 17 + client/app/containers/App/tests/index.test.js | 14 + .../containers/App/tests/selectors.test.js | 13 + client/app/containers/HomePage/Loadable.js | 7 + client/app/containers/HomePage/index.js | 18 + client/app/containers/HomePage/messages.js | 15 + .../tests/__snapshots__/index.test.js.snap | 9 + .../containers/HomePage/tests/index.test.js | 18 + .../containers/LanguageProvider/actions.js | 14 + .../containers/LanguageProvider/constants.js | 7 + .../app/containers/LanguageProvider/index.js | 51 + .../containers/LanguageProvider/reducer.js | 25 + .../containers/LanguageProvider/selectors.js | 19 + .../LanguageProvider/tests/actions.test.js | 15 + .../LanguageProvider/tests/index.test.js | 49 + .../LanguageProvider/tests/reducer.test.js | 22 + .../LanguageProvider/tests/selectors.test.js | 11 + .../app/containers/NotFoundPage/Loadable.js | 7 + client/app/containers/NotFoundPage/index.js | 19 + .../app/containers/NotFoundPage/messages.js | 15 + .../tests/__snapshots__/index.test.js.snap | 9 + .../NotFoundPage/tests/index.test.js | 18 + client/app/global-styles.js | 31 + client/app/i18n.js | 46 + client/app/images/favicon.ico | Bin 0 -> 370070 bytes client/app/images/icon-512x512.png | Bin 0 -> 16733 bytes client/app/index.html | 29 + client/app/reducers.js | 22 + client/app/tests/i18n.test.js | 28 + client/app/tests/store.test.js | 43 + client/app/translations/en.json | 1 + client/app/utils/checkStore.js | 21 + client/app/utils/constants.js | 3 + client/app/utils/history.js | 3 + client/app/utils/injectReducer.js | 45 + client/app/utils/injectSaga.js | 61 + client/app/utils/loadable.js | 13 + client/app/utils/reducerInjectors.js | 34 + client/app/utils/sagaInjectors.js | 91 + client/app/utils/tests/checkStore.test.js | 33 + client/app/utils/tests/injectReducer.test.js | 98 + client/app/utils/tests/injectSaga.test.js | 149 + .../app/utils/tests/reducerInjectors.test.js | 100 + client/app/utils/tests/sagaInjectors.test.js | 231 + client/appveyor.yml | 45 + client/babel.config.js | 33 + client/docs/README.md | 128 + client/docs/css/README.md | 283 + client/docs/css/linting.md | 11 + client/docs/css/remove.md | 27 + client/docs/css/sanitize.md | 17 + client/docs/forks/README.md | 25 + client/docs/general/README.md | 133 + client/docs/general/commands.md | 149 + client/docs/general/components.md | 84 + client/docs/general/debugging.md | 77 + client/docs/general/deployment.md | 121 + client/docs/general/editor.md | 40 + client/docs/general/faq.md | 260 + client/docs/general/files.md | 34 + client/docs/general/gotchas.md | 114 + client/docs/general/introduction.md | 219 + client/docs/general/remove.md | 49 + client/docs/general/server-configs.md | 36 + client/docs/general/webstorm-debug.png | Bin 0 -> 453421 bytes client/docs/general/webstorm-eslint.png | Bin 0 -> 114499 bytes client/docs/general/workflow.png | Bin 0 -> 72522 bytes client/docs/js/README.md | 38 + client/docs/js/async-components.md | 28 + client/docs/js/i18n.md | 108 + client/docs/js/immer.md | 50 + client/docs/js/redux-saga.md | 77 + client/docs/js/redux.md | 79 + client/docs/js/remove.md | 83 + client/docs/js/reselect.md | 72 + client/docs/js/routing.md | 60 + client/docs/maintenance/dependency.md | 269 + client/docs/testing/README.md | 28 + client/docs/testing/component-testing.md | 193 + client/docs/testing/remote-testing.md | 12 + client/docs/testing/unit-testing.md | 356 + .../internals/generators/component/index.js | 92 + .../generators/component/index.js.hbs | 36 + .../generators/component/loadable.js.hbs | 9 + .../generators/component/messages.js.hbs | 16 + .../generators/component/test.js.hbs | 61 + .../generators/container/actions.js.hbs | 13 + .../generators/container/actions.test.js.hbs | 13 + .../generators/container/constants.js.hbs | 7 + .../internals/generators/container/index.js | 177 + .../generators/container/index.js.hbs | 90 + .../generators/container/messages.js.hbs | 16 + .../generators/container/reducer.js.hbs | 20 + .../generators/container/reducer.test.js.hbs | 32 + .../generators/container/saga.js.hbs | 6 + .../generators/container/saga.test.js.hbs | 15 + .../generators/container/selectors.js.hbs | 22 + .../container/selectors.test.js.hbs | 7 + .../generators/container/test.js.hbs | 62 + client/internals/generators/index.js | 75 + .../generators/language/add-locale-data.hbs | 1 + .../generators/language/app-locale.hbs | 1 + .../language/format-translation-messages.hbs | 1 + client/internals/generators/language/index.js | 111 + .../generators/language/intl-locale-data.hbs | 1 + .../language/polyfill-intl-locale.hbs | 1 + .../language/translation-messages.hbs | 1 + .../generators/language/translations-json.hbs | 1 + .../generators/utils/componentExists.js | 21 + client/internals/mocks/cssModule.js | 1 + client/internals/mocks/image.js | 1 + client/internals/scripts/analyze.js | 27 + client/internals/scripts/clean.js | 63 + client/internals/scripts/extract-intl.js | 164 + .../scripts/generate-templates-for-linting.js | 385 + client/internals/scripts/helpers/checkmark.js | 11 + .../scripts/helpers/get-npm-config.js | 3 + client/internals/scripts/helpers/progress.js | 19 + client/internals/scripts/helpers/xmark.js | 11 + client/internals/scripts/npmcheckversion.js | 8 + client/internals/testing/test-bundler.js | 3 + .../internals/webpack/webpack.base.babel.js | 126 + client/internals/webpack/webpack.dev.babel.js | 52 + .../internals/webpack/webpack.prod.babel.js | 150 + client/jest.config.js | 31 + client/package-lock.json | 17181 ++++++++++++++++ client/package.json | 170 +- client/server/argv.js | 1 + client/server/index.js | 56 + client/server/logger.js | 38 + .../server/middlewares/addDevMiddlewares.js | 38 + .../server/middlewares/addProdMiddlewares.js | 18 + .../server/middlewares/frontendMiddleware.js | 19 + client/server/port.js | 3 + 150 files changed, 24915 insertions(+), 7 deletions(-) create mode 100644 client/.editorconfig create mode 100644 client/.eslintrc.js create mode 100644 client/.gitattributes create mode 100644 client/.gitignore create mode 100644 client/.nvmrc create mode 100644 client/.prettierignore create mode 100644 client/.prettierrc create mode 100644 client/.stylelintrc create mode 100644 client/.travis.yml create mode 100644 client/app/.htaccess create mode 100644 client/app/.nginx.conf create mode 100644 client/app/app.js create mode 100644 client/app/configureStore.js create mode 100644 client/app/containers/App/constants.js create mode 100644 client/app/containers/App/index.js create mode 100644 client/app/containers/App/selectors.js create mode 100644 client/app/containers/App/tests/__snapshots__/index.test.js.snap create mode 100644 client/app/containers/App/tests/index.test.js create mode 100644 client/app/containers/App/tests/selectors.test.js create mode 100644 client/app/containers/HomePage/Loadable.js create mode 100644 client/app/containers/HomePage/index.js create mode 100644 client/app/containers/HomePage/messages.js create mode 100644 client/app/containers/HomePage/tests/__snapshots__/index.test.js.snap create mode 100644 client/app/containers/HomePage/tests/index.test.js create mode 100644 client/app/containers/LanguageProvider/actions.js create mode 100644 client/app/containers/LanguageProvider/constants.js create mode 100644 client/app/containers/LanguageProvider/index.js create mode 100644 client/app/containers/LanguageProvider/reducer.js create mode 100644 client/app/containers/LanguageProvider/selectors.js create mode 100644 client/app/containers/LanguageProvider/tests/actions.test.js create mode 100644 client/app/containers/LanguageProvider/tests/index.test.js create mode 100644 client/app/containers/LanguageProvider/tests/reducer.test.js create mode 100644 client/app/containers/LanguageProvider/tests/selectors.test.js create mode 100644 client/app/containers/NotFoundPage/Loadable.js create mode 100644 client/app/containers/NotFoundPage/index.js create mode 100644 client/app/containers/NotFoundPage/messages.js create mode 100644 client/app/containers/NotFoundPage/tests/__snapshots__/index.test.js.snap create mode 100644 client/app/containers/NotFoundPage/tests/index.test.js create mode 100644 client/app/global-styles.js create mode 100644 client/app/i18n.js create mode 100644 client/app/images/favicon.ico create mode 100755 client/app/images/icon-512x512.png create mode 100644 client/app/index.html create mode 100644 client/app/reducers.js create mode 100644 client/app/tests/i18n.test.js create mode 100644 client/app/tests/store.test.js create mode 100644 client/app/translations/en.json create mode 100644 client/app/utils/checkStore.js create mode 100644 client/app/utils/constants.js create mode 100644 client/app/utils/history.js create mode 100644 client/app/utils/injectReducer.js create mode 100644 client/app/utils/injectSaga.js create mode 100644 client/app/utils/loadable.js create mode 100644 client/app/utils/reducerInjectors.js create mode 100644 client/app/utils/sagaInjectors.js create mode 100644 client/app/utils/tests/checkStore.test.js create mode 100644 client/app/utils/tests/injectReducer.test.js create mode 100644 client/app/utils/tests/injectSaga.test.js create mode 100644 client/app/utils/tests/reducerInjectors.test.js create mode 100644 client/app/utils/tests/sagaInjectors.test.js create mode 100644 client/appveyor.yml create mode 100644 client/babel.config.js create mode 100644 client/docs/README.md create mode 100644 client/docs/css/README.md create mode 100644 client/docs/css/linting.md create mode 100644 client/docs/css/remove.md create mode 100644 client/docs/css/sanitize.md create mode 100644 client/docs/forks/README.md create mode 100644 client/docs/general/README.md create mode 100644 client/docs/general/commands.md create mode 100644 client/docs/general/components.md create mode 100644 client/docs/general/debugging.md create mode 100644 client/docs/general/deployment.md create mode 100644 client/docs/general/editor.md create mode 100644 client/docs/general/faq.md create mode 100644 client/docs/general/files.md create mode 100644 client/docs/general/gotchas.md create mode 100644 client/docs/general/introduction.md create mode 100644 client/docs/general/remove.md create mode 100644 client/docs/general/server-configs.md create mode 100644 client/docs/general/webstorm-debug.png create mode 100644 client/docs/general/webstorm-eslint.png create mode 100644 client/docs/general/workflow.png create mode 100644 client/docs/js/README.md create mode 100644 client/docs/js/async-components.md create mode 100644 client/docs/js/i18n.md create mode 100644 client/docs/js/immer.md create mode 100644 client/docs/js/redux-saga.md create mode 100644 client/docs/js/redux.md create mode 100644 client/docs/js/remove.md create mode 100644 client/docs/js/reselect.md create mode 100644 client/docs/js/routing.md create mode 100644 client/docs/maintenance/dependency.md create mode 100644 client/docs/testing/README.md create mode 100644 client/docs/testing/component-testing.md create mode 100644 client/docs/testing/remote-testing.md create mode 100644 client/docs/testing/unit-testing.md create mode 100644 client/internals/generators/component/index.js create mode 100644 client/internals/generators/component/index.js.hbs create mode 100644 client/internals/generators/component/loadable.js.hbs create mode 100644 client/internals/generators/component/messages.js.hbs create mode 100644 client/internals/generators/component/test.js.hbs create mode 100644 client/internals/generators/container/actions.js.hbs create mode 100644 client/internals/generators/container/actions.test.js.hbs create mode 100644 client/internals/generators/container/constants.js.hbs create mode 100644 client/internals/generators/container/index.js create mode 100644 client/internals/generators/container/index.js.hbs create mode 100644 client/internals/generators/container/messages.js.hbs create mode 100644 client/internals/generators/container/reducer.js.hbs create mode 100644 client/internals/generators/container/reducer.test.js.hbs create mode 100644 client/internals/generators/container/saga.js.hbs create mode 100644 client/internals/generators/container/saga.test.js.hbs create mode 100644 client/internals/generators/container/selectors.js.hbs create mode 100644 client/internals/generators/container/selectors.test.js.hbs create mode 100644 client/internals/generators/container/test.js.hbs create mode 100644 client/internals/generators/index.js create mode 100644 client/internals/generators/language/add-locale-data.hbs create mode 100644 client/internals/generators/language/app-locale.hbs create mode 100644 client/internals/generators/language/format-translation-messages.hbs create mode 100644 client/internals/generators/language/index.js create mode 100644 client/internals/generators/language/intl-locale-data.hbs create mode 100644 client/internals/generators/language/polyfill-intl-locale.hbs create mode 100644 client/internals/generators/language/translation-messages.hbs create mode 100644 client/internals/generators/language/translations-json.hbs create mode 100644 client/internals/generators/utils/componentExists.js create mode 100644 client/internals/mocks/cssModule.js create mode 100644 client/internals/mocks/image.js create mode 100644 client/internals/scripts/analyze.js create mode 100644 client/internals/scripts/clean.js create mode 100644 client/internals/scripts/extract-intl.js create mode 100644 client/internals/scripts/generate-templates-for-linting.js create mode 100644 client/internals/scripts/helpers/checkmark.js create mode 100644 client/internals/scripts/helpers/get-npm-config.js create mode 100644 client/internals/scripts/helpers/progress.js create mode 100644 client/internals/scripts/helpers/xmark.js create mode 100644 client/internals/scripts/npmcheckversion.js create mode 100644 client/internals/testing/test-bundler.js create mode 100644 client/internals/webpack/webpack.base.babel.js create mode 100644 client/internals/webpack/webpack.dev.babel.js create mode 100644 client/internals/webpack/webpack.prod.babel.js create mode 100644 client/jest.config.js create mode 100644 client/package-lock.json create mode 100644 client/server/argv.js create mode 100644 client/server/index.js create mode 100644 client/server/logger.js create mode 100644 client/server/middlewares/addDevMiddlewares.js create mode 100644 client/server/middlewares/addProdMiddlewares.js create mode 100644 client/server/middlewares/frontendMiddleware.js create mode 100644 client/server/port.js diff --git a/client/.editorconfig b/client/.editorconfig new file mode 100644 index 0000000..5370727 --- /dev/null +++ b/client/.editorconfig @@ -0,0 +1,14 @@ +# editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/client/.eslintrc.js b/client/.eslintrc.js new file mode 100644 index 0000000..0ee1945 --- /dev/null +++ b/client/.eslintrc.js @@ -0,0 +1,89 @@ +const fs = require('fs'); +const path = require('path'); + +const prettierOptions = JSON.parse( + fs.readFileSync(path.resolve(__dirname, '.prettierrc'), 'utf8'), +); + +module.exports = { + parser: 'babel-eslint', + extends: ['airbnb', 'prettier', 'prettier/react'], + plugins: ['prettier', 'redux-saga', 'react', 'react-hooks', 'jsx-a11y'], + env: { + jest: true, + browser: true, + node: true, + es6: true, + }, + parserOptions: { + ecmaVersion: 6, + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, + }, + rules: { + 'prettier/prettier': ['error', prettierOptions], + 'arrow-body-style': [2, 'as-needed'], + 'class-methods-use-this': 0, + 'import/imports-first': 0, + 'import/newline-after-import': 0, + 'import/no-dynamic-require': 0, + 'import/no-extraneous-dependencies': 0, + 'import/no-named-as-default': 0, + 'import/no-unresolved': 2, + 'import/no-webpack-loader-syntax': 0, + 'import/prefer-default-export': 0, + indent: [ + 2, + 2, + { + SwitchCase: 1, + }, + ], + 'jsx-a11y/aria-props': 2, + 'jsx-a11y/heading-has-content': 0, + 'jsx-a11y/label-has-associated-control': [ + 2, + { + // NOTE: If this error triggers, either disable it or add + // your custom components, labels and attributes via these options + // See https://github.com/evcohen/eslint-plugin-jsx-a11y/blob/master/docs/rules/label-has-associated-control.md + controlComponents: ['Input'], + }, + ], + 'jsx-a11y/label-has-for': 0, + 'jsx-a11y/mouse-events-have-key-events': 2, + 'jsx-a11y/role-has-required-aria-props': 2, + 'jsx-a11y/role-supports-aria-props': 2, + 'max-len': 0, + 'newline-per-chained-call': 0, + 'no-confusing-arrow': 0, + 'no-console': 1, + 'no-unused-vars': 2, + 'no-use-before-define': 0, + 'prefer-template': 2, + 'react/destructuring-assignment': 0, + 'react-hooks/rules-of-hooks': 'error', + 'react/jsx-closing-tag-location': 0, + 'react/forbid-prop-types': 0, + 'react/jsx-first-prop-new-line': [2, 'multiline'], + 'react/jsx-filename-extension': 0, + 'react/jsx-no-target-blank': 0, + 'react/jsx-uses-vars': 2, + 'react/require-default-props': 0, + 'react/require-extension': 0, + 'react/self-closing-comp': 0, + 'react/sort-comp': 0, + 'redux-saga/no-yield-in-race': 2, + 'redux-saga/yield-effects': 2, + 'require-yield': 0, + }, + settings: { + 'import/resolver': { + webpack: { + config: './internals/webpack/webpack.prod.babel.js', + }, + }, + }, +}; diff --git a/client/.gitattributes b/client/.gitattributes new file mode 100644 index 0000000..c917234 --- /dev/null +++ b/client/.gitattributes @@ -0,0 +1,107 @@ +# From https://github.com/Danimoth/gitattributes/blob/master/Web.gitattributes + +# Handle line endings automatically for files detected as text +# and leave all files detected as binary untouched. +* text=auto + +# +# The above will handle all files NOT found below +# + +# +## These files are text and should be normalized (Convert crlf => lf) +# + +# source code +*.php text +*.css text +*.sass text +*.scss text +*.less text +*.styl text +*.js text eol=lf +*.coffee text +*.json text +*.htm text +*.html text +*.xml text +*.svg text +*.txt text +*.ini text +*.inc text +*.pl text +*.rb text +*.py text +*.scm text +*.sql text +*.sh text +*.bat text + +# templates +*.ejs text +*.hbt text +*.jade text +*.haml text +*.hbs text +*.dot text +*.tmpl text +*.phtml text + +# server config +.htaccess text +.nginx.conf text + +# git config +.gitattributes text +.gitignore text +.gitconfig text + +# code analysis config +.jshintrc text +.jscsrc text +.jshintignore text +.csslintrc text + +# misc config +*.yaml text +*.yml text +.editorconfig text + +# build config +*.npmignore text +*.bowerrc text + +# Heroku +Procfile text +.slugignore text + +# Documentation +*.md text +LICENSE text +AUTHORS text + + +# +## These files are binary and should be left untouched +# + +# (binary is a macro for -text -diff) +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.mov binary +*.mp4 binary +*.mp3 binary +*.flv binary +*.fla binary +*.swf binary +*.gz binary +*.zip binary +*.7z binary +*.ttf binary +*.eot binary +*.woff binary +*.pyc binary +*.pdf binary diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 0000000..5d06c05 --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,10 @@ +# Don't check auto-generated stuff into git +coverage +build +node_modules +stats.json + +# Cruft +.DS_Store +npm-debug.log +.idea diff --git a/client/.nvmrc b/client/.nvmrc new file mode 100644 index 0000000..0312896 --- /dev/null +++ b/client/.nvmrc @@ -0,0 +1 @@ +lts/dubnium diff --git a/client/.prettierignore b/client/.prettierignore new file mode 100644 index 0000000..d71a79b --- /dev/null +++ b/client/.prettierignore @@ -0,0 +1,7 @@ +build/ +node_modules/ +internals/generators/ +internals/scripts/ +package-lock.json +yarn.lock +package.json diff --git a/client/.prettierrc b/client/.prettierrc new file mode 100644 index 0000000..0b0eae1 --- /dev/null +++ b/client/.prettierrc @@ -0,0 +1,8 @@ +{ + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": true, + "trailingComma": "all" +} diff --git a/client/.stylelintrc b/client/.stylelintrc new file mode 100644 index 0000000..9e72e47 --- /dev/null +++ b/client/.stylelintrc @@ -0,0 +1,7 @@ +{ + "processors": ["stylelint-processor-styled-components"], + "extends": [ + "stylelint-config-recommended", + "stylelint-config-styled-components" + ] +} diff --git a/client/.travis.yml b/client/.travis.yml new file mode 100644 index 0000000..44220cd --- /dev/null +++ b/client/.travis.yml @@ -0,0 +1,25 @@ +language: node_js + +node_js: + - 'node' + - 'lts/*' + +script: + - node ./internals/scripts/generate-templates-for-linting + - npm test -- --maxWorkers=4 + - npm run build + +before_install: + - export CHROME_BIN=chromium-browser + - export DISPLAY=:99.0 + - sh -e /etc/init.d/xvfb start + +notifications: + email: + on_failure: change + +after_success: 'npm run coveralls' + +cache: + directories: + - node_modules diff --git a/client/app/.htaccess b/client/app/.htaccess new file mode 100644 index 0000000..40252ae --- /dev/null +++ b/client/app/.htaccess @@ -0,0 +1,52 @@ + + + + ####################################################################### + # GENERAL # + ####################################################################### + + # Make apache follow sym links to files + Options +FollowSymLinks + # If somebody opens a folder, hide all files from the resulting folder list + IndexIgnore */* + + + ####################################################################### + # REWRITING # + ####################################################################### + + # Enable rewriting + RewriteEngine On + + # If its not HTTPS + RewriteCond %{HTTPS} off + + # Comment out the RewriteCond above, and uncomment the RewriteCond below if you're using a load balancer (e.g. CloudFlare) for SSL + # RewriteCond %{HTTP:X-Forwarded-Proto} !https + + # Redirect to the same URL with https://, ignoring all further rules if this one is in effect + RewriteRule ^(.*) https://%{HTTP_HOST}/$1 [R,L] + + # If we get to here, it means we are on https:// + + # If the file with the specified name in the browser doesn't exist + RewriteCond %{REQUEST_FILENAME} !-f + + # and the directory with the specified name in the browser doesn't exist + RewriteCond %{REQUEST_FILENAME} !-d + + # and we are not opening the root already (otherwise we get a redirect loop) + RewriteCond %{REQUEST_FILENAME} !\/$ + + # Rewrite all requests to the root + RewriteRule ^(.*) / + + + + + # Do not cache sw.js, required for offline-first updates. + + Header set Cache-Control "private, no-cache, no-store, proxy-revalidate, no-transform" + Header set Pragma "no-cache" + + diff --git a/client/app/.nginx.conf b/client/app/.nginx.conf new file mode 100644 index 0000000..6a71831 --- /dev/null +++ b/client/app/.nginx.conf @@ -0,0 +1,112 @@ +## +# Put this file in /etc/nginx/conf.d folder and make sure +# you have a line 'include /etc/nginx/conf.d/*.conf;' +# in your main nginx configuration file +## + +## +# Redirect to the same URL with https:// +## + +server { + + listen 80; + +# Type your domain name below + server_name example.com; + + return 301 https://$server_name$request_uri; + +} + +## +# HTTPS configurations +## + +server { + + listen 443 ssl; + +# Type your domain name below + server_name example.com; + +# Configure the Certificate and Key you got from your CA (e.g. Lets Encrypt) + ssl_certificate /path/to/certificate.crt; + ssl_certificate_key /path/to/server.key; + + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:50m; + ssl_session_tickets off; + +# Only use TLS v1.2 as Transport Security Protocol + ssl_protocols TLSv1.2; + +# Only use ciphersuites that are considered modern and secure by Mozilla + ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256'; + +# Do not let attackers downgrade the ciphersuites in Client Hello +# Always use server-side offered ciphersuites + ssl_prefer_server_ciphers on; + +# HSTS (ngx_http_headers_module is required) (15768000 seconds = 6 months) + add_header Strict-Transport-Security max-age=15768000; + +# Diffie-Hellman parameter for DHE ciphersuites, recommended 2048 bits +# Uncomment if you want to use your own Diffie-Hellman parameter, which can be generated with: openssl ecparam -genkey -out dhparam.pem -name prime256v1 +# See https://wiki.mozilla.org/Security/Server_Side_TLS#DHE_handshake_and_dhparam +# ssl_dhparam /path/to/dhparam.pem; + + +## OCSP Configuration START +# If you want to provide OCSP Stapling, you can uncomment the following lines +# See https://www.digitalocean.com/community/tutorials/how-to-configure-ocsp-stapling-on-apache-and-nginx for more infos about OCSP and its use case +# fetch OCSP records from URL in ssl_certificate and cache them + +#ssl_stapling on; +#ssl_stapling_verify on; + +# verify chain of trust of OCSP response using Root CA and Intermediate certs (you will get this file from your CA) +#ssl_trusted_certificate /path/to/root_CA_cert_plus_intermediates; + +## OCSP Configuration END + +# To let nginx use its own DNS Resolver +# resolver ; + + +# Always serve index.html for any request + location / { + # Set path + root /var/www/; + try_files $uri /index.html; + } + +# Do not cache sw.js, required for offline-first updates. + location /sw.js { + add_header Cache-Control "no-cache"; + proxy_cache_bypass $http_pragma; + proxy_cache_revalidate on; + expires off; + access_log off; + } + +## +# If you want to use Node/Rails/etc. API server +# on the same port (443) config Nginx as a reverse proxy. +# For security reasons use a firewall like ufw in Ubuntu +# and deny port 3000/tcp. +## + +# location /api/ { +# +# proxy_pass http://localhost:3000; +# proxy_http_version 1.1; +# proxy_set_header X-Forwarded-Proto https; +# proxy_set_header Upgrade $http_upgrade; +# proxy_set_header Connection 'upgrade'; +# proxy_set_header Host $host; +# proxy_cache_bypass $http_upgrade; +# +# } + +} diff --git a/client/app/app.js b/client/app/app.js new file mode 100644 index 0000000..99fe5e9 --- /dev/null +++ b/client/app/app.js @@ -0,0 +1,83 @@ +/** + * app.js + * + * This is the entry file for the application, only setup and boilerplate + * code. + */ + +// Needed for redux-saga es6 generator support +import '@babel/polyfill'; + +// Import all the third party stuff +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; +import { ConnectedRouter } from 'connected-react-router'; +import history from 'utils/history'; +import 'sanitize.css/sanitize.css'; + +// Import root app +import App from 'containers/App'; + +// Import Language Provider +import LanguageProvider from 'containers/LanguageProvider'; + +// Load the favicon and the .htaccess file +/* eslint-disable import/no-unresolved, import/extensions */ +import '!file-loader?name=[name].[ext]!./images/favicon.ico'; +import 'file-loader?name=.htaccess!./.htaccess'; +/* eslint-enable import/no-unresolved, import/extensions */ + +import configureStore from './configureStore'; + +// Import i18n messages +import { translationMessages } from './i18n'; + +// Create redux store with history +const initialState = {}; +const store = configureStore(initialState, history); +const MOUNT_NODE = document.getElementById('app'); + +const render = messages => { + ReactDOM.render( + + + + + + + , + MOUNT_NODE, + ); +}; + +if (module.hot) { + // Hot reloadable React components and translation json files + // modules.hot.accept does not accept dynamic dependencies, + // have to be constants at compile-time + module.hot.accept(['./i18n', 'containers/App'], () => { + ReactDOM.unmountComponentAtNode(MOUNT_NODE); + render(translationMessages); + }); +} + +// Chunked polyfill for browsers without Intl support +if (!window.Intl) { + new Promise(resolve => { + resolve(import('intl')); + }) + .then(() => Promise.all([import('intl/locale-data/jsonp/en.js')])) + .then(() => render(translationMessages)) + .catch(err => { + throw err; + }); +} else { + render(translationMessages); +} + +// Install ServiceWorker and AppCache in the end since +// it's not most important operation and if main code fails, +// we do not want it installed +if (process.env.NODE_ENV === 'production') { + require('offline-plugin/runtime').install(); // eslint-disable-line global-require +} diff --git a/client/app/configureStore.js b/client/app/configureStore.js new file mode 100644 index 0000000..6018694 --- /dev/null +++ b/client/app/configureStore.js @@ -0,0 +1,59 @@ +/** + * Create the store with dynamic reducers + */ + +import { createStore, applyMiddleware, compose } from 'redux'; +import { routerMiddleware } from 'connected-react-router'; +import createSagaMiddleware from 'redux-saga'; +import createReducer from './reducers'; + +export default function configureStore(initialState = {}, history) { + let composeEnhancers = compose; + const reduxSagaMonitorOptions = {}; + + // If Redux Dev Tools and Saga Dev Tools Extensions are installed, enable them + /* istanbul ignore next */ + if (process.env.NODE_ENV !== 'production' && typeof window === 'object') { + /* eslint-disable no-underscore-dangle */ + if (window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) + composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({}); + + // NOTE: Uncomment the code below to restore support for Redux Saga + // Dev Tools once it supports redux-saga version 1.x.x + // if (window.__SAGA_MONITOR_EXTENSION__) + // reduxSagaMonitorOptions = { + // sagaMonitor: window.__SAGA_MONITOR_EXTENSION__, + // }; + /* eslint-enable */ + } + + const sagaMiddleware = createSagaMiddleware(reduxSagaMonitorOptions); + + // Create the store with two middlewares + // 1. sagaMiddleware: Makes redux-sagas work + // 2. routerMiddleware: Syncs the location/URL path to the state + const middlewares = [sagaMiddleware, routerMiddleware(history)]; + + const enhancers = [applyMiddleware(...middlewares)]; + + const store = createStore( + createReducer(), + initialState, + composeEnhancers(...enhancers), + ); + + // Extensions + store.runSaga = sagaMiddleware.run; + store.injectedReducers = {}; // Reducer registry + store.injectedSagas = {}; // Saga registry + + // Make reducers hot reloadable, see http://mxs.is/googmo + /* istanbul ignore next */ + if (module.hot) { + module.hot.accept('./reducers', () => { + store.replaceReducer(createReducer(store.injectedReducers)); + }); + } + + return store; +} diff --git a/client/app/containers/App/constants.js b/client/app/containers/App/constants.js new file mode 100644 index 0000000..9b74bc2 --- /dev/null +++ b/client/app/containers/App/constants.js @@ -0,0 +1,10 @@ +/* + * AppConstants + * Each action has a corresponding type, which the reducer knows and picks up on. + * To avoid weird typos between the reducer and the actions, we save them as + * constants here. We prefix them with 'yourproject/YourComponent' so we avoid + * reducers accidentally picking up actions they shouldn't. + * + * Follow this format: + * export const YOUR_ACTION_CONSTANT = 'yourproject/YourContainer/YOUR_ACTION_CONSTANT'; + */ diff --git a/client/app/containers/App/index.js b/client/app/containers/App/index.js new file mode 100644 index 0000000..be4af6e --- /dev/null +++ b/client/app/containers/App/index.js @@ -0,0 +1,28 @@ +/** + * + * App.js + * + * This component is the skeleton around the actual pages, and should only + * contain code that should be seen on all pages. (e.g. navigation bar) + * + */ + +import React from 'react'; +import { Switch, Route } from 'react-router-dom'; + +import HomePage from 'containers/HomePage/Loadable'; +import NotFoundPage from 'containers/NotFoundPage/Loadable'; + +import GlobalStyle from '../../global-styles'; + +export default function App() { + return ( +
+ + + + + +
+ ); +} diff --git a/client/app/containers/App/selectors.js b/client/app/containers/App/selectors.js new file mode 100644 index 0000000..9175e31 --- /dev/null +++ b/client/app/containers/App/selectors.js @@ -0,0 +1,11 @@ +import { createSelector } from 'reselect'; + +const selectRouter = state => state.router; + +const makeSelectLocation = () => + createSelector( + selectRouter, + routerState => routerState.location, + ); + +export { makeSelectLocation }; diff --git a/client/app/containers/App/tests/__snapshots__/index.test.js.snap b/client/app/containers/App/tests/__snapshots__/index.test.js.snap new file mode 100644 index 0000000..ddf9911 --- /dev/null +++ b/client/app/containers/App/tests/__snapshots__/index.test.js.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render and match the snapshot 1`] = ` +
+ + + + + +
+`; diff --git a/client/app/containers/App/tests/index.test.js b/client/app/containers/App/tests/index.test.js new file mode 100644 index 0000000..9a40c31 --- /dev/null +++ b/client/app/containers/App/tests/index.test.js @@ -0,0 +1,14 @@ +import React from 'react'; +import ShallowRenderer from 'react-test-renderer/shallow'; + +import App from '../index'; + +const renderer = new ShallowRenderer(); + +describe('', () => { + it('should render and match the snapshot', () => { + renderer.render(); + const renderedOutput = renderer.getRenderOutput(); + expect(renderedOutput).toMatchSnapshot(); + }); +}); diff --git a/client/app/containers/App/tests/selectors.test.js b/client/app/containers/App/tests/selectors.test.js new file mode 100644 index 0000000..005c1e8 --- /dev/null +++ b/client/app/containers/App/tests/selectors.test.js @@ -0,0 +1,13 @@ +import { makeSelectLocation } from 'containers/App/selectors'; + +describe('makeSelectLocation', () => { + it('should select the location', () => { + const router = { + location: { pathname: '/foo' }, + }; + const mockedState = { + router, + }; + expect(makeSelectLocation()(mockedState)).toEqual(router.location); + }); +}); diff --git a/client/app/containers/HomePage/Loadable.js b/client/app/containers/HomePage/Loadable.js new file mode 100644 index 0000000..a58c373 --- /dev/null +++ b/client/app/containers/HomePage/Loadable.js @@ -0,0 +1,7 @@ +/** + * Asynchronously loads the component for HomePage + */ + +import loadable from 'utils/loadable'; + +export default loadable(() => import('./index')); diff --git a/client/app/containers/HomePage/index.js b/client/app/containers/HomePage/index.js new file mode 100644 index 0000000..385236a --- /dev/null +++ b/client/app/containers/HomePage/index.js @@ -0,0 +1,18 @@ +/* + * HomePage + * + * This is the first thing users see of our App, at the '/' route + * + */ + +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import messages from './messages'; + +export default function HomePage() { + return ( +

+ +

+ ); +} diff --git a/client/app/containers/HomePage/messages.js b/client/app/containers/HomePage/messages.js new file mode 100644 index 0000000..a7f7f2b --- /dev/null +++ b/client/app/containers/HomePage/messages.js @@ -0,0 +1,15 @@ +/* + * HomePage Messages + * + * This contains all the text for the HomePage container. + */ +import { defineMessages } from 'react-intl'; + +export const scope = 'app.containers.HomePage'; + +export default defineMessages({ + header: { + id: `${scope}.header`, + defaultMessage: 'This is the HomePage container!', + }, +}); diff --git a/client/app/containers/HomePage/tests/__snapshots__/index.test.js.snap b/client/app/containers/HomePage/tests/__snapshots__/index.test.js.snap new file mode 100644 index 0000000..f7d9831 --- /dev/null +++ b/client/app/containers/HomePage/tests/__snapshots__/index.test.js.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render and match the snapshot 1`] = ` +

+ + This is the HomePage container! + +

+`; diff --git a/client/app/containers/HomePage/tests/index.test.js b/client/app/containers/HomePage/tests/index.test.js new file mode 100644 index 0000000..388e5ad --- /dev/null +++ b/client/app/containers/HomePage/tests/index.test.js @@ -0,0 +1,18 @@ +import React from 'react'; +import { render } from 'react-testing-library'; +import { IntlProvider } from 'react-intl'; + +import HomePage from '../index'; + +describe('', () => { + it('should render and match the snapshot', () => { + const { + container: { firstChild }, + } = render( + + + , + ); + expect(firstChild).toMatchSnapshot(); + }); +}); diff --git a/client/app/containers/LanguageProvider/actions.js b/client/app/containers/LanguageProvider/actions.js new file mode 100644 index 0000000..04bfb66 --- /dev/null +++ b/client/app/containers/LanguageProvider/actions.js @@ -0,0 +1,14 @@ +/* + * + * LanguageProvider actions + * + */ + +import { CHANGE_LOCALE } from './constants'; + +export function changeLocale(languageLocale) { + return { + type: CHANGE_LOCALE, + locale: languageLocale, + }; +} diff --git a/client/app/containers/LanguageProvider/constants.js b/client/app/containers/LanguageProvider/constants.js new file mode 100644 index 0000000..7d2f425 --- /dev/null +++ b/client/app/containers/LanguageProvider/constants.js @@ -0,0 +1,7 @@ +/* + * + * LanguageProvider constants + * + */ + +export const CHANGE_LOCALE = 'app/LanguageToggle/CHANGE_LOCALE'; diff --git a/client/app/containers/LanguageProvider/index.js b/client/app/containers/LanguageProvider/index.js new file mode 100644 index 0000000..57761e0 --- /dev/null +++ b/client/app/containers/LanguageProvider/index.js @@ -0,0 +1,51 @@ +/* + * + * LanguageProvider + * + * this component connects the redux state language locale to the + * IntlProvider component and i18n messages (loaded from `app/translations`) + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { IntlProvider } from 'react-intl'; + +import { makeSelectLocale } from './selectors'; + +export function LanguageProvider(props) { + return ( + + {React.Children.only(props.children)} + + ); +} + +LanguageProvider.propTypes = { + locale: PropTypes.string, + messages: PropTypes.object, + children: PropTypes.element.isRequired, +}; + +const mapStateToProps = createSelector( + makeSelectLocale(), + locale => ({ + locale, + }), +); + +function mapDispatchToProps(dispatch) { + return { + dispatch, + }; +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(LanguageProvider); diff --git a/client/app/containers/LanguageProvider/reducer.js b/client/app/containers/LanguageProvider/reducer.js new file mode 100644 index 0000000..c5e7acf --- /dev/null +++ b/client/app/containers/LanguageProvider/reducer.js @@ -0,0 +1,25 @@ +/* + * + * LanguageProvider reducer + * + */ +import produce from 'immer'; + +import { CHANGE_LOCALE } from './constants'; +import { DEFAULT_LOCALE } from '../../i18n'; + +export const initialState = { + locale: DEFAULT_LOCALE, +}; + +/* eslint-disable default-case, no-param-reassign */ +const languageProviderReducer = (state = initialState, action) => + produce(state, draft => { + switch (action.type) { + case CHANGE_LOCALE: + draft.locale = action.locale; + break; + } + }); + +export default languageProviderReducer; diff --git a/client/app/containers/LanguageProvider/selectors.js b/client/app/containers/LanguageProvider/selectors.js new file mode 100644 index 0000000..a0a7a05 --- /dev/null +++ b/client/app/containers/LanguageProvider/selectors.js @@ -0,0 +1,19 @@ +import { createSelector } from 'reselect'; +import { initialState } from './reducer'; + +/** + * Direct selector to the languageToggle state domain + */ +const selectLanguage = state => state.language || initialState; + +/** + * Select the language locale + */ + +const makeSelectLocale = () => + createSelector( + selectLanguage, + languageState => languageState.locale, + ); + +export { selectLanguage, makeSelectLocale }; diff --git a/client/app/containers/LanguageProvider/tests/actions.test.js b/client/app/containers/LanguageProvider/tests/actions.test.js new file mode 100644 index 0000000..a57c8cc --- /dev/null +++ b/client/app/containers/LanguageProvider/tests/actions.test.js @@ -0,0 +1,15 @@ +import { changeLocale } from '../actions'; + +import { CHANGE_LOCALE } from '../constants'; + +describe('LanguageProvider actions', () => { + describe('Change Local Action', () => { + it('has a type of CHANGE_LOCALE', () => { + const expected = { + type: CHANGE_LOCALE, + locale: 'de', + }; + expect(changeLocale('de')).toEqual(expected); + }); + }); +}); diff --git a/client/app/containers/LanguageProvider/tests/index.test.js b/client/app/containers/LanguageProvider/tests/index.test.js new file mode 100644 index 0000000..bf7eac2 --- /dev/null +++ b/client/app/containers/LanguageProvider/tests/index.test.js @@ -0,0 +1,49 @@ +import React from 'react'; +import { render } from 'react-testing-library'; +import { FormattedMessage, defineMessages } from 'react-intl'; +import { Provider } from 'react-redux'; +import { browserHistory } from 'react-router-dom'; + +import ConnectedLanguageProvider, { LanguageProvider } from '../index'; +import configureStore from '../../../configureStore'; + +import { translationMessages } from '../../../i18n'; + +const messages = defineMessages({ + someMessage: { + id: 'some.id', + defaultMessage: 'This is some default message', + en: 'This is some en message', + }, +}); + +describe('', () => { + it('should render its children', () => { + const children =

Test

; + const { container } = render( + + {children} + , + ); + expect(container.firstChild).not.toBeNull(); + }); +}); + +describe('', () => { + let store; + + beforeAll(() => { + store = configureStore({}, browserHistory); + }); + + it('should render the default language messages', () => { + const { queryByText } = render( + + + + + , + ); + expect(queryByText(messages.someMessage.defaultMessage)).not.toBeNull(); + }); +}); diff --git a/client/app/containers/LanguageProvider/tests/reducer.test.js b/client/app/containers/LanguageProvider/tests/reducer.test.js new file mode 100644 index 0000000..8015bd2 --- /dev/null +++ b/client/app/containers/LanguageProvider/tests/reducer.test.js @@ -0,0 +1,22 @@ +import languageProviderReducer from '../reducer'; +import { CHANGE_LOCALE } from '../constants'; + +/* eslint-disable default-case, no-param-reassign */ +describe('languageProviderReducer', () => { + it('returns the initial state', () => { + expect(languageProviderReducer(undefined, {})).toEqual({ + locale: 'en', + }); + }); + + it('changes the locale', () => { + expect( + languageProviderReducer(undefined, { + type: CHANGE_LOCALE, + locale: 'de', + }), + ).toEqual({ + locale: 'de', + }); + }); +}); diff --git a/client/app/containers/LanguageProvider/tests/selectors.test.js b/client/app/containers/LanguageProvider/tests/selectors.test.js new file mode 100644 index 0000000..0befdb2 --- /dev/null +++ b/client/app/containers/LanguageProvider/tests/selectors.test.js @@ -0,0 +1,11 @@ +import { selectLanguage } from '../selectors'; + +describe('selectLanguage', () => { + it('should select the global state', () => { + const globalState = {}; + const mockedState = { + language: globalState, + }; + expect(selectLanguage(mockedState)).toEqual(globalState); + }); +}); diff --git a/client/app/containers/NotFoundPage/Loadable.js b/client/app/containers/NotFoundPage/Loadable.js new file mode 100644 index 0000000..7a55904 --- /dev/null +++ b/client/app/containers/NotFoundPage/Loadable.js @@ -0,0 +1,7 @@ +/** + * Asynchronously loads the component for NotFoundPage + */ + +import loadable from 'utils/loadable'; + +export default loadable(() => import('./index')); diff --git a/client/app/containers/NotFoundPage/index.js b/client/app/containers/NotFoundPage/index.js new file mode 100644 index 0000000..d8c9804 --- /dev/null +++ b/client/app/containers/NotFoundPage/index.js @@ -0,0 +1,19 @@ +/** + * NotFoundPage + * + * This is the page we show when the user visits a url that doesn't have a route + * + */ + +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +import messages from './messages'; + +export default function NotFound() { + return ( +

+ +

+ ); +} diff --git a/client/app/containers/NotFoundPage/messages.js b/client/app/containers/NotFoundPage/messages.js new file mode 100644 index 0000000..370e66d --- /dev/null +++ b/client/app/containers/NotFoundPage/messages.js @@ -0,0 +1,15 @@ +/* + * NotFoundPage Messages + * + * This contains all the text for the NotFoundPage container. + */ +import { defineMessages } from 'react-intl'; + +export const scope = 'app.containers.NotFoundPage'; + +export default defineMessages({ + header: { + id: `${scope}.header`, + defaultMessage: 'This is the NotFoundPage container!', + }, +}); diff --git a/client/app/containers/NotFoundPage/tests/__snapshots__/index.test.js.snap b/client/app/containers/NotFoundPage/tests/__snapshots__/index.test.js.snap new file mode 100644 index 0000000..c1ed4a7 --- /dev/null +++ b/client/app/containers/NotFoundPage/tests/__snapshots__/index.test.js.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render and match the snapshot 1`] = ` +

+ + This is the NotFoundPage container! + +

+`; diff --git a/client/app/containers/NotFoundPage/tests/index.test.js b/client/app/containers/NotFoundPage/tests/index.test.js new file mode 100644 index 0000000..cd4dbf3 --- /dev/null +++ b/client/app/containers/NotFoundPage/tests/index.test.js @@ -0,0 +1,18 @@ +import React from 'react'; +import { render } from 'react-testing-library'; +import { IntlProvider } from 'react-intl'; + +import NotFoundPage from '../index'; + +describe('', () => { + it('should render and match the snapshot', () => { + const { + container: { firstChild }, + } = render( + + + , + ); + expect(firstChild).toMatchSnapshot(); + }); +}); diff --git a/client/app/global-styles.js b/client/app/global-styles.js new file mode 100644 index 0000000..4762008 --- /dev/null +++ b/client/app/global-styles.js @@ -0,0 +1,31 @@ +import { createGlobalStyle } from 'styled-components'; + +const GlobalStyle = createGlobalStyle` + html, + body { + height: 100%; + width: 100%; + } + + body { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + } + + body.fontLoaded { + font-family: 'Open Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; + } + + #app { + background-color: #fafafa; + min-height: 100%; + min-width: 100%; + } + + p, + label { + font-family: Georgia, Times, 'Times New Roman', serif; + line-height: 1.5em; + } +`; + +export default GlobalStyle; diff --git a/client/app/i18n.js b/client/app/i18n.js new file mode 100644 index 0000000..cac434f --- /dev/null +++ b/client/app/i18n.js @@ -0,0 +1,46 @@ +/** + * i18n.js + * + * This will setup the i18n language files and locale data for your app. + * + * IMPORTANT: This file is used by the internal build + * script `extract-intl`, and must use CommonJS module syntax + * You CANNOT use import/export in this file. + */ +const addLocaleData = require('react-intl').addLocaleData; //eslint-disable-line +const enLocaleData = require('react-intl/locale-data/en'); + +const enTranslationMessages = require('./translations/en.json'); + +addLocaleData(enLocaleData); + +const DEFAULT_LOCALE = 'en'; + +// prettier-ignore +const appLocales = [ + 'en', +]; + +const formatTranslationMessages = (locale, messages) => { + const defaultFormattedMessages = + locale !== DEFAULT_LOCALE + ? formatTranslationMessages(DEFAULT_LOCALE, enTranslationMessages) + : {}; + const flattenFormattedMessages = (formattedMessages, key) => { + const formattedMessage = + !messages[key] && locale !== DEFAULT_LOCALE + ? defaultFormattedMessages[key] + : messages[key]; + return Object.assign(formattedMessages, { [key]: formattedMessage }); + }; + return Object.keys(messages).reduce(flattenFormattedMessages, {}); +}; + +const translationMessages = { + en: formatTranslationMessages('en', enTranslationMessages), +}; + +exports.appLocales = appLocales; +exports.formatTranslationMessages = formatTranslationMessages; +exports.translationMessages = translationMessages; +exports.DEFAULT_LOCALE = DEFAULT_LOCALE; diff --git a/client/app/images/favicon.ico b/client/app/images/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a041392a60464f84a94ede82cc7ba582aad1dab9 GIT binary patch literal 370070 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

xKI0dJ49+gLJrL`?^Q_h51EN zw#31yv@N@J_0f{bMR%d0(D!e}CvECGRI5lIdEim;|1wxv#GEpzr*K`bn1YTq7XOKD zl{w#+ue}1RL1E*5yDic7bk?U#>=uZ2vyMc#&q|a<~AAOcOE++L#UxVN! zXt&`q|KFOVq3mmTV_xo+zn1JYx7rR$|F%ZiGMp&wqbvFSH{d?-GH;}xmc3|R>~+kb zzp3D4f`%3?Wu=Ozxt^!bYK!zY6O_GYlP0gX>w&XhmR)MfLP?PgQ zbTOH==$E7?KvjO@|j8MZSS`VJyg{va-9KmF3UVW8X@-9VJ`Te zEsnmwwyr|?l<)tI_??V_DtJWiOMs6#&VM@17HhOWa5-M5boh4 zFbs6A)|F0t)g$hUzN*&$ee^9kl`W&^^s%^1-KVupzglPee$FAUJVzM6Eg71bKDfv;Ltp`z`Z+^ZcXqy%1vDD^STA2RqmZ-u9S1ByCT7 zmqYBIG)NbLuxB{>jJNjizhUllhI9lZW!7ugx8TEjj7MhMfDhl$-1f&ZV_90;^t%uA znmu%)_dPLm7(2NLKbjW|l1?WsjQe;1pW3_E7(WMSoc{|v1U_uHlP-$B#~x7B)i-|9 z?A~^t{&Ws{f_ILJ<|VDYv-IipIk`HNfqYTTn|G~?O)RD_#n!Ir?Xs@E`SqnMoq5VZ-wODA z+9jR7KVC3&H16`i2Y!O3Dncr zt+K*Gpf5O#yqr`wDriO17cj z|Lm-XWfAFl(0O@$y}d5JFuCa$mG7a#oAw>GXITAp&V7>o4?F8Qs{@%;}-idbY$y^4T@tK+Uv~k@` zzWiYt&>sI$@C?*>k5#nY#&@@oW_ss^@-u+Q9WM+#e!U+{oH~mE~ERME9WiSD-nI`04|SWus`s|LcrtIoJmzkGG&o zxU<1Jhf(-;42sRLGAlN{# zfnWo{27(O)8wfTKY#`V`uz_F$!3Kg21RDr85NsgWK(K*e1HlG@4Fnq)w>I$q!e5X; zkU)??kU)??kU)??kU)??kU)??kU)??kU)??kU)??kU)??kU)??kU)??kU)??kU)?? OkU)??kU-B%;Qs>w0eJ-g literal 0 HcmV?d00001 diff --git a/client/app/images/icon-512x512.png b/client/app/images/icon-512x512.png new file mode 100755 index 0000000000000000000000000000000000000000..15419d2234f8b3fdd662656839e54630d5b1ccc6 GIT binary patch literal 16733 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 literal 0 HcmV?d00001 diff --git a/client/app/index.html b/client/app/index.html new file mode 100644 index 0000000..88dca69 --- /dev/null +++ b/client/app/index.html @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + React.js Boilerplate + + + + + + + +

+ + + + diff --git a/client/app/reducers.js b/client/app/reducers.js new file mode 100644 index 0000000..7ba4220 --- /dev/null +++ b/client/app/reducers.js @@ -0,0 +1,22 @@ +/** + * Combine all reducers in this file and export the combined reducers. + */ + +import { combineReducers } from 'redux'; +import { connectRouter } from 'connected-react-router'; + +import history from 'utils/history'; +import languageProviderReducer from 'containers/LanguageProvider/reducer'; + +/** + * Merges the main reducer with the router state and dynamically injected reducers + */ +export default function createReducer(injectedReducers = {}) { + const rootReducer = combineReducers({ + language: languageProviderReducer, + router: connectRouter(history), + ...injectedReducers, + }); + + return rootReducer; +} diff --git a/client/app/tests/i18n.test.js b/client/app/tests/i18n.test.js new file mode 100644 index 0000000..6a4ff28 --- /dev/null +++ b/client/app/tests/i18n.test.js @@ -0,0 +1,28 @@ +import { formatTranslationMessages } from '../i18n'; + +jest.mock('../translations/en.json', () => ({ + message1: 'default message', + message2: 'default message 2', +})); + +const esTranslationMessages = { + message1: 'mensaje predeterminado', + message2: '', +}; + +describe('formatTranslationMessages', () => { + it('should build only defaults when DEFAULT_LOCALE', () => { + const result = formatTranslationMessages('en', { a: 'a' }); + + expect(result).toEqual({ a: 'a' }); + }); + + it('should combine default locale and current locale when not DEFAULT_LOCALE', () => { + const result = formatTranslationMessages('', esTranslationMessages); + + expect(result).toEqual({ + message1: 'mensaje predeterminado', + message2: 'default message 2', + }); + }); +}); diff --git a/client/app/tests/store.test.js b/client/app/tests/store.test.js new file mode 100644 index 0000000..cf3a682 --- /dev/null +++ b/client/app/tests/store.test.js @@ -0,0 +1,43 @@ +/** + * Test store addons + */ + +import { browserHistory } from 'react-router-dom'; +import configureStore from '../configureStore'; + +describe('configureStore', () => { + let store; + + beforeAll(() => { + store = configureStore({}, browserHistory); + }); + + describe('injectedReducers', () => { + it('should contain an object for reducers', () => { + expect(typeof store.injectedReducers).toBe('object'); + }); + }); + + describe('injectedSagas', () => { + it('should contain an object for sagas', () => { + expect(typeof store.injectedSagas).toBe('object'); + }); + }); + + describe('runSaga', () => { + it('should contain a hook for `sagaMiddleware.run`', () => { + expect(typeof store.runSaga).toBe('function'); + }); + }); +}); + +describe('configureStore params', () => { + it('should call window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__', () => { + /* eslint-disable no-underscore-dangle */ + const compose = jest.fn(); + window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ = () => compose; + configureStore(undefined, browserHistory); + expect(compose).toHaveBeenCalled(); + /* eslint-enable */ + }); +}); diff --git a/client/app/translations/en.json b/client/app/translations/en.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/client/app/translations/en.json @@ -0,0 +1 @@ +[] diff --git a/client/app/utils/checkStore.js b/client/app/utils/checkStore.js new file mode 100644 index 0000000..610ea0f --- /dev/null +++ b/client/app/utils/checkStore.js @@ -0,0 +1,21 @@ +import { conformsTo, isFunction, isObject } from 'lodash'; +import invariant from 'invariant'; + +/** + * Validate the shape of redux store + */ +export default function checkStore(store) { + const shape = { + dispatch: isFunction, + subscribe: isFunction, + getState: isFunction, + replaceReducer: isFunction, + runSaga: isFunction, + injectedReducers: isObject, + injectedSagas: isObject, + }; + invariant( + conformsTo(store, shape), + '(app/utils...) injectors: Expected a valid redux store', + ); +} diff --git a/client/app/utils/constants.js b/client/app/utils/constants.js new file mode 100644 index 0000000..97ece0f --- /dev/null +++ b/client/app/utils/constants.js @@ -0,0 +1,3 @@ +export const RESTART_ON_REMOUNT = '@@saga-injector/restart-on-remount'; +export const DAEMON = '@@saga-injector/daemon'; +export const ONCE_TILL_UNMOUNT = '@@saga-injector/once-till-unmount'; diff --git a/client/app/utils/history.js b/client/app/utils/history.js new file mode 100644 index 0000000..ee3abb7 --- /dev/null +++ b/client/app/utils/history.js @@ -0,0 +1,3 @@ +import { createBrowserHistory } from 'history'; +const history = createBrowserHistory(); +export default history; diff --git a/client/app/utils/injectReducer.js b/client/app/utils/injectReducer.js new file mode 100644 index 0000000..65c34c7 --- /dev/null +++ b/client/app/utils/injectReducer.js @@ -0,0 +1,45 @@ +import React from 'react'; +import hoistNonReactStatics from 'hoist-non-react-statics'; +import { ReactReduxContext } from 'react-redux'; + +import getInjectors from './reducerInjectors'; + +/** + * Dynamically injects a reducer + * + * @param {string} key A key of the reducer + * @param {function} reducer A reducer that will be injected + * + */ +export default ({ key, reducer }) => WrappedComponent => { + class ReducerInjector extends React.Component { + static WrappedComponent = WrappedComponent; + + static contextType = ReactReduxContext; + + static displayName = `withReducer(${WrappedComponent.displayName || + WrappedComponent.name || + 'Component'})`; + + constructor(props, context) { + super(props, context); + + getInjectors(context.store).injectReducer(key, reducer); + } + + render() { + return ; + } + } + + return hoistNonReactStatics(ReducerInjector, WrappedComponent); +}; + +const useInjectReducer = ({ key, reducer }) => { + const context = React.useContext(ReactReduxContext); + React.useEffect(() => { + getInjectors(context.store).injectReducer(key, reducer); + }, []); +}; + +export { useInjectReducer }; diff --git a/client/app/utils/injectSaga.js b/client/app/utils/injectSaga.js new file mode 100644 index 0000000..ea6e9c0 --- /dev/null +++ b/client/app/utils/injectSaga.js @@ -0,0 +1,61 @@ +import React from 'react'; +import hoistNonReactStatics from 'hoist-non-react-statics'; +import { ReactReduxContext } from 'react-redux'; + +import getInjectors from './sagaInjectors'; + +/** + * Dynamically injects a saga, passes component's props as saga arguments + * + * @param {string} key A key of the saga + * @param {function} saga A root saga that will be injected + * @param {string} [mode] By default (constants.DAEMON) the saga will be started + * on component mount and never canceled or started again. Another two options: + * - constants.RESTART_ON_REMOUNT — the saga will be started on component mount and + * cancelled with `task.cancel()` on component unmount for improved performance, + * - constants.ONCE_TILL_UNMOUNT — behaves like 'RESTART_ON_REMOUNT' but never runs it again. + * + */ +export default ({ key, saga, mode }) => WrappedComponent => { + class InjectSaga extends React.Component { + static WrappedComponent = WrappedComponent; + + static contextType = ReactReduxContext; + + static displayName = `withSaga(${WrappedComponent.displayName || + WrappedComponent.name || + 'Component'})`; + + constructor(props, context) { + super(props, context); + + this.injectors = getInjectors(context.store); + + this.injectors.injectSaga(key, { saga, mode }, this.props); + } + + componentWillUnmount() { + this.injectors.ejectSaga(key); + } + + render() { + return ; + } + } + + return hoistNonReactStatics(InjectSaga, WrappedComponent); +}; + +const useInjectSaga = ({ key, saga, mode }) => { + const context = React.useContext(ReactReduxContext); + React.useEffect(() => { + const injectors = getInjectors(context.store); + injectors.injectSaga(key, { saga, mode }); + + return () => { + injectors.ejectSaga(key); + }; + }, []); +}; + +export { useInjectSaga }; diff --git a/client/app/utils/loadable.js b/client/app/utils/loadable.js new file mode 100644 index 0000000..08eedba --- /dev/null +++ b/client/app/utils/loadable.js @@ -0,0 +1,13 @@ +import React, { lazy, Suspense } from 'react'; + +const loadable = (importFunc, { fallback = null } = { fallback: null }) => { + const LazyComponent = lazy(importFunc); + + return props => ( + + + + ); +}; + +export default loadable; diff --git a/client/app/utils/reducerInjectors.js b/client/app/utils/reducerInjectors.js new file mode 100644 index 0000000..a929a6b --- /dev/null +++ b/client/app/utils/reducerInjectors.js @@ -0,0 +1,34 @@ +import invariant from 'invariant'; +import { isEmpty, isFunction, isString } from 'lodash'; + +import checkStore from './checkStore'; +import createReducer from '../reducers'; + +export function injectReducerFactory(store, isValid) { + return function injectReducer(key, reducer) { + if (!isValid) checkStore(store); + + invariant( + isString(key) && !isEmpty(key) && isFunction(reducer), + '(app/utils...) injectReducer: Expected `reducer` to be a reducer function', + ); + + // Check `store.injectedReducers[key] === reducer` for hot reloading when a key is the same but a reducer is different + if ( + Reflect.has(store.injectedReducers, key) && + store.injectedReducers[key] === reducer + ) + return; + + store.injectedReducers[key] = reducer; // eslint-disable-line no-param-reassign + store.replaceReducer(createReducer(store.injectedReducers)); + }; +} + +export default function getInjectors(store) { + checkStore(store); + + return { + injectReducer: injectReducerFactory(store, true), + }; +} diff --git a/client/app/utils/sagaInjectors.js b/client/app/utils/sagaInjectors.js new file mode 100644 index 0000000..15f4f10 --- /dev/null +++ b/client/app/utils/sagaInjectors.js @@ -0,0 +1,91 @@ +import invariant from 'invariant'; +import { isEmpty, isFunction, isString, conformsTo } from 'lodash'; + +import checkStore from './checkStore'; +import { DAEMON, ONCE_TILL_UNMOUNT, RESTART_ON_REMOUNT } from './constants'; + +const allowedModes = [RESTART_ON_REMOUNT, DAEMON, ONCE_TILL_UNMOUNT]; + +const checkKey = key => + invariant( + isString(key) && !isEmpty(key), + '(app/utils...) injectSaga: Expected `key` to be a non empty string', + ); + +const checkDescriptor = descriptor => { + const shape = { + saga: isFunction, + mode: mode => isString(mode) && allowedModes.includes(mode), + }; + invariant( + conformsTo(descriptor, shape), + '(app/utils...) injectSaga: Expected a valid saga descriptor', + ); +}; + +export function injectSagaFactory(store, isValid) { + return function injectSaga(key, descriptor = {}, args) { + if (!isValid) checkStore(store); + + const newDescriptor = { + ...descriptor, + mode: descriptor.mode || DAEMON, + }; + const { saga, mode } = newDescriptor; + + checkKey(key); + checkDescriptor(newDescriptor); + + let hasSaga = Reflect.has(store.injectedSagas, key); + + if (process.env.NODE_ENV !== 'production') { + const oldDescriptor = store.injectedSagas[key]; + // enable hot reloading of daemon and once-till-unmount sagas + if (hasSaga && oldDescriptor.saga !== saga) { + oldDescriptor.task.cancel(); + hasSaga = false; + } + } + + if ( + !hasSaga || + (hasSaga && mode !== DAEMON && mode !== ONCE_TILL_UNMOUNT) + ) { + /* eslint-disable no-param-reassign */ + store.injectedSagas[key] = { + ...newDescriptor, + task: store.runSaga(saga, args), + }; + /* eslint-enable no-param-reassign */ + } + }; +} + +export function ejectSagaFactory(store, isValid) { + return function ejectSaga(key) { + if (!isValid) checkStore(store); + + checkKey(key); + + if (Reflect.has(store.injectedSagas, key)) { + const descriptor = store.injectedSagas[key]; + if (descriptor.mode && descriptor.mode !== DAEMON) { + descriptor.task.cancel(); + // Clean up in production; in development we need `descriptor.saga` for hot reloading + if (process.env.NODE_ENV === 'production') { + // Need some value to be able to detect `ONCE_TILL_UNMOUNT` sagas in `injectSaga` + store.injectedSagas[key] = 'done'; // eslint-disable-line no-param-reassign + } + } + } + }; +} + +export default function getInjectors(store) { + checkStore(store); + + return { + injectSaga: injectSagaFactory(store, true), + ejectSaga: ejectSagaFactory(store, true), + }; +} diff --git a/client/app/utils/tests/checkStore.test.js b/client/app/utils/tests/checkStore.test.js new file mode 100644 index 0000000..b58ecb6 --- /dev/null +++ b/client/app/utils/tests/checkStore.test.js @@ -0,0 +1,33 @@ +/** + * Test injectors + */ + +import checkStore from '../checkStore'; + +describe('checkStore', () => { + let store; + + beforeEach(() => { + store = { + dispatch: () => {}, + subscribe: () => {}, + getState: () => {}, + replaceReducer: () => {}, + runSaga: () => {}, + injectedReducers: {}, + injectedSagas: {}, + }; + }); + + it('should not throw if passed valid store shape', () => { + expect(() => checkStore(store)).not.toThrow(); + }); + + it('should throw if passed invalid store shape', () => { + expect(() => checkStore({})).toThrow(); + expect(() => checkStore({ ...store, injectedSagas: null })).toThrow(); + expect(() => checkStore({ ...store, injectedReducers: null })).toThrow(); + expect(() => checkStore({ ...store, runSaga: null })).toThrow(); + expect(() => checkStore({ ...store, replaceReducer: null })).toThrow(); + }); +}); diff --git a/client/app/utils/tests/injectReducer.test.js b/client/app/utils/tests/injectReducer.test.js new file mode 100644 index 0000000..ce628e6 --- /dev/null +++ b/client/app/utils/tests/injectReducer.test.js @@ -0,0 +1,98 @@ +/** + * Test injectors + */ + +import { memoryHistory } from 'react-router-dom'; +import React from 'react'; +import { Provider } from 'react-redux'; +import renderer from 'react-test-renderer'; +import { render } from 'react-testing-library'; + +import configureStore from '../../configureStore'; +import injectReducer, { useInjectReducer } from '../injectReducer'; +import * as reducerInjectors from '../reducerInjectors'; + +// Fixtures +const Component = () => null; + +const reducer = s => s; + +describe('injectReducer decorator', () => { + let store; + let injectors; + let ComponentWithReducer; + + beforeAll(() => { + reducerInjectors.default = jest.fn().mockImplementation(() => injectors); + }); + + beforeEach(() => { + store = configureStore({}, memoryHistory); + injectors = { + injectReducer: jest.fn(), + }; + ComponentWithReducer = injectReducer({ key: 'test', reducer })(Component); + reducerInjectors.default.mockClear(); + }); + + it('should inject a given reducer', () => { + renderer.create( + + + , + ); + + expect(injectors.injectReducer).toHaveBeenCalledTimes(1); + expect(injectors.injectReducer).toHaveBeenCalledWith('test', reducer); + }); + + it('should set a correct display name', () => { + expect(ComponentWithReducer.displayName).toBe('withReducer(Component)'); + expect( + injectReducer({ key: 'test', reducer })(() => null).displayName, + ).toBe('withReducer(Component)'); + }); + + it('should propagate props', () => { + const props = { testProp: 'test' }; + const renderedComponent = renderer.create( + + + , + ); + const { + props: { children }, + } = renderedComponent.getInstance(); + + expect(children.props).toEqual(props); + }); +}); + +describe('useInjectReducer hook', () => { + let store; + let injectors; + let ComponentWithReducer; + + beforeAll(() => { + injectors = { + injectReducer: jest.fn(), + }; + reducerInjectors.default = jest.fn().mockImplementation(() => injectors); + store = configureStore({}, memoryHistory); + ComponentWithReducer = () => { + useInjectReducer({ key: 'test', reducer }); + return null; + }; + }); + + it('should inject a given reducer', () => { + render( + + + , + ); + + expect(injectors.injectReducer).toHaveBeenCalledTimes(1); + expect(injectors.injectReducer).toHaveBeenCalledWith('test', reducer); + }); +}); diff --git a/client/app/utils/tests/injectSaga.test.js b/client/app/utils/tests/injectSaga.test.js new file mode 100644 index 0000000..15a0db6 --- /dev/null +++ b/client/app/utils/tests/injectSaga.test.js @@ -0,0 +1,149 @@ +/** + * Test injectors + */ + +import { memoryHistory } from 'react-router-dom'; +import { put } from 'redux-saga/effects'; +import renderer from 'react-test-renderer'; +import { render } from 'react-testing-library'; +import React from 'react'; +import { Provider } from 'react-redux'; + +import configureStore from '../../configureStore'; +import injectSaga, { useInjectSaga } from '../injectSaga'; +import * as sagaInjectors from '../sagaInjectors'; + +// Fixtures +const Component = () => null; + +function* testSaga() { + yield put({ type: 'TEST', payload: 'yup' }); +} + +describe('injectSaga decorator', () => { + let store; + let injectors; + let ComponentWithSaga; + + beforeAll(() => { + sagaInjectors.default = jest.fn().mockImplementation(() => injectors); + }); + + beforeEach(() => { + store = configureStore({}, memoryHistory); + injectors = { + injectSaga: jest.fn(), + ejectSaga: jest.fn(), + }; + ComponentWithSaga = injectSaga({ + key: 'test', + saga: testSaga, + mode: 'testMode', + })(Component); + sagaInjectors.default.mockClear(); + }); + + it('should inject given saga, mode, and props', () => { + const props = { test: 'test' }; + renderer.create( + + + , + ); + + expect(injectors.injectSaga).toHaveBeenCalledTimes(1); + expect(injectors.injectSaga).toHaveBeenCalledWith( + 'test', + { saga: testSaga, mode: 'testMode' }, + props, + ); + }); + + it('should eject on unmount with a correct saga key', () => { + const props = { test: 'test' }; + const renderedComponent = renderer.create( + + + , + ); + renderedComponent.unmount(); + + expect(injectors.ejectSaga).toHaveBeenCalledTimes(1); + expect(injectors.ejectSaga).toHaveBeenCalledWith('test'); + }); + + it('should set a correct display name', () => { + expect(ComponentWithSaga.displayName).toBe('withSaga(Component)'); + expect( + injectSaga({ key: 'test', saga: testSaga })(() => null).displayName, + ).toBe('withSaga(Component)'); + }); + + it('should propagate props', () => { + const props = { testProp: 'test' }; + const renderedComponent = renderer.create( + + + , + ); + const { + props: { children }, + } = renderedComponent.getInstance(); + expect(children.props).toEqual(props); + }); +}); + +describe('useInjectSaga hook', () => { + let store; + let injectors; + let ComponentWithSaga; + + beforeAll(() => { + sagaInjectors.default = jest.fn().mockImplementation(() => injectors); + }); + + beforeEach(() => { + store = configureStore({}, memoryHistory); + injectors = { + injectSaga: jest.fn(), + ejectSaga: jest.fn(), + }; + ComponentWithSaga = () => { + useInjectSaga({ + key: 'test', + saga: testSaga, + mode: 'testMode', + }); + return null; + }; + sagaInjectors.default.mockClear(); + }); + + it('should inject given saga and mode', () => { + const props = { test: 'test' }; + render( + + + , + ); + + expect(injectors.injectSaga).toHaveBeenCalledTimes(1); + expect(injectors.injectSaga).toHaveBeenCalledWith('test', { + saga: testSaga, + mode: 'testMode', + }); + }); + + it('should eject on unmount with a correct saga key', () => { + const props = { test: 'test' }; + const { unmount } = render( + + + , + ); + unmount(); + + expect(injectors.ejectSaga).toHaveBeenCalledTimes(1); + expect(injectors.ejectSaga).toHaveBeenCalledWith('test'); + }); +}); diff --git a/client/app/utils/tests/reducerInjectors.test.js b/client/app/utils/tests/reducerInjectors.test.js new file mode 100644 index 0000000..6c34644 --- /dev/null +++ b/client/app/utils/tests/reducerInjectors.test.js @@ -0,0 +1,100 @@ +/** + * Test injectors + */ + +import produce from 'immer'; +import { memoryHistory } from 'react-router-dom'; +import identity from 'lodash/identity'; + +import configureStore from '../../configureStore'; + +import getInjectors, { injectReducerFactory } from '../reducerInjectors'; + +// Fixtures + +const initialState = { reduced: 'soon' }; + +/* eslint-disable default-case, no-param-reassign */ +const reducer = (state = initialState, action) => + produce(state, draft => { + switch (action.type) { + case 'TEST': + draft.reduced = action.payload; + break; + } + }); + +describe('reducer injectors', () => { + let store; + let injectReducer; + + describe('getInjectors', () => { + beforeEach(() => { + store = configureStore({}, memoryHistory); + }); + + it('should return injectors', () => { + expect(getInjectors(store)).toEqual( + expect.objectContaining({ + injectReducer: expect.any(Function), + }), + ); + }); + + it('should throw if passed invalid store shape', () => { + Reflect.deleteProperty(store, 'dispatch'); + + expect(() => getInjectors(store)).toThrow(); + }); + }); + + describe('injectReducer helper', () => { + beforeEach(() => { + store = configureStore({}, memoryHistory); + injectReducer = injectReducerFactory(store, true); + }); + + it('should check a store if the second argument is falsy', () => { + const inject = injectReducerFactory({}); + + expect(() => inject('test', reducer)).toThrow(); + }); + + it('it should not check a store if the second argument is true', () => { + Reflect.deleteProperty(store, 'dispatch'); + + expect(() => injectReducer('test', reducer)).not.toThrow(); + }); + + it("should validate a reducer and reducer's key", () => { + expect(() => injectReducer('', reducer)).toThrow(); + expect(() => injectReducer(1, reducer)).toThrow(); + expect(() => injectReducer(1, 1)).toThrow(); + }); + + it('given a store, it should provide a function to inject a reducer', () => { + injectReducer('test', reducer); + + const actual = store.getState().test; + const expected = initialState; + + expect(actual).toEqual(expected); + }); + + it('should not assign reducer if already existing', () => { + store.replaceReducer = jest.fn(); + injectReducer('test', reducer); + injectReducer('test', reducer); + + expect(store.replaceReducer).toHaveBeenCalledTimes(1); + }); + + it('should assign reducer if different implementation for hot reloading', () => { + store.replaceReducer = jest.fn(); + injectReducer('test', reducer); + injectReducer('test', identity); + + expect(store.replaceReducer).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/client/app/utils/tests/sagaInjectors.test.js b/client/app/utils/tests/sagaInjectors.test.js new file mode 100644 index 0000000..4b53ed8 --- /dev/null +++ b/client/app/utils/tests/sagaInjectors.test.js @@ -0,0 +1,231 @@ +/** + * Test injectors + */ + +import { memoryHistory } from 'react-router-dom'; +import { put } from 'redux-saga/effects'; + +import configureStore from '../../configureStore'; +import getInjectors, { + injectSagaFactory, + ejectSagaFactory, +} from '../sagaInjectors'; +import { DAEMON, ONCE_TILL_UNMOUNT, RESTART_ON_REMOUNT } from '../constants'; + +function* testSaga() { + yield put({ type: 'TEST', payload: 'yup' }); +} + +describe('injectors', () => { + const originalNodeEnv = process.env.NODE_ENV; + let store; + let injectSaga; + let ejectSaga; + + describe('getInjectors', () => { + beforeEach(() => { + store = configureStore({}, memoryHistory); + }); + + it('should return injectors', () => { + expect(getInjectors(store)).toEqual( + expect.objectContaining({ + injectSaga: expect.any(Function), + ejectSaga: expect.any(Function), + }), + ); + }); + + it('should throw if passed invalid store shape', () => { + Reflect.deleteProperty(store, 'dispatch'); + + expect(() => getInjectors(store)).toThrow(); + }); + }); + + describe('ejectSaga helper', () => { + beforeEach(() => { + store = configureStore({}, memoryHistory); + injectSaga = injectSagaFactory(store, true); + ejectSaga = ejectSagaFactory(store, true); + }); + + it('should check a store if the second argument is falsy', () => { + const eject = ejectSagaFactory({}); + + expect(() => eject('test')).toThrow(); + }); + + it('should not check a store if the second argument is true', () => { + Reflect.deleteProperty(store, 'dispatch'); + injectSaga('test', { saga: testSaga }); + + expect(() => ejectSaga('test')).not.toThrow(); + }); + + it("should validate saga's key", () => { + expect(() => ejectSaga('')).toThrow(); + expect(() => ejectSaga(1)).toThrow(); + }); + + it('should cancel a saga in RESTART_ON_REMOUNT mode', () => { + const cancel = jest.fn(); + store.injectedSagas.test = { task: { cancel }, mode: RESTART_ON_REMOUNT }; + ejectSaga('test'); + + expect(cancel).toHaveBeenCalled(); + }); + + it('should not cancel a daemon saga', () => { + const cancel = jest.fn(); + store.injectedSagas.test = { task: { cancel }, mode: DAEMON }; + ejectSaga('test'); + + expect(cancel).not.toHaveBeenCalled(); + }); + + it('should ignore saga that was not previously injected', () => { + expect(() => ejectSaga('test')).not.toThrow(); + }); + + it("should remove non daemon saga's descriptor in production", () => { + process.env.NODE_ENV = 'production'; + injectSaga('test', { saga: testSaga, mode: RESTART_ON_REMOUNT }); + injectSaga('test1', { saga: testSaga, mode: ONCE_TILL_UNMOUNT }); + + ejectSaga('test'); + ejectSaga('test1'); + + expect(store.injectedSagas.test).toBe('done'); + expect(store.injectedSagas.test1).toBe('done'); + process.env.NODE_ENV = originalNodeEnv; + }); + + it("should not remove daemon saga's descriptor in production", () => { + process.env.NODE_ENV = 'production'; + injectSaga('test', { saga: testSaga, mode: DAEMON }); + ejectSaga('test'); + + expect(store.injectedSagas.test.saga).toBe(testSaga); + process.env.NODE_ENV = originalNodeEnv; + }); + + it("should not remove daemon saga's descriptor in development", () => { + injectSaga('test', { saga: testSaga, mode: DAEMON }); + ejectSaga('test'); + + expect(store.injectedSagas.test.saga).toBe(testSaga); + }); + }); + + describe('injectSaga helper', () => { + beforeEach(() => { + store = configureStore({}, memoryHistory); + injectSaga = injectSagaFactory(store, true); + ejectSaga = ejectSagaFactory(store, true); + }); + + it('should check a store if the second argument is falsy', () => { + const inject = injectSagaFactory({}); + + expect(() => inject('test', testSaga)).toThrow(); + }); + + it('it should not check a store if the second argument is true', () => { + Reflect.deleteProperty(store, 'dispatch'); + + expect(() => injectSaga('test', { saga: testSaga })).not.toThrow(); + }); + + it("should validate saga's key", () => { + expect(() => injectSaga('', { saga: testSaga })).toThrow(); + expect(() => injectSaga(1, { saga: testSaga })).toThrow(); + }); + + it("should validate saga's descriptor", () => { + expect(() => injectSaga('test')).toThrow(); + expect(() => injectSaga('test', { saga: 1 })).toThrow(); + expect(() => + injectSaga('test', { saga: testSaga, mode: 'testMode' }), + ).toThrow(); + expect(() => injectSaga('test', { saga: testSaga, mode: 1 })).toThrow(); + expect(() => + injectSaga('test', { saga: testSaga, mode: RESTART_ON_REMOUNT }), + ).not.toThrow(); + expect(() => + injectSaga('test', { saga: testSaga, mode: DAEMON }), + ).not.toThrow(); + expect(() => + injectSaga('test', { saga: testSaga, mode: ONCE_TILL_UNMOUNT }), + ).not.toThrow(); + }); + + it('should pass args to saga.run', () => { + const args = {}; + store.runSaga = jest.fn(); + injectSaga('test', { saga: testSaga }, args); + + expect(store.runSaga).toHaveBeenCalledWith(testSaga, args); + }); + + it('should not start daemon and once-till-unmount sagas if were started before', () => { + store.runSaga = jest.fn(); + + injectSaga('test1', { saga: testSaga, mode: DAEMON }); + injectSaga('test1', { saga: testSaga, mode: DAEMON }); + injectSaga('test2', { saga: testSaga, mode: ONCE_TILL_UNMOUNT }); + injectSaga('test2', { saga: testSaga, mode: ONCE_TILL_UNMOUNT }); + + expect(store.runSaga).toHaveBeenCalledTimes(2); + }); + + it('should start any saga that was not started before', () => { + store.runSaga = jest.fn(); + + injectSaga('test1', { saga: testSaga }); + injectSaga('test2', { saga: testSaga, mode: DAEMON }); + injectSaga('test3', { saga: testSaga, mode: ONCE_TILL_UNMOUNT }); + + expect(store.runSaga).toHaveBeenCalledTimes(3); + }); + + it('should restart a saga if different implementation for hot reloading', () => { + const cancel = jest.fn(); + store.injectedSagas.test = { saga: testSaga, task: { cancel } }; + store.runSaga = jest.fn(); + + function* testSaga1() { + yield put({ type: 'TEST', payload: 'yup' }); + } + + injectSaga('test', { saga: testSaga1 }); + + expect(cancel).toHaveBeenCalledTimes(1); + expect(store.runSaga).toHaveBeenCalledWith(testSaga1, undefined); + }); + + it('should not cancel saga if different implementation in production', () => { + process.env.NODE_ENV = 'production'; + const cancel = jest.fn(); + store.injectedSagas.test = { + saga: testSaga, + task: { cancel }, + mode: RESTART_ON_REMOUNT, + }; + + function* testSaga1() { + yield put({ type: 'TEST', payload: 'yup' }); + } + + injectSaga('test', { saga: testSaga1, mode: DAEMON }); + + expect(cancel).toHaveBeenCalledTimes(0); + process.env.NODE_ENV = originalNodeEnv; + }); + + it('should save an entire descriptor in the saga registry', () => { + injectSaga('test', { saga: testSaga, foo: 'bar' }); + expect(store.injectedSagas.test.foo).toBe('bar'); + }); + }); +}); diff --git a/client/appveyor.yml b/client/appveyor.yml new file mode 100644 index 0000000..72c3eeb --- /dev/null +++ b/client/appveyor.yml @@ -0,0 +1,45 @@ +# http://www.appveyor.com/docs/appveyor-yml + +# Set build version format here instead of in the admin panel +version: '{build}' + +# Do not build on gh tags +skip_tags: true + +# Test against these versions of Node.js +environment: + matrix: + # Node versions to run + - nodejs_version: 'Current' + - nodejs_version: 'LTS' + +# Fix line endings in Windows. (runs before repo cloning) +init: + - git config --global core.autocrlf input + +# Install scripts--runs after repo cloning +install: + # Install the latest stable version of Node + - ps: Install-Product node $env:nodejs_version + - npm ci + +# Disable automatic builds +build: off + +# Post-install test scripts +test_script: + # Output debugging info + - node --version + - node ./internals/scripts/generate-templates-for-linting + # run tests and run build + - npm run test + - npm run build + +# Cache node_modules for faster builds +cache: + - node_modules -> package.json + +# remove, as appveyor doesn't support secure variables on pr builds +# so `COVERALLS_REPO_TOKEN` cannot be set, without hard-coding in this file +#on_success: +#- npm run coveralls diff --git a/client/babel.config.js b/client/babel.config.js new file mode 100644 index 0000000..df1039a --- /dev/null +++ b/client/babel.config.js @@ -0,0 +1,33 @@ +module.exports = { + presets: [ + [ + '@babel/preset-env', + { + modules: false, + }, + ], + '@babel/preset-react', + ], + plugins: [ + 'styled-components', + '@babel/plugin-proposal-class-properties', + '@babel/plugin-syntax-dynamic-import', + ], + env: { + production: { + only: ['app'], + plugins: [ + 'lodash', + 'transform-react-remove-prop-types', + '@babel/plugin-transform-react-inline-elements', + '@babel/plugin-transform-react-constant-elements', + ], + }, + test: { + plugins: [ + '@babel/plugin-transform-modules-commonjs', + 'dynamic-import-node', + ], + }, + }, +}; diff --git a/client/docs/README.md b/client/docs/README.md new file mode 100644 index 0000000..46e8bda --- /dev/null +++ b/client/docs/README.md @@ -0,0 +1,128 @@ +# Documentation + +## Table of Contents + +- [General](general) + - [**CLI Commands**](general/commands.md) + - [Introduction ](general/introduction.md) + - [Tool Configuration](general/files.md) + - [Server Configurations](general/server-configs.md) + - [Deployment](general/deployment.md) _(currently Heroku and AWS S3 specific)_ + - [Debugging](general/debugging.md) + - [FAQ](general/faq.md) + - [Gotchas](general/gotchas.md) + - [Remove](general/remove.md) + - [Extracting components](general/components.md) +- [Testing](testing) + - [Unit Testing](testing/unit-testing.md) + - [Component Testing](testing/component-testing.md) + - [Remote Testing](testing/remote-testing.md) +- [Styling (CSS)](css/README.md) + - [Next Generation CSS](css/README.md#next-generation-css) + - [CSS Support](css/README.md#css-we-support) + - [styled-components](css/README.md#styled-components) + - [Stylesheet](css/README.md#stylesheet) + - [CSS Modules](css/README.md#css-modules) + - [Sass](css/README.md#sass) + - [LESS](css/README.md#less) +- [JS](js) + - [Redux](js/redux.md) + - [Immer](js/immer.md) + - [reselect](js/reselect.md) + - [redux-saga](js/redux-saga.md) + - [i18n](js/i18n.md) + - [routing](js/routing.md) +- [Maintenance](maintenance) + - [Dependency Update](maintenance/dependency.md) +- [Forks](forks) + +## Overview + +### Quickstart + +1. First, let's kick the tyres by launching the sample _Repospective_ app + bundled with this project to demo some of its best features: + + ```Shell + npm run setup && npm start + ``` + +1. Open [localhost:3000](http://localhost:3000) to see it in action. + + - Add a Github username to see Redux and Redux Sagas in action: effortless + async state updates and side effects are now yours :) + - Edit the file at `./app/components/Header/index.js` so that the text of + the `; +} +``` + +> For more information about Stylesheets and the `css-loader` see https://github.com/webpack-contrib/css-loader + +## CSS Modules + +### Setup + +Modify [`webpack.base.babel.js`][webpackconfig] +to look like: + +```diff +{ + test: /\.css$/, + exclude: /node_modules/, +- use: ['style-loader', 'css-loader'], ++ use: [ ++ 'style-loader', ++ { ++ loader: 'css-loader', ++ options: { ++ modules: true, ++ }, ++ }, ++ ], +} +``` + +### Usage + +The syntax is very similar to using a [Stylesheet](#stylesheet) +and this often catches people out. +The key difference in CSS Modules is that you import styles to a variable. + +**`Button.css`** + +```css +.danger { + background-color: red; +} +``` + +**`Button.js`** + +```js +import React from 'react'; +import styles from './Button.css'; // different import compared to stylesheets + +function Button() { + // different usage to stylesheets + return ; +} +``` + +**IMPORTANT: if you enable this rule, [stylesheets](#stylesheet) will no longer work, +it's one or the other unless you include or exclude specific directories.** + +> For more information about CSS Modules see https://github.com/css-modules/css-modules + +## Sass + +### Setup + +Install `sass-loader` and the `node-sass` dependancy. + +``` +npm i -D sass-loader node-sass +``` + +Modify [`webpack.base.babel.js`][webpackconfig] +to look like: + +```diff +{ +- test: /\.css$/, ++ test: /\.scss$/, + exclude: /node_modules/, +- use: ['style-loader', 'css-loader'], ++ use: ['style-loader', 'css-loader', 'sass-loader'], +} +``` + +### Usage + +**`Button.scss`** + +```scss +$error-color: red; + +.danger { + background-color: $error-color; +} +``` + +**`Button.js`** + +```js +import React from 'react'; +import './Button.scss'; + +function Button() { + return ; +} +``` + +> For more information about Sass and the `sass-loader` see https://github.com/webpack-contrib/sass-loader + +## LESS + +### Setup + +Install `less-loader` and the `less` dependancy. + +``` +npm i -D less-loader less +``` + +Modify [`webpack.base.babel.js`][webpackconfig] +to look like: + +```diff +{ +- test: /\.css$/, ++ test: /\.less$/, + exclude: /node_modules/, +- use: ['style-loader', 'css-loader'], ++ use: [ ++ 'style-loader', ++ { ++ loader: 'css-loader', ++ options: { ++ importLoaders: 1, ++ }, ++ }, ++ 'less-loader', ++], +} +``` + +### Usage + +**`Button.less`** + +```less +@error-color: red; + +.danger { + background-color: @error-color; +} +``` + +**`Button.js`** + +```js +import React from 'react'; +import './Button.less'; + +function Button() { + return ; +} +``` + +> For more information about LESS and the `less-loader` see https://github.com/webpack-contrib/less-loader. + +[webpackconfig]: ../../internals/webpack/webpack.base.babel.js 'Webpack config' diff --git a/client/docs/css/linting.md b/client/docs/css/linting.md new file mode 100644 index 0000000..d703c19 --- /dev/null +++ b/client/docs/css/linting.md @@ -0,0 +1,11 @@ +# Linting + +We use `stylelint` for CSS linting. `stylelint` catches syntax errors and helps you and your team stay consistent with modern CSS standards and conventions. + +We've pre-configured it specifically for the boilerplate's `styled-components` setup (and following that package's [recommendations](https://www.styled-components.com/docs/tooling#stylelint)). + +However, you can (and should!) adapt it to your own style setup if you decide to modify it. + +You can trigger `stylelint` using the `npm run lint:css` command or you can integrate with your IDE by downloading the relevant plugin. We recommend the latter. + +See the [official documentation](https://stylelint.io/) for more information! diff --git a/client/docs/css/remove.md b/client/docs/css/remove.md new file mode 100644 index 0000000..d45eac7 --- /dev/null +++ b/client/docs/css/remove.md @@ -0,0 +1,27 @@ +## Removing `sanitize.css` + +To remove `sanitize.css` you will need to remove it from both: + +- [`app.js`](../../app/app.js) + +```diff +import FontFaceObserver from 'fontfaceobserver'; +import history from 'utils/history'; +-import 'sanitize.css/sanitize.css'; + +// Import root app +import App from 'containers/App'; +``` + +- [`package.json`](../../package.json)! + +```diff +"dependencies": { + ... + "redux-saga": "0.14.3", + "reselect": "2.5.4", +- "sanitize.css": "4.1.0", + "styled-components": "1.4.3", + ... +}, +``` diff --git a/client/docs/css/sanitize.md b/client/docs/css/sanitize.md new file mode 100644 index 0000000..747ba2c --- /dev/null +++ b/client/docs/css/sanitize.md @@ -0,0 +1,17 @@ +# `sanitize.css` + +Sanitize.css makes browsers render elements more in +line with developer expectations (e.g. having the box model set to a cascading +`box-sizing: border-box`) and preferences (its defaults can be individually +overridden). + +It was selected over older projects like `normalize.css` and `reset.css` due +to its greater flexibility and better alignment with CSSNext features like CSS +variables. + +See the [official documentation](https://github.com/10up/sanitize.css) for more +information. + +--- + +_Don't like this feature? [Click here](remove.md)_ diff --git a/client/docs/forks/README.md b/client/docs/forks/README.md new file mode 100644 index 0000000..24f7477 --- /dev/null +++ b/client/docs/forks/README.md @@ -0,0 +1,25 @@ +# Forks + +## Electron + +Electron is a very popular open source library developed by GitHub. It enables the creation of cross-platform desktop applications using HTML, CSS and Javascript. This fork provide steps through which you can kickstart an Electron application using `react-boilerplate`. + +Electron provides a combined single runtime of Chromium and Node.js. No server is needed to host your React application. You can simply package the entire source code in a `.exe`, `.dmg` or `.deb` file. + +This fork gives you two options: + +1. Clone the repository for a fresh start: [reactron](https://github.com/mjangir/reactron) + +2. Read the steps to [convert an existing RBP-based project to an Electron application](https://github.com/mjangir/reactron/wiki/Convert-Existing-To-Electron) + +## Server-side rendering + +This repo receives many requests for server-side rendering and there have been plenty of long discussions on the topic. None have led to an implementation that we're happy to merge into the main repo. That being said, @gretzky has a fork which you can use as a solid starting point for your SSR needs: [react-boilerplate-ssr](https://github.com/gretzky/react-boilerplate-ssr) + +## TypeScript + +Since we don't support TypeScript out of the box, for those in need, we can direct you to a TypeScript implementation of this repo. + +TS Fork: [react-boilerplate-typescript](https://github.com/Can-Sahin/react-boilerplate-typescript) + +Details: [Docs](https://github.com/Can-Sahin/react-boilerplate-typescript/blob/master/docs/general/typescript.md) diff --git a/client/docs/general/README.md b/client/docs/general/README.md new file mode 100644 index 0000000..090caa4 --- /dev/null +++ b/client/docs/general/README.md @@ -0,0 +1,133 @@ +# Introduction + +The JavaScript ecosystem evolves at incredible speed: staying current can feel +overwhelming. So, instead of you having to stay on top of every new tool, +feature and technique to hit the headlines, this project aims to lighten the +load by providing a curated baseline of the most valuable ones. + +Using React Boilerplate, you get to start your app with our community's current +ideas on what represents optimal developer experience, best practice, most +efficient tooling and cleanest project structure. + +- [**CLI Commands**](commands.md) +- [Setting up your editor](editor.md) +- [Tool Configuration](files.md) +- [Server Configurations](server-configs.md) +- [Deployment](deployment.md) _(currently Heroku & AWS specific)_ +- [FAQ](faq.md) +- [Gotchas](gotchas.md) + +# Feature overview + +## Quick scaffolding + +Automate the creation of components, containers, routes, selectors and sagas - +and their tests - right from the CLI! + +Run `npm run generate` in your terminal and choose one of the parts you want +to generate. They'll automatically be imported in the correct places and have +everything set up correctly. + +> We use [plop] to generate new components, you can find all the logic and +> templates for the generation in `internals/generators`. + +[plop]: https://github.com/amwmedia/plop + +## Instant feedback + +Enjoy the best DX and code your app at the speed of thought! Your saved changes +to the CSS and JS are reflected instantaneously without refreshing the page. +Preserve application state even when you update something in the underlying code! + +## Predictable state management + +We use Redux to manage our applications state. We have also added optional +support for the [Chrome Redux DevTools Extension] – if you have it installed, +you can see, play back and change your action history! + +[chrome redux devtools extension]: https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd + +## Next generation JavaScript + +Use ESNext template strings, object destructuring, arrow functions, JSX syntax +and more, today. This is possible thanks to Babel with the `env`, `stage-0` +and `react` presets! + +## Next generation CSS + +Write composable CSS that's co-located with your components using [`styled-components`] +for complete modularity. Unique generated class names keep the specificity low +while eliminating style clashes. Ship only the styles that are used on the +visible page for the best performance. + +[`styled-components`]: ../css/README.md#styled-components + +## Industry-standard routing + +It's natural to want to add pages (e.g. `/about`) to your application, and +routing makes this possible. Thanks to [react-router] with [connected-react-router], +that's as easy as pie and the url is auto-synced to your application state! + +[react-router]: https://github.com/ReactTraining/react-router +[connected-react-router]: https://github.com/supasate/connected-react-router + +## Static code analysis + +Focus on writing new features without worrying about formatting or code quality. With the right editor setup, your code will automatically be formatted and linted as you work. + +Read more about linting in our [introduction](./introduction.md) and don't forget to setup your by following [our instructions](./editor.md). + +# Optional extras + +_Don't like any of these features? [Click here](remove.md)_ + +## Offline-first + +The next frontier in performant web apps: availability without a network +connection from the instant your users load the app. This is done with a +ServiceWorker and a fallback to AppCache, so this feature even works on older +browsers! + +> All your files are included automatically. No manual intervention needed +> thanks to Webpack's [`offline-plugin`](https://github.com/NekR/offline-plugin) + +### Add To Homescreen + +After repeat visits to your site, users will get a prompt to add your application +to their homescreen. Combined with offline caching, this means your web app can +be used exactly like a native application (without the limitations of an app store). + +The name and icon to be displayed are set in the `manifest.json` generated by +Webpack's `webpack-pwa-manifest` plugin. Configure the name, colors, and icons +in `webpack.prod.babel.js` and try it! + +## Performant Web Font Loading + +If you simply use web fonts in your project, the page will stay blank until +these fonts are downloaded. That means a lot of waiting time in which users +could already read the content. + +[FontFaceObserver](https://github.com/bramstein/fontfaceobserver) adds a class +to the `body` when the fonts have loaded. (see [`app.js`](../../app/app.js#L26-L36) +and [`App/styles.css`](../../app/containers/App/styles.css)) + +### Adding a new font + +1. Either add the `@font-face` declaration to `App/styles.css` or add a `` + tag to the [`index.html`](../../app/index.html). (Don't forget to remove the `` + for Open Sans from the [`index.html`](../../app/index.html)!) + +2. In `App/styles.css`, specify your initial `font-family` in the `body` tag + with only web-save fonts. In the `body.jsFontLoaded` tag, specify your + `font-family` stack with your web font. + +3. In `app.js` add a `Observer` for your font. + +## Image optimization + +Images often represent the majority of bytes downloaded on a web page, so image +optimization can often be a notable performance improvement. Thanks to Webpack's +[`image-loader`](https://github.com/tcoopman/image-webpack-loader), every PNG, JPEG, GIF and SVG images +is optimized. + +See [`image-loader`](https://github.com/tcoopman/image-webpack-loader) to customize optimizations options. diff --git a/client/docs/general/commands.md b/client/docs/general/commands.md new file mode 100644 index 0000000..6b234f9 --- /dev/null +++ b/client/docs/general/commands.md @@ -0,0 +1,149 @@ +# Command Line Commands + +## Initialization + +```Shell +npm run setup +``` + +Initializes a new project with this boilerplate. Deletes the `react-boilerplate` +git history, installs the dependencies and initializes a new repository. + +> Note: This command is self-destructive, once you've run it the init script is +> gone forever. This is for your own safety, so you can't delete your project's +> history irreversibly by accident. + +## Development + +```Shell +npm run start +``` + +Starts the development server running on `http://localhost:3000` + +## Cleaning + +```Shell +npm run clean +``` + +Deletes the example app, replacing it with the smallest amount of boilerplate +code necessary to start writing your app! + +> Note: This command is self-destructive, once you've run it you cannot run it +> again. This is for your own safety, so you can't delete portions of your project +> irreversibly by accident. + +## Generators + +```Shell +npm run generate +``` + +Allows you to auto-generate boilerplate code for common parts of your +application, specifically `component`s, and `container`s. You can +also run `npm run generate ` to skip the first selection. (e.g. `npm run generate container`) + +## Server + +### Development + +```Shell +npm start +``` + +Starts the development server and makes your application accessible at +`localhost:3000`. Changes in the application code will be hot-reloaded. + +### Production + +```Shell +npm run start:production +``` + +- Runs tests (see `npm test`) +- Builds your app (see `npm run build`) +- Starts the production server (see `npm run start:prod`) + +The app is built for optimal performance: assets are +minified and served gzipped. + +### Host and Port + +To change the host and/or port the app is accessible at, pass the `--host` and/or `--port` option to the command +with `--`. E.g. to make the app visible at `my-local-hostname:5000`, run the following: +`npm start -- --host my-local-hostname --port 5000` + +## Building + +```Shell +npm run build +``` + +Preps your app for deployment (does not run tests). Optimizes and minifies all files, piping them to the `build` folder. + +Upload the contents of `build` to your web server to +see your work live! + +## Testing + +See the [testing documentation](../testing/README.md) for detailed information +about our testing setup! + +## Unit testing + +```Shell +npm test +``` + +Tests your application with the unit tests specified in the `**/tests/*.js` files +throughout the application. +All the `test` commands allow an optional `-- [string]` argument to filter +the tests run by Jest. Useful if you need to run a specific test only. + +```Shell +# Run only the Button component tests +npm test -- Button +``` + +### Watching + +```Shell +npm run test:watch +``` + +Watches changes to your application and re-runs tests whenever a file changes. + +### Remote testing + +```Shell +npm run start:tunnel +``` + +Starts the development server and tunnels it with `ngrok`, making the website +available worldwide. Useful for testing on different devices in different locations! + +### Dependency size test + +```Shell +npm run analyze +``` + +This command will generate a `stats.json` file from your production build, which +you can upload to the [webpack analyzer](https://webpack.github.io/analyse/) or [Webpack Visualizer](https://chrisbateman.github.io/webpack-visualizer/). This +analyzer will visualize your dependencies and chunks with detailed statistics +about the bundle size. + +## Linting + +```Shell +npm run lint +``` + +Lints your JavaScript and your CSS. + +```Shell +npm run lint:eslint:fix -- . +``` + +Lints your code and tries to fix any errors it finds. diff --git a/client/docs/general/components.md b/client/docs/general/components.md new file mode 100644 index 0000000..b4f2996 --- /dev/null +++ b/client/docs/general/components.md @@ -0,0 +1,84 @@ +# Extracting components + +One of the most compelling arguments for the self-contained components +architecture is the ability to easily reuse each component in other projects. +Since all the files kept in the same folder, this should be a breeze. + +## When? + +Often when working on a project, you find you've created a component that you +could use in other upcoming projects. You would like to extract that +component to its own git repository and npm package since keeping the version +histories separate makes a lot of sense. + +You're not finished with the component, but would like to continue working on it +in parallel alongside your main project. + +## How? + +Since all the files are kept in the same place, its simply a matter of moving +the folder to its own directory, setting up the `package.json` for your new +package, and including it in your main project. + +### Npm + +Npm has a great feature that allows this kind of parallel development of +packages - `npm link` (read more [here](https://docs.npmjs.com/cli/link)). After +setting up your new package, you can link it into your main package like this: + +1. `cd` into your new package directory +2. Run `npm link` +3. `cd` into your main project directory +4. Run `npm link ` + +### Configuration + +#### Specifying dependencies + +Linking the packages won't save the package as a dependency in your main project +`package.json`, so you'll have to do that manually. + +```json +"dependencies": { + "": "*", +} +``` + +## Gotchas + +As well as this approach works for development, there are some things you need +to watch out for when building and publishing your new package or project. + +### Publishing to npm registry + +In your new package, you will most likely have a build task to transpile from +ES6 into ES5. You probably keep your ES6 code in a `src/` directory and your +transpiled code in a `lib/` directory. + +In your `package.json`, you probably have something like this: + +```json + "main": "lib/index.js" +``` + +This is what you want when you publish to the registry, but during development +you probably want to change this to + +```json + "main": "src/index.js" +``` + +This will make sure that your main project always includes your most recent +code. You've just got to remember to change it back to `lib/` before publishing +to the npm registry. + +You can, of course, go down the `lib/` path, but that requires you to +rebuild your package and transpile it to ES5 whenever you introduce a change, +which can be a pain. + +### Building + +Building the package can be a little bit tricky due to how webpack handles +symlinks. We've found it easiest to remove the symlink and replace it with the +actual files, either by copying the package to `node_modules` or running +`npm install` if you've published your package to the npm registry. diff --git a/client/docs/general/debugging.md b/client/docs/general/debugging.md new file mode 100644 index 0000000..686d486 --- /dev/null +++ b/client/docs/general/debugging.md @@ -0,0 +1,77 @@ +# Debugging + +- [Debugging with Visual Studio Code](#debugging-with-visual-studio-code) +- [Debugging with WebStorm](#debugging-with-webstorm) + - [Troubleshooting](#troubleshooting) + - [Enable ESLint](#enable-eslint) + +## Debugging with Visual Studio Code + +You can super charge your React debugging workflow via VS Code and Chrome. Here are the steps: + +1. Install the [Debugger for Chrome](https://marketplace.visualstudio.com/items?itemName=msjsdiag.debugger-for-chrome) extension in VS Code. +2. Add the `Launch Chrome` option to your `launch.json` config: + +```json +{ + "version": "0.2.0", + "configurations": [ + { + "type": "chrome", + "request": "launch", + "name": "Launch Chrome", + "url": "http://localhost:3000", + "webRoot": "${workspaceRoot}/app", + "sourceMapPathOverrides": { + "webpack:///./app/*": "${webRoot}/*", + "webpack:///app/*": "${webRoot}/*" + } + } + ] +} +``` + +3. Start your dev server with `npm run start`. +4. Launch the VS Code Debugger with the `Launch Chrome` configuration. + +You can then set breakpoints directly from inside VS Code, use stepping with the Chrome or VS Code buttons and more. + +Read all about it in the [Debugger for Chrome page](https://marketplace.visualstudio.com/items?itemName=msjsdiag.debugger-for-chrome). + +**Note**: There's currently a [known problem](https://github.com/react-boilerplate/react-boilerplate/pull/1698) with source maps and VS Code. You can change your Webpack dev config to use `inline-source-map` instead of `eval-source-map` and the issue should be resolved. + +## Debugging with WebStorm + +WebStorm is a powerful IDE, and why not also use it as debugger tool? Here are the steps: + +1. [Install JetBrain Chrome Extension](https://chrome.google.com/webstore/detail/jetbrains-ide-support/hmhgeddbohgjknpmjagkdomcpobmllji) +2. [Setting up the PORT](https://www.jetbrains.com/help/webstorm/2016.1/using-jetbrains-chrome-extension.html) +3. Change WebPack devtool config to `source-map` [(This line)](https://github.com/react-boilerplate/react-boilerplate/blob/56eb5a0ec4aa691169ef427f3a0122fde5a5aa24/internals/webpack/webpack.dev.babel.js#L65) +4. Run web server (`npm run start`) +5. Create Run Configuration (Run > Edit Configurations) +6. Add new `JavaScript Debug` +7. Setting up URL +8. Start Debug (Click the green bug button) +9. Edit Run Configuration Again +10. Mapping Url as below picture + - Map your `root` directory with `webpack://.` (please note the last dot) + - Map your `build` directory with your root path (e.g. `http://localhost:3000`) +11. Hit OK and restart debugging session + +![How to debug using WebStorm](webstorm-debug.png) + +### Troubleshooting + +1. You miss the last `.` (dot) in `webpack://.` +2. The port debugger is listening tool and the JetBrain extension is mismatch. + +### Enable ESLint + +ESLint helps developers on a team follow the same coding format. It's highly recommended to set it up in your IDE to avoid failing the linting step in tests. + +1. Go to WebStorm Preferences +2. Search for `ESLint` +3. Click `Enable` + +![Setting up ESLint](webstorm-eslint.png) + diff --git a/client/docs/general/deployment.md b/client/docs/general/deployment.md new file mode 100644 index 0000000..6631975 --- /dev/null +++ b/client/docs/general/deployment.md @@ -0,0 +1,121 @@ +# Deployment + +## Heroku + +### Easy 3-Step Deployment Process + +_Step 1:_ Create a _Procfile_ with the following line: `web: npm run start:prod`. We do this because Heroku runs `npm run start` by default, so we need this setting to override the default run command. + +_Step 2:_ Install the Node.js buildpack for your Heroku app by running the following command: `heroku buildpacks:set https://github.com/heroku/heroku-buildpack-nodejs#v133 -a [your app name]`. Make sure to replace `#v133` with whatever the latest buildpack is, which you can [find here](https://github.com/heroku/heroku-buildpack-nodejs/releases). + +_Step 3:_ Follow the standard Heroku deploy process: + +1. `git add .` +2. `git commit -m 'Made some epic changes as per usual'` +3. `git push heroku master` + +## AWS S3 + +### Easy 7-Step Deployment Process + +_Step 1:_ Run `npm install` to install dependencies, then `npm run build` to create the `./build` folder. + +_Step 2:_ Navigate to [AWS S3](https://aws.amazon.com/s3) and login (or sign up if you don't have an account). Click on `Services` followed by `S3` in the dropdown. + +_Step 3:_ Click on `Create Bucket` and fill out both your `Bucket Name` and `Region` (for the USA we recommend `US Standard`). Click `Create` to create your bucket. + +_Step 4:_ Open the `Permissions` accordion on the right (under the `Properties` tab) after selecting your new bucket. Click `Add more permissions`, set the `Grantee` to `Everyone` (or whoever you want to be able to access the website), and give them `View Permissions`. Click `Save`. + +_Step 5:_ Click on the `Static Website Hosting` accordion where you should see the URL (or _endpoint_) of your website (ie. example.s3-website-us-east-1.amazonaws.com). Click `Enable website hosting` and fill in both the `Index document` and `Error document` input fields with `index.html`. Click `Save`. + +_Step 6:_ Click on your new S3 bucket on the left to open the bucket. Click `Upload` and select all the files within your `./build` folder. Click `Start Upload`. Once the files are done, select all of the files, right-click on the selected files (or click on the `Actions` button) and select `Make Public`. + +_Step 7:_ Click on the `Properties` tab, open `Static Website Hosting`, and click on the _Endpoint_ link. The app should be running on that URL. + +## Deploying in a subfolder of an existing server + +Suppose you want users to access the app on `https:///web-app` + +_Step 1:_ Configure webpack to inject necessary environment variables into the app + +- Changes below are made to `internals/webpack/webpack.base.babel.js` file. + +```diff ++ const BUILD_FOLDER_PATH = process.env.BUILD_FOLDER_PATH || 'build'; ++ const PUBLIC_PATH = process.env.PUBLIC_PATH || '/'; +``` + +```diff +- path: path.resolve(process.cwd(), 'build'), +- publicPath: '/', ++ path: path.resolve(process.cwd(), BUILD_FOLDER_PATH), ++ publicPath: PUBLIC_PATH, +``` + +```diff +# inside EnvironmentPlugin ++ PUBLIC_PATH: '/', +``` + +_Step 2:_ add `basename` to the history + +- Changes below are made to `app/utils/history.js` file. + +```diff +- const history = createHistory(); ++ const basename = process.env.PUBLIC_PATH; ++ const history = createHistory({ basename }); +``` + +_Step 3:_ Run `PUBLIC_PATH='/web-app/' BUILD_FOLDER_PATH='build/web-app' npm run build`, to save production build inside `./build/web-app` folder. + +_Step 4:_ Upload/Place the created `web-app` folder in your server's web-root folder + +_Endpoint_ The app should be accessible on `https:///web-app`. + +_NOTE_ that this has been tested on both APACHE and NGINX servers. + +## AWS Elastic Beanstalk + +Please refer to to issue [#2566](https://github.com/react-boilerplate/react-boilerplate/issues/2566) for more explanation. + +### Pre-requisites + +1. Create an account on [AWS console](https://console.aws.amazon.com/) +2. Install EB CLI ([AWS documentation](https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/eb-cli3-install.html?icmpid=docs_elasticbeanstalk_console#eb-cli3-install.cli-only)) +3. Create your [AWS EB profile](https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/eb-cli3-configuration.html#eb-cli3-profile). + In case you are using a continous deployment tool, you can create another user + for your CD tool as well. +4. Create your Elastic Beanstalk [application](https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/applications.html) and [environment](https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/using-features.managing.html) (either via CLI or web console) +5. [Configure your EB CLI](https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/eb-cli3-configuration.html). You should have a `.elasticbeanstalk/config.yml` if properly configured + +### Configuration + +_Step 1:_ Add AWS EB start scripts in _package.json_: `"aws-eb:prod": "npm run build && npm run start:prod"` + +_Step 2:_ Create a `.ebextensions/aws.config` file: + +```yaml +# Check https://github.com/react-boilerplate/react-boilerplate/issues/2566 for details +option_settings: + aws:elasticbeanstalk:container:nodejs: + NodeCommand: 'npm run aws-eb:prod' + aws:elasticbeanstalk:application:environment: + NPM_USE_PRODUCTION: false +``` + +In the likely case of multiple environment, remove the `NodeCommand` entry and +manually configure it per environment in the web console: _Configuration > Software > Node command_. + +_Step 3:_ Create a `.npmrc` file: + +``` +# Check https://github.com/react-boilerplate/react-boilerplate/issues/2566 for details +unsafe-perm=true +``` + +_Step 4:_ commit your changes and deploy via EB CLI: + +```sh +eb deploy {target environment name} +``` diff --git a/client/docs/general/editor.md b/client/docs/general/editor.md new file mode 100644 index 0000000..792749a --- /dev/null +++ b/client/docs/general/editor.md @@ -0,0 +1,40 @@ +# Setting Up Your Editor + +You can edit React Boilerplate using any editor or IDE, but there are a few extra steps that you can take to make sure your coding experience is as good as it can be. + +## VS Code + +To get the best editing experience with [VS Code](https://code.visualstudio.com), create a [`jsconfig.json`](https://code.visualstudio.com/Docs/languages/javascript#_javascript-projects-jsconfigjson) file at the root of your project: + +```json +{ + "compilerOptions": { + "baseUrl": "app", + "module": "commonjs", + "target": "es2016", + "jsx": "react" + }, + "exclude": ["node_modules", "**/node_modules/*"] +} +``` + +This `jsconfig.json` file tells VS Code to treat all JS files as part of the same project, improving IntelliSense, code navigation, and refactoring. You can configure project wide settings in using the `jsconfig.json`, such as only allowing functions from the ES5 standard library, or even enable [more advanced type checking for JS files](https://code.visualstudio.com/docs/languages/javascript#_type-checking) + +# ESLint + Prettier integration + +You can also get VSCode to understand your project's static code analysis setup. If you do this: + +- You'll see any warnings or errors directly within VSCode +- VSCode can also automatically fix or format your code for you + +To make this happen, install both the ESLint and Prettier extensions for VSCode and add the following to either your User or Workspace Settings: + +```json +{ + "editor.formatOnSave": true, + "prettier.eslintIntegration": true, + "eslint.run": "onSave" +} +``` + +Here's also a detailed video on the topic: [How to Setup VS Code + Prettier + ESLint](https://www.youtube.com/watch?v=YIvjKId9m2c) diff --git a/client/docs/general/faq.md b/client/docs/general/faq.md new file mode 100644 index 0000000..b2ade57 --- /dev/null +++ b/client/docs/general/faq.md @@ -0,0 +1,260 @@ +# Frequently Asked Questions + +- [Where are Babel, Prettier and ESLint configured?](#where-are-babel-prettier-and-eslint-configured) +- [Where are the files coming from when I run `npm start`?](#where-are-the-files-coming-from-when-i-run-npm-start) +- [How do I fix `Error: listen EADDRINUSE 127.0.0.1:3000`?](#how-do-i-fix-error-listen-eaddrinuse-1270013000) + - [OS X / Linux:](#os-x--linux) + - [Windows](#windows) +- [Issue with local caching when running in production mode (F5 / ctrl+F5 / cmd+r weird behavior)](#issue-with-local-caching-when-running-in-production-mode-f5--ctrlf5--cmdr-weird-behavior) + - [Quick fix on your local browser:](#quick-fix-on-your-local-browser) + - [Full in-depth explanation](#full-in-depth-explanation) +- [Local webfonts not working for development](#local-webfonts-not-working-for-development) +- [Non-route containers](#non-route-containers) + - [Where do I put the reducer?](#where-do-i-put-the-reducer) +- [Use CI with bitbucket pipelines](#use-ci-with-bitbucket-pipelines) +- [How to keep my project up-to-date with `react-boilerplate`?](#how-to-keep-my-project-up-to-date-with-react-boilerplate) +- [How to turn off Webpack performance warnings after production build?](#how-to-turn-off-webpack-performance-warnings-after-production-build) +- [Styles getting overridden?](#styles-getting-overridden) +- [Have another question?](#have-another-question) + +## Where are Babel, Prettier and ESLint configured? + +ESLint, Babel and Prettier all have their own config files in the root of the project. Same for Jest and stylelint. + +## Where are the files coming from when I run `npm start`? + +In development Webpack compiles your application runs it in-memory. Only when +you run `npm run build` will it write to disk and preserve your bundled +application across computer restarts. + +## How do I fix `Error: listen EADDRINUSE 127.0.0.1:3000`? + +This simply means that there's another process already listening on port 3000. +The fix is to kill the process and rerun `npm start`. + +### OS X / Linux: + +1. Find the process id (PID): + + ```Shell + ps aux | grep node + ``` + + > This will return the PID as the value following your username: + > + > ```Shell + > janedoe 29811 49.1 2.1 3394936 356956 s004 S+ 4:45pm 2:40.07 node server + > ``` + > + > Note: If nothing is listed, you can try `lsof -i tcp:3000` + +2. Then run + ```Shell + kill -9 YOUR_PID + ``` + > e.g. given the output from the example above, `YOUR_PID` is `29811`, hence + > that would mean you would run `kill -9 29811` + +### Windows + +1. Find the process id (PID): + + ```Shell + netstat -a -o -n + ``` + + > This will return a list of running processes and the ports they're + > listening on: + > + > ``` + > Proto Local Address Foreign Address State PID + > TCP 0.0.0.0:25 0.0.0.0:0 Listening 4196 + > ... + > TCP 0.0.0.0:3000 0.0.0.0:0 Listening 28344 + > ``` + + ``` + + ``` + +1. Then run + ```Shell + taskkill /F /PID YOUR_PID + ``` + > e.g. given the output from the example above, `YOUR_PID` is `28344`, hence + > that would mean you would run `taskkill /F /PID 28344` + +## Issue with local caching when running in production mode (F5 / ctrl+F5 / cmd+r weird behavior) + +Your production site isn't working? You update the code and nothing changes? It drives you insane? + +#### Quick fix on your local browser: + +To fix it on your local browser, just do the following. (Suited when you're testing the production mode locally) + +`Chrome dev tools > Application > Clear Storage > Clear site data` _(Chrome)_ + +#### Full in-depth explanation + +Read more at https://github.com/NekR/offline-plugin/blob/master/docs/updates.md + +## Local webfonts not working for development + +In development mode CSS sourcemaps require that styling is loaded by blob://, +resulting in browsers resolving font files relative to the main document. + +A way to use local webfonts in development mode is to add an absolute +output.publicPath in webpack.dev.babel.js, with protocol. + +```javascript +// webpack.dev.babel.js + +output: { + publicPath: 'http://127.0.0.1:3000/', + /* … */ +}, +``` + +## Non-route containers + +> Note: Container will always be nested somewhere below a route. Even if there's dozens of components +> in between, somewhere up the tree will be route. (maybe only "/", but still a route) + +### Where do I put the reducer? + +While you can include the reducer statically in `reducers.js`, we don't recommend this as you lose +the benefits of code splitting. Instead, add it as a _composed reducer_. This means that you +pass actions onward to a second reducer from a lower-level route reducer like so: + +```JS +// Main route reducer + +function myReducerOfRoute(state, action) { + switch (action.type) { + case SOME_OTHER_ACTION: + return someOtherReducer(state, action); + } +} +``` + +That way, you still get the code splitting at route level, but avoid having a static `combineReducers` +call that includes all of them by default. + +_See [this and the following lesson](https://egghead.io/lessons/javascript-redux-reducer-composition-with-arrays?course=getting-started-with-redux) of the egghead.io Redux course for more information about reducer composition!_ + +## Use CI with bitbucket pipelines + +Your project is on bitbucket? Take advantage of the pipelines feature (Continuous Integration) by creating a 'bitbucket-pipelines.yml' file at the root of the project and use the following code to automatically test your app at each commit: + +```YAML +image: gwhansscheuren/bitbucket-pipelines-node-chrome-firefox + +pipelines: + default: + - step: + script: + - node --version + - npm --version + - npm install + - npm test +``` + +## How to keep my project up-to-date with `react-boilerplate`? + +While it's possible to keep your project up-to-date or "in sync" with `react-boilerplate`, it's usually +very difficult and is therefore **_at your own risk_** and not recommended. You should not need to do it either, as +every version you use will be amazing! There is a long term goal to make this much easier but no ETA at the moment. + +## How to turn off Webpack performance warnings after production build? + +Webpack recommends having those performance hints turned off in development but to keep them on in production. If you still want to disable them, add the next lines to the config in `webpack.prod.babel.js`: + +```js +performance: { + hints: false; +} +``` + +You can find more information about the `performance` option (how to change maximum allowed size of a generated file, how to exclude some files from being checked and so on) in the [Webpack documentation](https://webpack.js.org/configuration/performance/). + +## Styles getting overridden? + +There is a strong chance that your styles are getting imported in the wrong order. Confused? +Let me try and explain with an example! + +```javascript +// MyStyledComponent.js +const MyStyledComponent = styled.div` + background-color: green; +`; +``` + +```css +/* styles.css */ +.alert { + background-color: red; +} +``` + +```javascript +// ContrivedExample.js +import MyStyledComponent from './MyStyledComponent'; +import './styles.css'; + +const ContrivedExample = props => ( + {props.children} +); +``` + +With the magic of [webpack](https://webpack.js.org/), both `MyStyledComponent.js` and `styles.css` +will each generate a stylesheet that will be injected at the end of `` and applied to `` +via the [`class` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes#attr-class). + +So, will `` have a green background or a red background? + +Applying the rules of [specificity](https://developer.mozilla.org/en/docs/Web/CSS/Specificity), you +may think red as `styles.css` was imported last. Unfortunately, at the time of writing +an open issue ["CSS resolving order"](https://github.com/webpack/webpack/issues/215) +means you cannot control the order in which the stylesheets are injected. So, with this contrived +example, the background could be either green or red. + +To resolve the issue, you can either: + +**1) Increase the specificity of the CSS you want to win** + +```css +/* styles.css (imported css to win) */ +.alert.alert { + background-color: red; +} +``` + +```javascript +// MyStyledComponent.js (styled-component css to win) +const MyStyledComponent = styled.div` + && { + background-color: green; + } +`; +``` + +**2) Import the CSS in the `` of your `index.html` manually** + +This is a good choice if you are having issues with third-party styles + +```javascript +// Import bootstrap style (e.g. move this into the of index.html) +import 'bootstrap/dist/css/bootstrap.min.css'; +``` + +**3) Change the position of `` in the rendering of ``** + +You can do that inside `containers/App/index.js`. + +More information is available in the [official documentation](https://github.com/styled-components/styled-components/blob/master/docs/existing-css.md). + +## Have another question? + +Submit an [issue](https://github.com/react-boilerplate/react-boilerplate/issues), +hop onto the [Spectrum chat](https://spectrum.chat/react-boilerplate) +or contact Max direct on [twitter](https://twitter.com/mxstbr)! diff --git a/client/docs/general/files.md b/client/docs/general/files.md new file mode 100644 index 0000000..ce9e0d4 --- /dev/null +++ b/client/docs/general/files.md @@ -0,0 +1,34 @@ +# Configuration: A Glossary + +A guide to the configuration files for this project: where they live and what +they do. + +## The root folder + +- `.editorconfig`: Sets the default configuration for certain files across editors. (e.g. indentation) + +- `.gitattributes`: Normalizes how `git`, the version control system this boilerplate uses, handles certain files. + +- `.gitignore`: Tells `git` to ignore certain files and folders which don't need to be version controlled, like the build folder. + +- `.travis.yml` and `appveyor.yml`: Continuous Integration configuration
+ This boilerplate uses [Travis CI](https://travis-ci.com) for Linux environments + and [AppVeyor](https://www.appveyor.com/) for Windows platforms, but feel free + to swap either out for your own choice of CI. + +- `package.json`: Our `npm` configuration file has three functions: + + 1. It's where Babel and ESLint are configured + 1. It's the API for the project: a consistent interface for all its controls + 1. It lists the project's package dependencies + + Baking the config in is a slightly unusual set-up, but it allows us to keep + the project root as uncluttered and grokkable-at-a-glance as possible. + +## The `./internals` folder + +This is where the bulk of the tooling configuration lives, broken out into +recognisable units of work. + +Feel free to change anything you like but don't be afraid to [ask upfront](https://spectrum.chat/react-boilerplate) +whether you should: build systems are easy to break! diff --git a/client/docs/general/gotchas.md b/client/docs/general/gotchas.md new file mode 100644 index 0000000..c36912a --- /dev/null +++ b/client/docs/general/gotchas.md @@ -0,0 +1,114 @@ +# Gotchas + +These are some things to be aware of when using this boilerplate. + +- [Special images in HTML files](#special-images-in-html-files) +- [Load reducers optimistically](#load-reducers-optimistically) +- [Exclude modules from Babel processing](#exclude-modules-from-babel-processing) +- [Running tests in `watch` mode](#running-tests-in-watch-mode) +- [When in doubt, re-install!](#when-in-doubt-re-install) +- [Cleaning up Jest cache](#cleaning-up-jest-cache) +- [Using short_name in Web App manifest](#using-short_name-in-web-app-manifest) + +## Special images in HTML files + +If you specify your images in the `.html` files using the `` tag, everything +will work fine. The problem comes up if you try to include images using anything +except that tag, like meta tags: + +```HTML + +``` + +The webpack `html-loader` does not recognise this as an image file and will not +transfer the image to the build folder. To get webpack to transfer them, you +have to import them with the file loader in your JavaScript somewhere, e.g.: + +```JavaScript +import 'file?name=[name].[ext]!../img/yourimg.png'; +``` + +Then webpack will correctly transfer the image to the build folder. + +## Load reducers optimistically + +If you have containers that should be available throughout the app, like a `NavigationBar` (they aren't route specific), you need to add their respective reducers to the root reducer with the help of `combineReducers`. + +```js +// In app/reducers.js + +... +import { combineReducers } from 'redux'; +... + +import navigationBarReducer from 'containers/NavigationBar/reducer'; + +export default function createReducer(injectedReducers = {}) { + const rootReducer = combineReducers({ + global: globalReducer, + language: languageProviderReducer, + router: connectRouter(history), + navigationBar: navigationBarReducer, + ...injectedReducers, + }); + + return rootReducer; +} +``` + +## Exclude modules from Babel processing + +You need to exclude packages which are not intended to be processed by babel. For e.g. Server packages such as 'express' or a CSS file. Just add the package name to `exclude` array in `internals/config.js` and you're all set! + +```js +// in internals/config.js + +exclude: [ + 'chalk', + 'compression', + 'cross-env', + 'express', + 'ip', + 'minimist', + 'sanitize.css', + 'your-unwanted-package', <- add your-unwanted-package + ... +] +``` + +## Running tests in `watch` mode + +If you are unable to run tests in watch mode, you may have to install `watchman` for this to work. If you're using a Mac, simply run `brew install watchman` + +You can also install `watchman` from source. Please visit their [official guide](https://facebook.github.io/watchman/docs/install.html) for more information. + +## When in doubt, re-install! + +If you're facing any inexplicable problems while installing dependencies, building your project or running tests, try reinstalling dependencies. It works for most cases. Run the following commands in the exact order given: + +Remove node_modules + +- `rm -rf node_modules` + +Clear cache + +- `npm cache clean` + +Re-install dependencies + +- `npm install` + +Build project + +- `npm run build` + +## Cleaning up Jest cache + +By default, Jest caches transformed modules, which may lead to faulty coverage reports. To prevent this, you'll have to clear the cache by running `npm run test -- --no-cache` as pointed out in [Jest docs](https://facebook.github.io/jest/docs/cli.html#cache) + +## Using short_name in Web App manifest + +When there's insufficient space to display an app's full name it is truncated. +This happens with app launcher and new tab in Chrome for Android. +The `short_name` field allows a _12 character or less abbreviation_. +It also addresses any problems in testing the PWA in Lighthouse. diff --git a/client/docs/general/introduction.md b/client/docs/general/introduction.md new file mode 100644 index 0000000..1dc2919 --- /dev/null +++ b/client/docs/general/introduction.md @@ -0,0 +1,219 @@ +# The Hitchhiker's Guide to `react-boilerplate` + +The [`README.md`](https://github.com/react-boilerplate/react-boilerplate#features) gives you adequate information on how to clone boilerplate files, install dependencies and launch the example app. + +Once you've done that, this document is intended to give you a taste of how `react-boilerplate` works. It still assumes basic knowledge of React, Redux and `react-router`. **If you're completely new to React, please refer to https://github.com/petehunt/react-howto instead!** + +This is a production-ready boilerplate, and as such optimized for browsers, not for beginners. It includes tools to help you manage performance, asynchrony, styling, everything you need to build a _real_ application. Before you get your hands dirty with the source code, we'd like you to go through a checklist that will help you determine whether or not you're ready to use this boilerplate. It's not because we're _holier-than-thou_, but rather because we genuinely want to save you the frustration. + +Opening an issue is the fastest way to draw the attention of the team, but please make it a point to read the [docs](https://github.com/react-boilerplate/react-boilerplate/tree/master/docs) and [contribution instructions](https://github.com/react-boilerplate/react-boilerplate/blob/master/.github/CONTRIBUTING.md) before you do. The issues section is specifically used for pointing out defects and suggesting enhancements. If you have a question about one of the tools please refer to StackOverflow instead. + +## Tech Stack + +Here's a curated list of packages that you should be at least familiar with before starting your awesome project. However, the best way to see a complete list of the dependencies is to check [package.json](https://github.com/react-boilerplate/react-boilerplate/blob/master/package.json). + +### Core + +- [ ] [React](https://facebook.github.io/react/) +- [ ] [React Router](https://github.com/ReactTraining/react-router) +- [ ] [Redux](http://redux.js.org/) +- [ ] [Redux Saga](https://redux-saga.github.io/redux-saga/) +- [ ] [Reselect](https://github.com/reactjs/reselect) +- [ ] [Immer](https://github.com/mweststrate/immer) +- [ ] [Styled Components](https://github.com/styled-components/styled-components) + +### Unit Testing + +- [ ] [Jest](http://facebook.github.io/jest/) +- [ ] [react-testing-library](https://github.com/kentcdodds/react-testing-library) + +### Linting + +- [ ] [ESLint](http://eslint.org/) +- [ ] [Prettier](https://prettier.io/) +- [ ] [stylelint](https://stylelint.io/) + +Note that while `react-boilerplate` includes a lot of features, many of them are optional and you can find instructions in the docs on how to remove... + +- [`redux-saga` or `reselect`](https://github.com/react-boilerplate/react-boilerplate/blob/master/docs/js/remove.md) +- [offline-first, add to homescreen, performant web font loading and image optimisation](https://github.com/react-boilerplate/react-boilerplate/blob/master/docs/general/remove.md) +- [`sanitize.css`](https://github.com/react-boilerplate/react-boilerplate/blob/master/docs/css/remove.md) +- [i18n (i.e. `react-intl`)](https://github.com/react-boilerplate/react-boilerplate/blob/0f88f55ed905f8432c3dd7b452d713df5fb76d8e/docs/js/i18n.md#removing-i18n-and-react-intl) + +## Project Structure + +Let's start with understanding why we have chosen our particular structure. It has been an [evolving discussion](https://github.com/react-boilerplate/react-boilerplate/issues/27), and if you have an afternoon or two we recommend you read the full thread. + +In any case, here's the TL;DR: + +- You will write your app in the `app` folder. This is the folder you will spend most, if not all, of your time in. +- Configuration, generators and templates are in the `internals` folder. +- The `server` folder contains development and production server configuration files. + +### `app/` + +We use the [container/component architecture](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0#.4rmjqneiw). `containers/` contains React components which are connected to the redux store. `components/` contains dumb React components which depend on containers for data. **Container components care about how things work, while components care about how things look.** + +We've found that for many applications treating single pages (e.g. the LoginPage, the HomePage, etc.) as containers and their small parts (e.g. the Login form, the Navigation bar) as components works well, but there are no rigid rules. **Bend the architecture to the needs of your app, nothing is set in stone!** + +### `internals/` + +You can call this area the "engine" of your app. Your source code cannot be executed as-is in the web browser. It needs to pass through webpack to get converted into a version of Javascript that web browsers understand. While it's certainly helpful to understand what's happening here, for real world usage, you won't have to mess around with this folder much. + +- `internals/webpack`: You'll most probably use ECMAScript 6 or ECMAScript 7 to write the source code of your app. webpack takes care of making it compatible with a majority of browsers. + +> ([ECMAScript](http://stackoverflow.com/a/33748400/5241520) is the standard for JavaScript. Most people are still using browsers which understand ECMAScript 5. So your code must be [transpiled](https://scotch.io/tutorials/javascript-transpilers-what-they-are-why-we-need-them) into browser-understandable code. To apply the transpiler to your source code, you will use webpack. Feeling the jitters already? [Don't worry](https://hackernoon.com/how-it-feels-to-learn-javascript-in-2016-d3a717dd577f#.d2uasw2n6). Take a tea-break and then read on.) + +- `internals/generators`: This folder has the code to scaffold out new components, containers and routes. Read [more about scaffolding](https://github.com/react-boilerplate/react-boilerplate/tree/master/docs/general#quick-scaffolding) in the docs. + +- `internals/mocks`: This folder contains mocks which Jest uses when testing your app, e.g. for images. + +The other folders are mostly for the maintainers and/or the setup, and you should absolutely never need to touch them so we are going skip them for the sake of brevity. + +### `server/` + +As the name suggests, this folder contains development and production server configuration. + +## Basic Building Blocks + +These days when musicians produce music, they record different parts of the song separately. So vocals, drums, guitar, bass may be played in separate sessions and when they're satisfied with their work, the sessions are combined into a beautiful song. In a similar fashion, let's understand the role of different technologies and in the end, we'll see how everything converges into a single application. + +You can launch the example app by running `npm start`. To fully understand its inner workings, you'll have to understand multiple technologies and how they interact. From this point, we're going into an overdrive of implementation details. We'll simplify the technical jargon as much as we can. Please bear with us here. + +### How does the application boot up? + +Like any other webpage your app starts with the [`app/index.html`](https://github.com/react-boilerplate/react-boilerplate/blob/master/app/index.html) file. React will render your application into `div#app` . + +But how do we include all of your react components into a single HTML file? That's where webpack comes into the picture. webpack will literally pack your application into small javascript files. These files will be injected into the `index.html` as `