From 3cb1759648d2eeedab00a11391159fa2d6fb7c41 Mon Sep 17 00:00:00 2001 From: Mastermindzh Date: Mon, 27 Jun 2022 16:41:03 +0200 Subject: [PATCH] - Updated to React 18 - Updated for public release - Git was reset for privacy reasons --- .babelrc | 3 + .browserslistrc | 12 + .dockerignore | 38 + .editorconfig | 18 + .eslintignore | 11 + .eslintrc | 65 + .gitignore | 68 + .nvmrc | 1 + .prettierrc.js | 4 + .vscode/launch.json | 40 + .vscode/settings.json | 19 + .vscode/typescriptreact.code-snippets | 72 + CHANGELOG.md | 11 + Dockerfile | 6 + README.md | 46 + config/env.js | 101 + config/getHttpsConfig.js | 58 + config/jest/babelTransform.js | 29 + config/jest/cssTransform.js | 14 + config/jest/fileTransform.js | 40 + config/modules.js | 134 + config/paths.js | 72 + config/webpack.config.js | 708 + .../persistentCache/createEnvironmentHash.js | 9 + config/webpackDevServer.config.js | 126 + jest.config.js | 47 + package-lock.json | 32417 ++++++++++++++++ package.json | 117 + public/config.js | 9 + public/icon/android-icon-144x144.png | Bin 0 -> 12055 bytes public/icon/android-icon-192x192.png | Bin 0 -> 18173 bytes public/icon/android-icon-36x36.png | Bin 0 -> 2141 bytes public/icon/android-icon-48x48.png | Bin 0 -> 2834 bytes public/icon/android-icon-72x72.png | Bin 0 -> 4528 bytes public/icon/android-icon-96x96.png | Bin 0 -> 6588 bytes public/icon/apple-icon-180x180.png | Bin 0 -> 17035 bytes public/icon/favicon.ico | Bin 0 -> 1150 bytes public/index.html | 18 + public/manifest.json | 46 + public/robots.txt | 3 + scripts/build.js | 198 + scripts/start.js | 128 + scripts/test.js | 47 + src/App.tsx | 29 + src/app/hooks.ts | 6 + src/app/store.ts | 17 + src/features/about/About.spec.tsx | 10 + src/features/about/About.tsx | 7 + src/features/counter/Counter.module.css | 79 + src/features/counter/Counter.tsx | 69 + src/features/counter/models/CounterState.ts | 4 + src/features/counter/services/counterAPI.ts | 4 + .../counter/state/actions/incrementAsync.ts | 13 + .../counter/state/counterSlice.spec.ts | 34 + src/features/counter/state/counterSlice.ts | 73 + src/features/home/Home.spec.tsx | 10 + src/features/home/Home.tsx | 29 + src/index.css | 11 + src/index.tsx | 23 + src/infrastructure/config.ts | 5 + src/infrastructure/navbar/Navbar.css | 3 + src/infrastructure/navbar/Navbar.spec.tsx | 15 + src/infrastructure/navbar/Navbar.tsx | 19 + src/infrastructure/wrappers/WithRouter.tsx | 8 + src/react-app-env.d.ts | 71 + src/reportWebVitals.ts | 15 + src/setupTests.ts | 7 + tsconfig.json | 21 + 68 files changed, 35317 insertions(+) create mode 100644 .babelrc create mode 100644 .browserslistrc create mode 100755 .dockerignore create mode 100644 .editorconfig create mode 100644 .eslintignore create mode 100644 .eslintrc create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 .prettierrc.js create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 .vscode/typescriptreact.code-snippets create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 config/env.js create mode 100644 config/getHttpsConfig.js create mode 100644 config/jest/babelTransform.js create mode 100644 config/jest/cssTransform.js create mode 100644 config/jest/fileTransform.js create mode 100644 config/modules.js create mode 100644 config/paths.js create mode 100644 config/webpack.config.js create mode 100644 config/webpack/persistentCache/createEnvironmentHash.js create mode 100644 config/webpackDevServer.config.js create mode 100644 jest.config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/config.js create mode 100644 public/icon/android-icon-144x144.png create mode 100644 public/icon/android-icon-192x192.png create mode 100644 public/icon/android-icon-36x36.png create mode 100644 public/icon/android-icon-48x48.png create mode 100644 public/icon/android-icon-72x72.png create mode 100644 public/icon/android-icon-96x96.png create mode 100644 public/icon/apple-icon-180x180.png create mode 100644 public/icon/favicon.ico create mode 100644 public/index.html create mode 100644 public/manifest.json create mode 100644 public/robots.txt create mode 100644 scripts/build.js create mode 100644 scripts/start.js create mode 100644 scripts/test.js create mode 100644 src/App.tsx create mode 100644 src/app/hooks.ts create mode 100644 src/app/store.ts create mode 100644 src/features/about/About.spec.tsx create mode 100644 src/features/about/About.tsx create mode 100644 src/features/counter/Counter.module.css create mode 100644 src/features/counter/Counter.tsx create mode 100644 src/features/counter/models/CounterState.ts create mode 100644 src/features/counter/services/counterAPI.ts create mode 100644 src/features/counter/state/actions/incrementAsync.ts create mode 100644 src/features/counter/state/counterSlice.spec.ts create mode 100644 src/features/counter/state/counterSlice.ts create mode 100644 src/features/home/Home.spec.tsx create mode 100644 src/features/home/Home.tsx create mode 100644 src/index.css create mode 100644 src/index.tsx create mode 100644 src/infrastructure/config.ts create mode 100644 src/infrastructure/navbar/Navbar.css create mode 100644 src/infrastructure/navbar/Navbar.spec.tsx create mode 100644 src/infrastructure/navbar/Navbar.tsx create mode 100644 src/infrastructure/wrappers/WithRouter.tsx create mode 100644 src/react-app-env.d.ts create mode 100644 src/reportWebVitals.ts create mode 100644 src/setupTests.ts create mode 100644 tsconfig.json 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"] +}