commit 3cb1759648d2eeedab00a11391159fa2d6fb7c41 Author: Mastermindzh Date: Mon Jun 27 16:41:03 2022 +0200 - Updated to React 18 - Updated for public release - Git was reset for privacy reasons diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..c14b282 --- /dev/null +++ b/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["react-app"] +} diff --git a/.browserslistrc b/.browserslistrc new file mode 100644 index 0000000..b15c7fa --- /dev/null +++ b/.browserslistrc @@ -0,0 +1,12 @@ +# This file is used by the build system to adjust CSS and JS output to support the specified browsers below. +# For additional information regarding the format and rule options, please see: +# https://github.com/browserslist/browserslist#queries + +# You can see what browsers were selected by your queries by running: +# npx browserslist + +> 0.5% +last 2 versions +Firefox ESR +not dead +not IE 9-11 # For IE 9-11 support, remove 'not'. diff --git a/.dockerignore b/.dockerignore new file mode 100755 index 0000000..36ad720 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,38 @@ +.git +.github +.vscode +coverage + +# OS generated files # +.DS_Store +ehthumbs.db +Icon? +Thumbs.db + +# Node Files # +node_modules +npm-debug.log +npm-debug.log.* + +# Typing # +src/typings/tsd +typings +tsd_typings + +# Dist # +.awcache +.webpack.json +compiled +dll + +# IDE # +.idea +*.swp + + +# Angular # +*.ngfactory.ts +*.css.shim.ts +*.ngsummary.json +*.shim.ngstyle.ts + diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e84a7f6 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true +end_of_line = lf +max_line_length = 120 + +[*.{ts,js,jsx,tsx}] +quote_type = double + +[*.md] +max_line_length = off +trim_trailing_whitespace = false +insert_final_newline = false diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..2ab62e7 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,11 @@ +*.d.ts +main.browser.ts +index.js +node_modules/* +*.e2e.* +src/environments/* +dist/** +development/** +deployment/** +*.spec.ts +*.css diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..22c0f3b --- /dev/null +++ b/.eslintrc @@ -0,0 +1,65 @@ +{ + "env": { + "browser": true, + "jest": true, + "es6": true + }, + "plugins": ["import"], + "extends": ["eslint:recommended", "prettier", "react-app", "react-app/jest"], + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module", + "babelOptions": { + "presets": [["babel-preset-react-app", false], "babel-preset-react-app/prod"] + } + }, + "rules": { + "no-console": [ + "error", + { + "allow": ["debug", "error"] + } + ], + "no-eval": "error", + "import/first": "error", + "camelcase": [ + "error", + { + "ignoreImports": true, + "ignoreDestructuring": true + } + ], + "consistent-return": "warn", + "comma-dangle": ["warn", "always-multiline"], + "constructor-super": "error", + "curly": "error", + "eol-last": "warn", + "eqeqeq": ["error", "smart"], + "import/order": 1, + "new-parens": "error", + "no-debugger": "error", + "no-fallthrough": "off", + "max-len": [ + "warn", + { + "code": 120 + } + ], + "no-shadow": [ + "error", + { + "hoist": "all" + } + ], + "no-trailing-spaces": "warn", + "no-underscore-dangle": "error", + "no-unsafe-finally": "error", + "no-var": "error", + "object-shorthand": "error", + "one-var": ["error", "never"], + "prefer-arrow/prefer-arrow-functions": "off", + "prefer-const": "error", + "radix": "off", + "space-in-parens": ["off", "never"] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c6564a5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,68 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# Compiled output +/dist +/build +/tmp +/out-tsc +# Only exists if Bazel was run +/bazel-out + +# Dependencies +/node_modules + +# Cordova +/www +/plugins +/platforms + +# Electron +/dist-electron +/dist-packages +/electron.main.js + +# IDEs and editors +.idea/* +!.idea/runConfigurations/ +!.idea/codeStyleSettings.xml +.project +.classpath +.c9/ +*.launch +.settings/ +xcuserdata/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Maven +/target +/log + +# Misc +/.sass-cache +/connect.lock +/coverage +/libpeerconnection.log +npm-debug.log +yarn-error.log +testem.log +/typings +/reports +/src/translations/template.* +/src/environments/.env.* + +# System Files +.DS_Store +Thumbs.db +bundle.zip + +#SonarCloud +.scannerwork +.angular/cache/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..b009dfb --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +lts/* diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..16ad73c --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,4 @@ +module.exports = { + ...require("@mastermindzh/prettier-config"), + trailingComma: "all", +}; diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..63a97f8 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,40 @@ +{ + "version": "0.2.0", + "compounds": [ + { + "name": "Start and Debug chrome", + "configurations": ["Start", "attach chrome"] + }, + { + "name": "Start and Debug firefox", + "configurations": ["Start", "attach firefox"] + } + ], + "configurations": [ + { + "type": "chrome", + "request": "launch", + "name": "attach chrome", + "url": "http://localhost:3000", + "webRoot": "${workspaceFolder}" + }, + { + "name": "Start", + "request": "launch", + "runtimeArgs": ["run", "start"], + "runtimeExecutable": "npm", + "skipFiles": ["/**"], + "type": "pwa-node" + }, + { + "type": "firefox", + "request": "launch", + "name": "attach firefox", + "url": "http://localhost:3000", + "webRoot": "${workspaceFolder}", + "enableCRAWorkaround": true, + "reAttach": true, + "reloadOnAttach": true + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..8351215 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,19 @@ +{ + "cSpell.words": [ + "browserslist", + "camelcase", + "flexbugs", + "Immer", + "pmmmwh", + "reduxjs", + "SVGR", + "tailwindcss", + "typeahead", + "uncompiled" + ], + "files.exclude": { + "**/.git": true, + "coverage": true, + "node_modules": true + } +} diff --git a/.vscode/typescriptreact.code-snippets b/.vscode/typescriptreact.code-snippets new file mode 100644 index 0000000..7a13705 --- /dev/null +++ b/.vscode/typescriptreact.code-snippets @@ -0,0 +1,72 @@ +{ + "Create Typed Functional Component": { + "prefix": ["rtfc", "rfc", "trfc", "react-typed-functional-component"], + "body": [ + "import { FunctionComponent } from \"react\";", + "", + "type Props = {}", + "", + "export const ${1:${TM_FILENAME_BASE}}: FunctionComponent = () => {", + " return

${1:${TM_FILENAME_BASE}}

;", + "};", + "" + ], + "description": "Create a simple functional component" + }, + "Create Typed Functional Component With Children": { + "prefix": ["rtfcc", "rfcc", "react-typed-functional-component-children"], + "body": [ + "import { FunctionComponent, ReactNode } from \"react\";", + "", + "type Props = { children?: ReactNode };", + "", + "export const ${1:${TM_FILENAME_BASE}}: FunctionComponent = ({ children }) => {", + " return <>{children};", + "};", + "" + ] + }, + "Create Typed Functional Container": { + "prefix": ["rtfcontainer", "rtc", "redux-container"], + "body": [ + "import { useAppDispatch, useAppSelector } from \"app/hooks\";", + "", + "export function ${1:${TM_FILENAME_BASE}}() {", + " //const {} = useAppSelector(select${1:${TM_FILENAME_BASE}}State);", + " const dispatch = useAppDispatch();", + "", + " return (", + "
", + "

${1:${TM_FILENAME_BASE}}

", + "
", + " );", + "}", + "" + ] + }, + "Create Empty Reducer Slice": { + "prefix": ["redux-slice", "rs", "ers"], + "body": [ + "import { createSlice } from \"@reduxjs/toolkit\";", + "import { RootState } from \"app/store\";", + "", + "const initialState = {", + " value: 0,", + " status: \"idle\",", + "};", + "", + "export const ${1:${TM_FILENAME_BASE}}Slice = createSlice({", + " name: \"${1:${TM_FILENAME_BASE}}\",", + " initialState,", + " reducers: {},", + " extraReducers: (builder) => {},", + "});", + "", + "export const {} = ${1:${TM_FILENAME_BASE}}Slice.actions;", + "", + "export const select${1:${TM_FILENAME_BASE}}State = (state: RootState) => state.${1:${TM_FILENAME_BASE}};", + "", + "export default ${1:${TM_FILENAME_BASE}}Slice.reducer;" + ] + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..19d692b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project will be documented in this file. +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2022-06-27 + +- Updated to React 18 +- Updated for public release + - Git was reset for privacy reasons diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..469c639 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM nginx:1.17.3 + +RUN mkdir -p /usr/share/nginx/html +COPY dist/ /usr/share/nginx/html +COPY docker/prod/nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/README.md b/README.md new file mode 100644 index 0000000..5beeead --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# react-starter-kit + +Web project starter kit including modern tools and workflow based on +[create-react-app](https://create-react-app.dev/), best practices from the community, a scalable base template and a good learning base. + +Includes: + +- Redux-toolkit +- Vscode setup (debugging + snippets) +- Jest, @testing-library and Cypress +- Immer + + + +- [Getting started](#getting-started) +- [Project structure](#project-structure) + + + +## Getting started + +1. `npm install` +2. `npm start` ([localhost:3000](http://localhost:3000)) +3. `npm test` (run jest + coverage on [localhost:8080](http://localhost:8080)) + +## Project structure + +Only the important files are shown + +```bash +. +├── .vscode # vscode setup (debug, snippets, etc) +├── config # tool configuration +├── dist # production version +├── public # directory with public files (config, icons, etc) +├── scripts # Modified default create-react-app scripts +├── src # application source +│ ├── app # redux-toolkit hooks + store +│ └── infrastructure # infrastructure code (wrappers, navigation, config file class) +├── CHANGELOG.md # update this whenever you update the application +├── Dockerfile # Dockerfile to build nginx container +├── jest.config.js # configuration for jest +├── package.json +├── README.md # keep this up to date +└── tsconfig.json +``` diff --git a/config/env.js b/config/env.js new file mode 100644 index 0000000..5525db4 --- /dev/null +++ b/config/env.js @@ -0,0 +1,101 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const paths = require("./paths"); + +// Make sure that including paths.js after env.js will read .env variables. +delete require.cache[require.resolve("./paths")]; + +const NODE_ENV = process.env.NODE_ENV; +if (!NODE_ENV) { + throw new Error("The NODE_ENV environment variable is required but was not specified."); +} + +// https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use +const dotenvFiles = [ + `${paths.dotenv}.${NODE_ENV}.local`, + // Don't include `.env.local` for `test` environment + // since normally you expect tests to produce the same + // results for everyone + NODE_ENV !== "test" && `${paths.dotenv}.local`, + `${paths.dotenv}.${NODE_ENV}`, + paths.dotenv, +].filter(Boolean); +// Load environment variables from .env* files. Suppress warnings using silent +// if this file is missing. dotenv will never modify any environment variables +// that have already been set. Variable expansion is supported in .env files. +// https://github.com/motdotla/dotenv +// https://github.com/motdotla/dotenv-expand +dotenvFiles.forEach((dotenvFile) => { + if (fs.existsSync(dotenvFile)) { + require("dotenv-expand")( + require("dotenv").config({ + path: dotenvFile, + }), + ); + } +}); + +// We support resolving modules according to `NODE_PATH`. +// This lets you use absolute paths in imports inside large monorepos: +// https://github.com/facebook/create-react-app/issues/253. +// It works similar to `NODE_PATH` in Node itself: +// https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders +// Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored. +// Otherwise, we risk importing Node.js core modules into an app instead of webpack shims. +// https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421 +// We also resolve them to make sure all tools using them work consistently. +const appDirectory = fs.realpathSync(process.cwd()); +process.env.NODE_PATH = (process.env.NODE_PATH || "") + .split(path.delimiter) + .filter((folder) => folder && !path.isAbsolute(folder)) + .map((folder) => path.resolve(appDirectory, folder)) + .join(path.delimiter); + +// Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be +// injected into the application via DefinePlugin in webpack configuration. +const REACT_APP = /^REACT_APP_/i; + +function getClientEnvironment(publicUrl) { + const raw = Object.keys(process.env) + .filter((key) => REACT_APP.test(key)) + .reduce( + (env, key) => { + env[key] = process.env[key]; + return env; + }, + { + // Useful for determining whether we’re running in production mode. + // Most importantly, it switches React into the correct mode. + NODE_ENV: process.env.NODE_ENV || "development", + // Useful for resolving the correct path to static assets in `public`. + // For example, . + // This should only be used as an escape hatch. Normally you would put + // images into the `src` and `import` them in code to get their paths. + PUBLIC_URL: publicUrl, + // We support configuring the sockjs pathname during development. + // These settings let a developer run multiple simultaneous projects. + // They are used as the connection `hostname`, `pathname` and `port` + // in webpackHotDevClient. They are used as the `sockHost`, `sockPath` + // and `sockPort` options in webpack-dev-server. + WDS_SOCKET_HOST: process.env.WDS_SOCKET_HOST, + WDS_SOCKET_PATH: process.env.WDS_SOCKET_PATH, + WDS_SOCKET_PORT: process.env.WDS_SOCKET_PORT, + // Whether or not react-refresh is enabled. + // It is defined here so it is available in the webpackHotDevClient. + FAST_REFRESH: process.env.FAST_REFRESH !== "false", + }, + ); + // Stringify all values so we can feed into webpack DefinePlugin + const stringified = { + "process.env": Object.keys(raw).reduce((env, key) => { + env[key] = JSON.stringify(raw[key]); + return env; + }, {}), + }; + + return { raw, stringified }; +} + +module.exports = getClientEnvironment; diff --git a/config/getHttpsConfig.js b/config/getHttpsConfig.js new file mode 100644 index 0000000..7db6695 --- /dev/null +++ b/config/getHttpsConfig.js @@ -0,0 +1,58 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); +const chalk = require("react-dev-utils/chalk"); +const paths = require("./paths"); + +// Ensure the certificate and key provided are valid and if not +// throw an easy to debug error +function validateKeyAndCerts({ cert, key, keyFile, crtFile }) { + let encrypted; + try { + // publicEncrypt will throw an error with an invalid cert + encrypted = crypto.publicEncrypt(cert, Buffer.from("test")); + } catch (err) { + throw new Error(`The certificate "${chalk.yellow(crtFile)}" is invalid.\n${err.message}`); + } + + try { + // privateDecrypt will throw an error with an invalid key + crypto.privateDecrypt(key, encrypted); + } catch (err) { + throw new Error(`The certificate key "${chalk.yellow(keyFile)}" is invalid.\n${err.message}`); + } +} + +// Read file and throw an error if it doesn't exist +function readEnvFile(file, type) { + if (!fs.existsSync(file)) { + throw new Error( + `You specified ${chalk.cyan(type)} in your env, but the file "${chalk.yellow(file)}" can't be found.`, + ); + } + return fs.readFileSync(file); +} + +// Get the https config +// Return cert files if provided in env, otherwise just true or false +function getHttpsConfig() { + const { SSL_CRT_FILE, SSL_KEY_FILE, HTTPS } = process.env; + const isHttps = HTTPS === "true"; + + if (isHttps && SSL_CRT_FILE && SSL_KEY_FILE) { + const crtFile = path.resolve(paths.appPath, SSL_CRT_FILE); + const keyFile = path.resolve(paths.appPath, SSL_KEY_FILE); + const config = { + cert: readEnvFile(crtFile, "SSL_CRT_FILE"), + key: readEnvFile(keyFile, "SSL_KEY_FILE"), + }; + + validateKeyAndCerts({ ...config, keyFile, crtFile }); + return config; + } + return isHttps; +} + +module.exports = getHttpsConfig; diff --git a/config/jest/babelTransform.js b/config/jest/babelTransform.js new file mode 100644 index 0000000..7e2179a --- /dev/null +++ b/config/jest/babelTransform.js @@ -0,0 +1,29 @@ +"use strict"; + +const babelJest = require("babel-jest").default; + +const hasJsxRuntime = (() => { + if (process.env.DISABLE_NEW_JSX_TRANSFORM === "true") { + return false; + } + + try { + require.resolve("react/jsx-runtime"); + return true; + } catch (e) { + return false; + } +})(); + +module.exports = babelJest.createTransformer({ + presets: [ + [ + require.resolve("babel-preset-react-app"), + { + runtime: hasJsxRuntime ? "automatic" : "classic", + }, + ], + ], + babelrc: false, + configFile: false, +}); diff --git a/config/jest/cssTransform.js b/config/jest/cssTransform.js new file mode 100644 index 0000000..606cc27 --- /dev/null +++ b/config/jest/cssTransform.js @@ -0,0 +1,14 @@ +"use strict"; + +// This is a custom Jest transformer turning style imports into empty objects. +// http://facebook.github.io/jest/docs/en/webpack.html + +module.exports = { + process() { + return "module.exports = {};"; + }, + getCacheKey() { + // The output is always the same. + return "cssTransform"; + }, +}; diff --git a/config/jest/fileTransform.js b/config/jest/fileTransform.js new file mode 100644 index 0000000..449c2fe --- /dev/null +++ b/config/jest/fileTransform.js @@ -0,0 +1,40 @@ +"use strict"; + +const path = require("path"); +const camelcase = require("camelcase"); + +// This is a custom Jest transformer turning file imports into filenames. +// http://facebook.github.io/jest/docs/en/webpack.html + +module.exports = { + process(_src, filename) { + const assetFilename = JSON.stringify(path.basename(filename)); + + if (filename.match(/\.svg$/)) { + // Based on how SVGR generates a component name: + // https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6 + const pascalCaseFilename = camelcase(path.parse(filename).name, { + pascalCase: true, + }); + const componentName = `Svg${pascalCaseFilename}`; + return `const React = require('react'); + module.exports = { + __esModule: true, + default: ${assetFilename}, + ReactComponent: React.forwardRef(function ${componentName}(props, ref) { + return { + $$typeof: Symbol.for('react.element'), + type: 'svg', + ref: ref, + key: null, + props: Object.assign({}, props, { + children: ${assetFilename} + }) + }; + }), + };`; + } + + return `module.exports = ${assetFilename};`; + }, +}; diff --git a/config/modules.js b/config/modules.js new file mode 100644 index 0000000..7b78cda --- /dev/null +++ b/config/modules.js @@ -0,0 +1,134 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const paths = require("./paths"); +const chalk = require("react-dev-utils/chalk"); +const resolve = require("resolve"); + +/** + * Get additional module paths based on the baseUrl of a compilerOptions object. + * + * @param {Object} options + */ +function getAdditionalModulePaths(options = {}) { + const baseUrl = options.baseUrl; + + if (!baseUrl) { + return ""; + } + + const baseUrlResolved = path.resolve(paths.appPath, baseUrl); + + // We don't need to do anything if `baseUrl` is set to `node_modules`. This is + // the default behavior. + if (path.relative(paths.appNodeModules, baseUrlResolved) === "") { + return null; + } + + // Allow the user set the `baseUrl` to `appSrc`. + if (path.relative(paths.appSrc, baseUrlResolved) === "") { + return [paths.appSrc]; + } + + // If the path is equal to the root directory we ignore it here. + // We don't want to allow importing from the root directly as source files are + // not transpiled outside of `src`. We do allow importing them with the + // absolute path (e.g. `src/Components/Button.js`) but we set that up with + // an alias. + if (path.relative(paths.appPath, baseUrlResolved) === "") { + return null; + } + + // Otherwise, throw an error. + throw new Error( + chalk.red.bold( + "Your project's `baseUrl` can only be set to `src` or `node_modules`." + + " Create React App does not support other values at this time.", + ), + ); +} + +/** + * Get webpack aliases based on the baseUrl of a compilerOptions object. + * + * @param {*} options + */ +function getWebpackAliases(options = {}) { + const baseUrl = options.baseUrl; + + if (!baseUrl) { + return {}; + } + + const baseUrlResolved = path.resolve(paths.appPath, baseUrl); + + if (path.relative(paths.appPath, baseUrlResolved) === "") { + return { + src: paths.appSrc, + }; + } +} + +/** + * Get jest aliases based on the baseUrl of a compilerOptions object. + * + * @param {*} options + */ +function getJestAliases(options = {}) { + const baseUrl = options.baseUrl; + + if (!baseUrl) { + return {}; + } + + const baseUrlResolved = path.resolve(paths.appPath, baseUrl); + + if (path.relative(paths.appPath, baseUrlResolved) === "") { + return { + "^src/(.*)$": "/src/$1", + }; + } +} + +function getModules() { + // Check if TypeScript is setup + const hasTsConfig = fs.existsSync(paths.appTsConfig); + const hasJsConfig = fs.existsSync(paths.appJsConfig); + + if (hasTsConfig && hasJsConfig) { + throw new Error( + "You have both a tsconfig.json and a jsconfig.json. If you are using TypeScript please remove your jsconfig.json file.", + ); + } + + let config; + + // If there's a tsconfig.json we assume it's a + // TypeScript project and set up the config + // based on tsconfig.json + if (hasTsConfig) { + const ts = require(resolve.sync("typescript", { + basedir: paths.appNodeModules, + })); + config = ts.readConfigFile(paths.appTsConfig, ts.sys.readFile).config; + // Otherwise we'll check if there is jsconfig.json + // for non TS projects. + } else if (hasJsConfig) { + config = require(paths.appJsConfig); + } + + config = config || {}; + const options = config.compilerOptions || {}; + + const additionalModulePaths = getAdditionalModulePaths(options); + + return { + additionalModulePaths: additionalModulePaths, + webpackAliases: getWebpackAliases(options), + jestAliases: getJestAliases(options), + hasTsConfig, + }; +} + +module.exports = getModules(); diff --git a/config/paths.js b/config/paths.js new file mode 100644 index 0000000..34e7776 --- /dev/null +++ b/config/paths.js @@ -0,0 +1,72 @@ +"use strict"; + +const path = require("path"); +const fs = require("fs"); +const getPublicUrlOrPath = require("react-dev-utils/getPublicUrlOrPath"); + +// Make sure any symlinks in the project folder are resolved: +// https://github.com/facebook/create-react-app/issues/637 +const appDirectory = fs.realpathSync(process.cwd()); +const resolveApp = (relativePath) => path.resolve(appDirectory, relativePath); + +// We use `PUBLIC_URL` environment variable or "homepage" field to infer +// "public path" at which the app is served. +// webpack needs to know it to put the right + + + +
+ + diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..cf672cf --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,46 @@ +{ + "short_name": "react-starter-kit", + "name": "Starter kit for react", + "icons": [ + { + "src": "./icon/android-icon-36x36.png", + "sizes": "36x36", + "type": "image/png", + "density": "0.75" + }, + { + "src": "./icon/android-icon-48x48.png", + "sizes": "48x48", + "type": "image/png", + "density": "1.0" + }, + { + "src": "./icon/android-icon-72x72.png", + "sizes": "72x72", + "type": "image/png", + "density": "1.5" + }, + { + "src": "./icon/android-icon-96x96.png", + "sizes": "96x96", + "type": "image/png", + "density": "2.0" + }, + { + "src": "./icon/android-icon-144x144.png", + "sizes": "144x144", + "type": "image/png", + "density": "3.0" + }, + { + "src": "./icon/android-icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "density": "4.0" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/scripts/build.js b/scripts/build.js new file mode 100644 index 0000000..e148d85 --- /dev/null +++ b/scripts/build.js @@ -0,0 +1,198 @@ +"use strict"; + +// Do this as the first thing so that any code reading it knows the right env. +process.env.BABEL_ENV = "production"; +process.env.NODE_ENV = "production"; +process.env.BUILD_PATH = "./dist"; + +// Makes the script crash on unhandled rejections instead of silently +// ignoring them. In the future, promise rejections that are not handled will +// terminate the Node.js process with a non-zero exit code. +process.on("unhandledRejection", (err) => { + throw err; +}); + +// Ensure environment variables are read. +require("../config/env"); + +const path = require("path"); +const chalk = require("react-dev-utils/chalk"); +const fs = require("fs-extra"); +const bfj = require("bfj"); +const webpack = require("webpack"); +const configFactory = require("../config/webpack.config"); +const paths = require("../config/paths"); +const checkRequiredFiles = require("react-dev-utils/checkRequiredFiles"); +const formatWebpackMessages = require("react-dev-utils/formatWebpackMessages"); +const printHostingInstructions = require("react-dev-utils/printHostingInstructions"); +const FileSizeReporter = require("react-dev-utils/FileSizeReporter"); +const printBuildError = require("react-dev-utils/printBuildError"); + +const measureFileSizesBeforeBuild = FileSizeReporter.measureFileSizesBeforeBuild; +const printFileSizesAfterBuild = FileSizeReporter.printFileSizesAfterBuild; +const useYarn = fs.existsSync(paths.yarnLockFile); + +// These sizes are pretty large. We'll warn for bundles exceeding them. +const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; +const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024; + +const isInteractive = process.stdout.isTTY; + +// Warn and crash if required files are missing +if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) { + process.exit(1); +} + +const argv = process.argv.slice(2); +const writeStatsJson = argv.indexOf("--stats") !== -1; + +// Generate configuration +const config = configFactory("production"); + +// We require that you explicitly set browsers and do not fall back to +// browserslist defaults. +const { checkBrowsers } = require("react-dev-utils/browsersHelper"); +checkBrowsers(paths.appPath, isInteractive) + .then(() => { + // First, read the current file sizes in build directory. + // This lets us display how much they changed later. + return measureFileSizesBeforeBuild(paths.appBuild); + }) + .then((previousFileSizes) => { + // Remove all content but keep the directory so that + // if you're in it, you don't end up in Trash + fs.emptyDirSync(paths.appBuild); + // Merge with the public folder + copyPublicFolder(); + // Start the webpack build + return build(previousFileSizes); + }) + .then( + ({ stats, previousFileSizes, warnings }) => { + if (warnings.length) { + console.log(chalk.yellow("Compiled with warnings.\n")); + console.log(warnings.join("\n\n")); + console.log( + "\nSearch for the " + chalk.underline(chalk.yellow("keywords")) + " to learn more about each warning.", + ); + console.log("To ignore, add " + chalk.cyan("// eslint-disable-next-line") + " to the line before.\n"); + } else { + console.log(chalk.green("Compiled successfully.\n")); + } + + console.log("File sizes after gzip:\n"); + printFileSizesAfterBuild( + stats, + previousFileSizes, + paths.appBuild, + WARN_AFTER_BUNDLE_GZIP_SIZE, + WARN_AFTER_CHUNK_GZIP_SIZE, + ); + console.log(); + + const appPackage = require(paths.appPackageJson); + const publicUrl = paths.publicUrlOrPath; + const publicPath = config.output.publicPath; + const buildFolder = path.relative(process.cwd(), paths.appBuild); + printHostingInstructions(appPackage, publicUrl, publicPath, buildFolder, useYarn); + }, + (err) => { + const tscCompileOnError = process.env.TSC_COMPILE_ON_ERROR === "true"; + if (tscCompileOnError) { + console.log( + chalk.yellow( + "Compiled with the following type errors (you may want to check these before deploying your app):\n", + ), + ); + printBuildError(err); + } else { + console.log(chalk.red("Failed to compile.\n")); + printBuildError(err); + process.exit(1); + } + }, + ) + .catch((err) => { + if (err && err.message) { + console.log(err.message); + } + process.exit(1); + }); + +// Create the production build and print the deployment instructions. +function build(previousFileSizes) { + console.log("Creating an optimized production build..."); + + const compiler = webpack(config); + return new Promise((resolve, reject) => { + compiler.run((err, stats) => { + let messages; + if (err) { + if (!err.message) { + return reject(err); + } + + let errMessage = err.message; + + // Add additional information for postcss errors + if (Object.prototype.hasOwnProperty.call(err, "postcssNode")) { + errMessage += "\nCompileError: Begins at CSS selector " + err["postcssNode"].selector; + } + + messages = formatWebpackMessages({ + errors: [errMessage], + warnings: [], + }); + } else { + messages = formatWebpackMessages(stats.toJson({ all: false, warnings: true, errors: true })); + } + if (messages.errors.length) { + // Only keep the first error. Others are often indicative + // of the same problem, but confuse the reader with noise. + if (messages.errors.length > 1) { + messages.errors.length = 1; + } + return reject(new Error(messages.errors.join("\n\n"))); + } + if ( + process.env.CI && + (typeof process.env.CI !== "string" || process.env.CI.toLowerCase() !== "false") && + messages.warnings.length + ) { + // Ignore sourcemap warnings in CI builds. See #8227 for more info. + const filteredWarnings = messages.warnings.filter((w) => !/Failed to parse source map/.test(w)); + if (filteredWarnings.length) { + console.log( + chalk.yellow( + "\nTreating warnings as errors because process.env.CI = true.\n" + + "Most CI servers set it automatically.\n", + ), + ); + return reject(new Error(filteredWarnings.join("\n\n"))); + } + } + + const resolveArgs = { + stats, + previousFileSizes, + warnings: messages.warnings, + }; + + if (writeStatsJson) { + return bfj + .write(paths.appBuild + "/bundle-stats.json", stats.toJson()) + .then(() => resolve(resolveArgs)) + .catch((error) => reject(new Error(error))); + } + + return resolve(resolveArgs); + }); + }); +} + +function copyPublicFolder() { + fs.copySync(paths.appPublic, paths.appBuild, { + dereference: true, + filter: (file) => file !== paths.appHtml, + }); +} diff --git a/scripts/start.js b/scripts/start.js new file mode 100644 index 0000000..e439d80 --- /dev/null +++ b/scripts/start.js @@ -0,0 +1,128 @@ +"use strict"; + +// Do this as the first thing so that any code reading it knows the right env. +process.env.BABEL_ENV = "development"; +process.env.NODE_ENV = "development"; + +// Makes the script crash on unhandled rejections instead of silently +// ignoring them. In the future, promise rejections that are not handled will +// terminate the Node.js process with a non-zero exit code. +process.on("unhandledRejection", (err) => { + throw err; +}); + +// Ensure environment variables are read. +require("../config/env"); + +const fs = require("fs"); +const chalk = require("react-dev-utils/chalk"); +const webpack = require("webpack"); +const WebpackDevServer = require("webpack-dev-server"); +const clearConsole = require("react-dev-utils/clearConsole"); +const checkRequiredFiles = require("react-dev-utils/checkRequiredFiles"); +const { choosePort, createCompiler, prepareProxy, prepareUrls } = require("react-dev-utils/WebpackDevServerUtils"); +const openBrowser = require("react-dev-utils/openBrowser"); +const semver = require("semver"); +const paths = require("../config/paths"); +const configFactory = require("../config/webpack.config"); +const createDevServerConfig = require("../config/webpackDevServer.config"); +const getClientEnvironment = require("../config/env"); +const react = require(require.resolve("react", { paths: [paths.appPath] })); + +const env = getClientEnvironment(paths.publicUrlOrPath.slice(0, -1)); +const useYarn = fs.existsSync(paths.yarnLockFile); +const isInteractive = process.stdout.isTTY; + +// Warn and crash if required files are missing +if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) { + process.exit(1); +} + +// Tools like Cloud9 rely on this. +const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 3000; +const HOST = process.env.HOST || "0.0.0.0"; + +if (process.env.HOST) { + console.log( + chalk.cyan(`Attempting to bind to HOST environment variable: ${chalk.yellow(chalk.bold(process.env.HOST))}`), + ); + console.log(`If this was unintentional, check that you haven't mistakenly set it in your shell.`); + console.log(`Learn more here: ${chalk.yellow("https://cra.link/advanced-config")}`); + console.log(); +} + +// We require that you explicitly set browsers and do not fall back to +// browserslist defaults. +const { checkBrowsers } = require("react-dev-utils/browsersHelper"); +checkBrowsers(paths.appPath, isInteractive) + .then(() => { + // We attempt to use the default port but if it is busy, we offer the user to + // run on a different port. `choosePort()` Promise resolves to the next free port. + return choosePort(HOST, DEFAULT_PORT); + }) + .then((port) => { + if (port == null) { + // We have not found a port. + return; + } + + const config = configFactory("development"); + const protocol = process.env.HTTPS === "true" ? "https" : "http"; + const appName = require(paths.appPackageJson).name; + + const useTypeScript = fs.existsSync(paths.appTsConfig); + const urls = prepareUrls(protocol, HOST, port, paths.publicUrlOrPath.slice(0, -1)); + // Create a webpack compiler that is configured with custom messages. + const compiler = createCompiler({ + appName, + config, + urls, + useYarn, + useTypeScript, + webpack, + }); + // Load proxy config + const proxySetting = require(paths.appPackageJson).proxy; + const proxyConfig = prepareProxy(proxySetting, paths.appPublic, paths.publicUrlOrPath); + // Serve webpack assets generated by the compiler over a web server. + const serverConfig = { + ...createDevServerConfig(proxyConfig, urls.lanUrlForConfig), + host: HOST, + port, + }; + const devServer = new WebpackDevServer(serverConfig, compiler); + // Launch WebpackDevServer. + devServer.startCallback(() => { + if (isInteractive) { + clearConsole(); + } + + if (env.raw.FAST_REFRESH && semver.lt(react.version, "16.10.0")) { + console.log(chalk.yellow(`Fast Refresh requires React 16.10 or higher. You are using React ${react.version}.`)); + } + + console.log(chalk.cyan("Starting the development server...\n")); + openBrowser(urls.localUrlForBrowser); + }); + + ["SIGINT", "SIGTERM"].forEach(function (sig) { + process.on(sig, function () { + devServer.close(); + process.exit(); + }); + }); + + if (process.env.CI !== "true") { + // Gracefully exit when stdin ends + process.stdin.on("end", function () { + devServer.close(); + process.exit(); + }); + } + }) + .catch((err) => { + if (err && err.message) { + console.log(err.message); + } + process.exit(1); + }); diff --git a/scripts/test.js b/scripts/test.js new file mode 100644 index 0000000..6a4f30e --- /dev/null +++ b/scripts/test.js @@ -0,0 +1,47 @@ +"use strict"; + +// Do this as the first thing so that any code reading it knows the right env. +process.env.BABEL_ENV = "test"; +process.env.NODE_ENV = "test"; +process.env.PUBLIC_URL = ""; + +// Makes the script crash on unhandled rejections instead of silently +// ignoring them. In the future, promise rejections that are not handled will +// terminate the Node.js process with a non-zero exit code. +process.on("unhandledRejection", (err) => { + throw err; +}); + +// Ensure environment variables are read. +require("../config/env"); + +const jest = require("jest"); +const execSync = require("child_process").execSync; +const argv = process.argv.slice(2); + +function isInGitRepository() { + try { + execSync("git rev-parse --is-inside-work-tree", { stdio: "ignore" }); + return true; + } catch (e) { + return false; + } +} + +function isInMercurialRepository() { + try { + execSync("hg --cwd . root", { stdio: "ignore" }); + return true; + } catch (e) { + return false; + } +} + +// Watch unless on CI or explicitly running all tests +if (!process.env.CI && argv.indexOf("--watchAll") === -1 && argv.indexOf("--watchAll=false") === -1) { + // https://github.com/facebook/create-react-app/issues/5210 + const hasSourceControl = isInGitRepository() || isInMercurialRepository(); + argv.push(hasSourceControl ? "--watch" : "--watchAll"); +} + +jest.run(argv); diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..fe59fdc --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,29 @@ +import { BrowserRouter, Route, Routes } from "react-router-dom"; +import { AboutContainer } from "./features/about/About"; +import { CounterContainer } from "./features/counter/Counter"; +import { HomeContainer } from "./features/home/Home"; +import { Navbar } from "./infrastructure/navbar/Navbar"; + +function App() { + return ( + +
+ + + } /> + } /> + } /> + {/* } /> */} + {/* }> + } /> + } /> + } /> + */} + {/* */} + +
+
+ ); +} + +export default App; diff --git a/src/app/hooks.ts b/src/app/hooks.ts new file mode 100644 index 0000000..520e84e --- /dev/null +++ b/src/app/hooks.ts @@ -0,0 +1,6 @@ +import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; +import type { RootState, AppDispatch } from './store'; + +// Use throughout your app instead of plain `useDispatch` and `useSelector` +export const useAppDispatch = () => useDispatch(); +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/src/app/store.ts b/src/app/store.ts new file mode 100644 index 0000000..8219d1b --- /dev/null +++ b/src/app/store.ts @@ -0,0 +1,17 @@ +import { configureStore, ThunkAction, Action } from "@reduxjs/toolkit"; +import counterReducer from "../features/counter/state/counterSlice"; + +export const store = configureStore({ + reducer: { + counter: counterReducer, + }, +}); + +export type AppDispatch = typeof store.dispatch; +export type RootState = ReturnType; +export type AppThunk = ThunkAction< + ReturnType, + RootState, + unknown, + Action +>; diff --git a/src/features/about/About.spec.tsx b/src/features/about/About.spec.tsx new file mode 100644 index 0000000..ab60d27 --- /dev/null +++ b/src/features/about/About.spec.tsx @@ -0,0 +1,10 @@ +import { render, screen } from "@testing-library/react"; +import { AboutContainer } from "./About"; + +describe("About container", () => { + it("renders welcome to the about page", () => { + render(); + + expect(screen.getByText(/Welcome to the about page/i)).toBeInTheDocument(); + }); +}); diff --git a/src/features/about/About.tsx b/src/features/about/About.tsx new file mode 100644 index 0000000..368379b --- /dev/null +++ b/src/features/about/About.tsx @@ -0,0 +1,7 @@ +import { FunctionComponent } from "react"; + +type Props = {}; + +export const AboutContainer: FunctionComponent = () => { + return

Welcome to the about page :)

; +}; diff --git a/src/features/counter/Counter.module.css b/src/features/counter/Counter.module.css new file mode 100644 index 0000000..025bb72 --- /dev/null +++ b/src/features/counter/Counter.module.css @@ -0,0 +1,79 @@ +.row { + display: flex; + align-items: center; + justify-content: center; +} + +.row > button { + margin-left: 4px; + margin-right: 8px; +} + +.row:not(:last-child) { + margin-bottom: 16px; +} + +.value { + font-size: 78px; + padding-left: 16px; + padding-right: 16px; + margin-top: 2px; + font-family: 'Courier New', Courier, monospace; +} + +.button { + appearance: none; + background: none; + font-size: 32px; + padding-left: 12px; + padding-right: 12px; + outline: none; + border: 2px solid transparent; + color: rgb(112, 76, 182); + padding-bottom: 4px; + cursor: pointer; + background-color: rgba(112, 76, 182, 0.1); + border-radius: 2px; + transition: all 0.15s; +} + +.textbox { + font-size: 32px; + padding: 2px; + width: 64px; + text-align: center; + margin-right: 4px; +} + +.button:hover, +.button:focus { + border: 2px solid rgba(112, 76, 182, 0.4); +} + +.button:active { + background-color: rgba(112, 76, 182, 0.2); +} + +.asyncButton { + composes: button; + position: relative; +} + +.asyncButton:after { + content: ''; + background-color: rgba(112, 76, 182, 0.15); + display: block; + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + opacity: 0; + transition: width 1s linear, opacity 0.5s ease 1s; +} + +.asyncButton:active:after { + width: 0%; + opacity: 1; + transition: 0s; +} diff --git a/src/features/counter/Counter.tsx b/src/features/counter/Counter.tsx new file mode 100644 index 0000000..b8abf65 --- /dev/null +++ b/src/features/counter/Counter.tsx @@ -0,0 +1,69 @@ +import { useState } from "react"; + +import { useAppDispatch, useAppSelector } from "../../app/hooks"; +import styles from "./Counter.module.css"; +import { incrementAsync } from "./state/actions/incrementAsync"; +import { + decrement, + increment, + incrementByAmount, + incrementIfOdd, + selectCountAndStatus, +} from "./state/counterSlice"; + +export function CounterContainer() { + const { value, status } = useAppSelector(selectCountAndStatus); + const dispatch = useAppDispatch(); + const [incrementAmount, setIncrementAmount] = useState("2"); + + const incrementValue = Number(incrementAmount) || 0; + + return ( +
+ Status: {status} +
+ + {value} + +
+
+ setIncrementAmount(e.target.value)} + /> + + + +
+
+ ); +} diff --git a/src/features/counter/models/CounterState.ts b/src/features/counter/models/CounterState.ts new file mode 100644 index 0000000..40a62b0 --- /dev/null +++ b/src/features/counter/models/CounterState.ts @@ -0,0 +1,4 @@ +export interface CounterState { + value: number; + status: "idle" | "loading" | "failed"; +} diff --git a/src/features/counter/services/counterAPI.ts b/src/features/counter/services/counterAPI.ts new file mode 100644 index 0000000..dc76027 --- /dev/null +++ b/src/features/counter/services/counterAPI.ts @@ -0,0 +1,4 @@ +// A mock function to mimic making an async request for data +export function fetchCount(amount = 1) { + return new Promise<{ data: number }>((resolve) => setTimeout(() => resolve({ data: amount }), 500)); +} diff --git a/src/features/counter/state/actions/incrementAsync.ts b/src/features/counter/state/actions/incrementAsync.ts new file mode 100644 index 0000000..52f8963 --- /dev/null +++ b/src/features/counter/state/actions/incrementAsync.ts @@ -0,0 +1,13 @@ +import { createAsyncThunk } from "@reduxjs/toolkit"; +import { fetchCount } from "../../services/counterAPI"; + +// The function below is called a thunk and allows us to perform async logic. It +// can be dispatched like a regular action: `dispatch(incrementAsync(10))`. This +// will call the thunk with the `dispatch` function as the first argument. Async +// code can then be executed and other actions can be dispatched. Thunks are +// typically used to make async requests. +export const incrementAsync = createAsyncThunk("counter/fetchCount", async (amount: number) => { + const response = await fetchCount(amount); + // The value we return becomes the `fulfilled` action payload + return response.data; +}); diff --git a/src/features/counter/state/counterSlice.spec.ts b/src/features/counter/state/counterSlice.spec.ts new file mode 100644 index 0000000..c823746 --- /dev/null +++ b/src/features/counter/state/counterSlice.spec.ts @@ -0,0 +1,34 @@ +import counterReducer, { + increment, + decrement, + incrementByAmount, +} from "./counterSlice"; +import { CounterState } from "../models/CounterState"; + +describe("counter reducer", () => { + const initialState: CounterState = { + value: 3, + status: "idle", + }; + it("should handle initial state", () => { + expect(counterReducer(undefined, { type: "unknown" })).toEqual({ + value: 0, + status: "idle", + }); + }); + + it.only("should handle increment", () => { + const actual = counterReducer(initialState, increment()); + expect(actual.value).toEqual(4); + }); + + it("should handle decrement", () => { + const actual = counterReducer(initialState, decrement()); + expect(actual.value).toEqual(2); + }); + + it("should handle incrementByAmount", () => { + const actual = counterReducer(initialState, incrementByAmount(2)); + expect(actual.value).toEqual(5); + }); +}); diff --git a/src/features/counter/state/counterSlice.ts b/src/features/counter/state/counterSlice.ts new file mode 100644 index 0000000..5086b7b --- /dev/null +++ b/src/features/counter/state/counterSlice.ts @@ -0,0 +1,73 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { AppThunk, RootState } from "../../../app/store"; +import { CounterState } from "../models/CounterState"; +import { incrementAsync } from "./actions/incrementAsync"; + +const initialState: CounterState = { + value: 0, + status: "idle", +}; + +export const counterSlice = createSlice({ + name: "counter", + initialState, + // The `reducers` field lets us define reducers and generate associated actions + reducers: { + increment: (state) => { + // Redux Toolkit allows us to write "mutating" logic in reducers. It + // doesn't actually mutate the state because it uses the Immer library, + // which detects changes to a "draft state" and produces a brand new + // immutable state based off those changes + state.value += 1; + }, + decrement: (state) => { + state.value -= 1; + }, + // Use the PayloadAction type to declare the type of `action.payload` + incrementByAmount: (state, action: PayloadAction) => { + state.value += action.payload; + }, + }, + // The `extraReducers` field lets the slice handle actions defined elsewhere, + // including actions generated by createAsyncThunk or in other slices. + extraReducers: (builder) => { + builder + .addCase(incrementAsync.pending, (state) => { + state.status = "loading"; + }) + .addCase(incrementAsync.fulfilled, (state, action) => { + state.status = "idle"; + state.value += action.payload; + }) + .addCase(incrementAsync.rejected, (state) => { + state.status = "failed"; + }); + }, +}); + +export const { increment, decrement, incrementByAmount } = counterSlice.actions; + +// The function below is called a selector and allows us to select a value from +// the state. Selectors can also be defined inline where they're used instead of +// in the slice file. For example: `useSelector((state: RootState) => state.counter.value)` +export const selectCount = (state: RootState) => state.counter.value; +export const selectStatus = (state: RootState) => state.counter.status; + +// You can combine related items (such as nr of results + items) +export const selectCountAndStatus = (state: RootState) => { + const { value, status } = state.counter; + return { value, status }; +}; + +// We can also write thunks by hand, which may contain both sync and async logic. +// Here's an example of conditionally dispatching actions based on current state. +export const incrementIfOdd = + (amount: number): AppThunk => + (dispatch, getState) => { + const currentValue = selectCount(getState()); + if (currentValue % 2 === 1) { + dispatch(incrementByAmount(amount)); + } + }; + +export default counterSlice.reducer; diff --git a/src/features/home/Home.spec.tsx b/src/features/home/Home.spec.tsx new file mode 100644 index 0000000..b6db438 --- /dev/null +++ b/src/features/home/Home.spec.tsx @@ -0,0 +1,10 @@ +import { render, screen } from "@testing-library/react"; +import { HomeContainer } from "./Home"; + +describe("Home component", () => { + it("renders learn react link", () => { + render(); + + expect(screen.getByText(/learn/i)).toBeInTheDocument(); + }); +}); diff --git a/src/features/home/Home.tsx b/src/features/home/Home.tsx new file mode 100644 index 0000000..1ace099 --- /dev/null +++ b/src/features/home/Home.tsx @@ -0,0 +1,29 @@ +import { FunctionComponent } from "react"; + +type Props = {}; + +export const HomeContainer: FunctionComponent = () => { + return ( +
+

This is the react base app :)

+ + Learn + + React + + , + + Redux + + , + + Redux Toolkit + + , and + + React Redux + + +
+ ); +}; diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..e992723 --- /dev/null +++ b/src/index.css @@ -0,0 +1,11 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", + "Droid Sans", "Helvetica Neue", sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; +} diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..c9e123b --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { Provider } from "react-redux"; +import App from "./App"; +import { store } from "./app/store"; +import "./index.css"; +import reportWebVitals from "./reportWebVitals"; + +const container = document.getElementById("root")!; +const root = createRoot(container); + +root.render( + + + + + , +); + +// If you want to start measuring performance in your app, pass a function +// to log results (for example: reportWebVitals(console.log)) +// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals +reportWebVitals(); diff --git a/src/infrastructure/config.ts b/src/infrastructure/config.ts new file mode 100644 index 0000000..8f1bf88 --- /dev/null +++ b/src/infrastructure/config.ts @@ -0,0 +1,5 @@ +type RunTimeConfig = { + version: number; +}; + +export const Config = (window as any).config as RunTimeConfig; diff --git a/src/infrastructure/navbar/Navbar.css b/src/infrastructure/navbar/Navbar.css new file mode 100644 index 0000000..4c818b9 --- /dev/null +++ b/src/infrastructure/navbar/Navbar.css @@ -0,0 +1,3 @@ +nav a { + padding-right: 10px; +} diff --git a/src/infrastructure/navbar/Navbar.spec.tsx b/src/infrastructure/navbar/Navbar.spec.tsx new file mode 100644 index 0000000..7bb6ae2 --- /dev/null +++ b/src/infrastructure/navbar/Navbar.spec.tsx @@ -0,0 +1,15 @@ +import { render, screen } from "@testing-library/react"; +import { WithRouter } from "../wrappers/WithRouter"; +import { Navbar } from "./Navbar"; + +describe("Navbar container", () => { + it("renders a navigation section identified by the nav test-id", () => { + render( + + + , + ); + + expect(screen.getAllByTestId("nav")?.length).toBeGreaterThan(0); + }); +}); diff --git a/src/infrastructure/navbar/Navbar.tsx b/src/infrastructure/navbar/Navbar.tsx new file mode 100644 index 0000000..dfbe830 --- /dev/null +++ b/src/infrastructure/navbar/Navbar.tsx @@ -0,0 +1,19 @@ +import { FunctionComponent } from "react"; +import { Link } from "react-router-dom"; +import { Config } from "../config"; +import "./Navbar.css"; +type Props = {}; + +export const Navbar: FunctionComponent = () => { + return ( + <> +

Our fancy header with navigation.

+

App version: {JSON.stringify(Config.version)}

+ + + ); +}; diff --git a/src/infrastructure/wrappers/WithRouter.tsx b/src/infrastructure/wrappers/WithRouter.tsx new file mode 100644 index 0000000..f18cc2a --- /dev/null +++ b/src/infrastructure/wrappers/WithRouter.tsx @@ -0,0 +1,8 @@ +import { FunctionComponent, ReactNode } from "react"; +import { BrowserRouter } from "react-router-dom"; + +type Props = { children?: ReactNode }; + +export const WithRouter: FunctionComponent = ({ children }) => { + return {children}; +}; diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts new file mode 100644 index 0000000..624c875 --- /dev/null +++ b/src/react-app-env.d.ts @@ -0,0 +1,71 @@ +/// +/// +/// + +declare namespace NodeJS { + interface ProcessEnv { + readonly NODE_ENV: 'development' | 'production' | 'test'; + readonly PUBLIC_URL: string; + } +} + +declare module '*.avif' { + const src: string; + export default src; +} + +declare module '*.bmp' { + const src: string; + export default src; +} + +declare module '*.gif' { + const src: string; + export default src; +} + +declare module '*.jpg' { + const src: string; + export default src; +} + +declare module '*.jpeg' { + const src: string; + export default src; +} + +declare module '*.png' { + const src: string; + export default src; +} + +declare module '*.webp' { + const src: string; + export default src; +} + +declare module '*.svg' { + import * as React from 'react'; + + export const ReactComponent: React.FunctionComponent & { title?: string }>; + + const src: string; + export default src; +} + +declare module '*.module.css' { + const classes: { readonly [key: string]: string }; + export default classes; +} + +declare module '*.module.scss' { + const classes: { readonly [key: string]: string }; + export default classes; +} + +declare module '*.module.sass' { + const classes: { readonly [key: string]: string }; + export default classes; +} diff --git a/src/reportWebVitals.ts b/src/reportWebVitals.ts new file mode 100644 index 0000000..49a2a16 --- /dev/null +++ b/src/reportWebVitals.ts @@ -0,0 +1,15 @@ +import { ReportHandler } from 'web-vitals'; + +const reportWebVitals = (onPerfEntry?: ReportHandler) => { + if (onPerfEntry && onPerfEntry instanceof Function) { + import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { + getCLS(onPerfEntry); + getFID(onPerfEntry); + getFCP(onPerfEntry); + getLCP(onPerfEntry); + getTTFB(onPerfEntry); + }); + } +}; + +export default reportWebVitals; diff --git a/src/setupTests.ts b/src/setupTests.ts new file mode 100644 index 0000000..e043e54 --- /dev/null +++ b/src/setupTests.ts @@ -0,0 +1,7 @@ +// jest-dom adds custom jest matchers for asserting on DOM nodes. +// allows you to do things like: +// expect(element).toHaveTextContent(/react/i) +// learn more: https://github.com/testing-library/jest-dom +import "@testing-library/jest-dom/extend-expect"; + +(window as any).config = require("./../public/config"); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0f25cca --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es5", + "baseUrl": "src", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"] +}