From 0f1c88180e56a8370157d0e3be3713adc5e832d5 Mon Sep 17 00:00:00 2001 From: Tran Manh Date: Mon, 3 Jul 2023 17:28:26 +0700 Subject: [PATCH 01/25] [CHORE] Bump version to 0.2.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 85373e1..539b87c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "manh-react-survey", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "manh-react-survey", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "axios": "1.4.0", "classnames": "2.3.2", diff --git a/package.json b/package.json index 2024f81..42d21f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "manh-react-survey", - "version": "0.1.0", + "version": "0.2.0", "private": true, "dependencies": { "axios": "1.4.0", From 9e1267611a952d5b50f6cf32c3d2393fa899d650 Mon Sep 17 00:00:00 2001 From: Tran Manh Date: Thu, 15 Jun 2023 10:03:53 +0700 Subject: [PATCH 02/25] [#45] Add babel dependencies to remove the warning in CI --- .babelrc | 3 +++ package-lock.json | 29 +++++++++++++++++++++++++---- package.json | 5 ++++- 3 files changed, 32 insertions(+), 5 deletions(-) create mode 100644 .babelrc diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..06c79ab --- /dev/null +++ b/.babelrc @@ -0,0 +1,3 @@ +{ + "plugins": [["@babel/plugin-proposal-private-property-in-object", { "loose": true }]] +} diff --git a/package-lock.json b/package-lock.json index 539b87c..f288dc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,10 @@ "sass": "1.49.11" }, "devDependencies": { - "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/core": "7.22.5", + "@babel/eslint-parser": "7.22.5", + "@babel/plugin-proposal-private-property-in-object": "7.21.11", + "@babel/preset-env": "7.22.5", "@cypress/browserify-preprocessor": "3.0.2", "@cypress/code-coverage": "3.10.7", "@cypress/instrument-cra": "1.4.0", @@ -627,9 +630,16 @@ } }, "node_modules/@babel/plugin-proposal-private-property-in-object": { - "version": "7.21.0-placeholder-for-preset-env.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", - "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "version": "7.21.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.11.tgz", + "integrity": "sha512-0QZ8qP/3RLDVBwBFoWAwCtgcDZJVwA5LUJRZU8x2YFfKNuFq161wK3cuGrALu5yiPu+vzwTAg/sMWVNeWeNyaw==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-create-class-features-plugin": "^7.21.0", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + }, "engines": { "node": ">=6.9.0" }, @@ -1879,6 +1889,17 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/preset-env/node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/preset-modules": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.5.tgz", diff --git a/package.json b/package.json index 42d21f1..242c90b 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,10 @@ ] }, "devDependencies": { - "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/core": "7.22.5", + "@babel/eslint-parser": "7.22.5", + "@babel/plugin-proposal-private-property-in-object": "7.21.11", + "@babel/preset-env": "7.22.5", "@cypress/browserify-preprocessor": "3.0.2", "@cypress/code-coverage": "3.10.7", "@cypress/instrument-cra": "1.4.0", From ccd3af3bad640d260bb7a85a294e67b5fa2f1170 Mon Sep 17 00:00:00 2001 From: Tran Manh Date: Wed, 14 Jun 2023 18:10:08 +0700 Subject: [PATCH 03/25] [#7] Implement signIn API and add unittest --- .eslintrc.js | 2 + package-lock.json | 863 +++++++++------------- package.json | 4 +- src/adapters/Authentication/indes.test.ts | 44 ++ src/adapters/Authentication/index.ts | 19 + src/adapters/Base/index.test.ts | 53 ++ src/adapters/Base/index.ts | 30 + src/config/index.ts | 22 + src/helpers/json.test.ts | 176 +++++ src/helpers/json.ts | 36 + src/helpers/string.test.ts | 12 + src/helpers/string.ts | 3 + src/lib/requestManager.test.ts | 15 +- src/lib/requestManager.ts | 38 +- src/screens/SignIn/index.tsx | 9 +- 15 files changed, 808 insertions(+), 518 deletions(-) create mode 100644 src/adapters/Authentication/indes.test.ts create mode 100644 src/adapters/Authentication/index.ts create mode 100644 src/adapters/Base/index.test.ts create mode 100644 src/adapters/Base/index.ts create mode 100644 src/config/index.ts create mode 100644 src/helpers/json.test.ts create mode 100644 src/helpers/json.ts create mode 100644 src/helpers/string.test.ts create mode 100644 src/helpers/string.ts diff --git a/.eslintrc.js b/.eslintrc.js index 0074144..91a94d4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -3,6 +3,8 @@ module.exports = { rules: { // Note: you must disable the base rule as it can report incorrect errors 'no-shadow': 'off', + 'no-use-before-define': 'off', '@typescript-eslint/no-shadow': 'off', + '@typescript-eslint/no-use-before-define': ['error'], }, }; diff --git a/package-lock.json b/package-lock.json index f288dc6..53a3816 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,10 @@ "name": "manh-react-survey", "version": "0.2.0", "dependencies": { + "@types/lodash": "4.14.195", "axios": "1.4.0", "classnames": "2.3.2", + "lodash": "4.17.21", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "6.3.0", @@ -2421,12 +2423,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@cypress/code-coverage/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, "node_modules/@cypress/code-coverage/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2470,18 +2466,6 @@ "node": ">=8" } }, - "node_modules/@cypress/code-coverage/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/@cypress/code-coverage/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -2652,11 +2636,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" - }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "13.20.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", @@ -2671,17 +2650,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/@eslint/eslintrc/node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -2829,6 +2797,26 @@ "node": ">=8" } }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -4798,6 +4786,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@pmmmwh/react-refresh-webpack-plugin/node_modules/schema-utils": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.2.0.tgz", + "integrity": "sha512-0zTyLGyDJYd/MBxG1AhJkKa6fpEBds4OQO2ut0w7OYG+ZGhGea09lijvzsqegYSik88zc7cUtIlnnO+/BvD6gQ==", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/@pmmmwh/react-refresh-webpack-plugin/node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -5603,9 +5608,9 @@ } }, "node_modules/@types/eslint": { - "version": "8.40.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.40.1.tgz", - "integrity": "sha512-vRb792M4mF1FBT+eoLecmkpLXwxsBHvWWRGJjzbYANBM6DtiJc6yETyv4rqDA6QNjF1pkj1U7LMA6dGb3VYlHw==", + "version": "8.40.2", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.40.2.tgz", + "integrity": "sha512-PRVjQ4Eh9z9pmmtaq8nTjZjQwKFk7YIHIud3lRoKRBgUQjgjRmoGxxGEPXQkF+lH7QkHJRNr5F4aBgYCW0lqpQ==", "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -5756,6 +5761,11 @@ "@types/node": "*" } }, + "node_modules/@types/lodash": { + "version": "4.14.195", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.195.tgz", + "integrity": "sha512-Hwx9EUgdwf2GLarOjQp5ZH8ZmblzcbTBC2wtQWNKARBSxM9ezRIAUpeDTgoQRAFB0+8CNWXVA9+MaSOzOF3nPg==" + }, "node_modules/@types/mime": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", @@ -6718,12 +6728,9 @@ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" }, "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dependencies": { - "sprintf-js": "~1.0.2" - } + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/aria-query": { "version": "5.2.1", @@ -7199,38 +7206,21 @@ } }, "node_modules/babel-loader": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.3.0.tgz", - "integrity": "sha512-H8SvsMF+m9t15HNLMipppzkC+Y2Yq+v3SonZyU70RBL/h1gxPkH08Ot8pEE9Z4Kd+czyWJClmFS8qzIP9OZ04Q==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.1.2.tgz", + "integrity": "sha512-mN14niXW43tddohGl8HPu5yfQq70iUThvFL/4QzESA7GcZoC0eVOhvWdQ8+3UlSjaDE9MVtsW9mxDY07W7VpVA==", + "dev": true, + "peer": true, "dependencies": { - "find-cache-dir": "^3.3.1", - "loader-utils": "^2.0.0", - "make-dir": "^3.1.0", - "schema-utils": "^2.6.5" + "find-cache-dir": "^3.3.2", + "schema-utils": "^4.0.0" }, "engines": { - "node": ">= 8.9" + "node": ">= 14.15.0" }, "peerDependencies": { - "@babel/core": "^7.0.0", - "webpack": ">=2" - } - }, - "node_modules/babel-loader/node_modules/schema-utils": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", - "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", - "dependencies": { - "@types/json-schema": "^7.0.5", - "ajv": "^6.12.4", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 8.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "@babel/core": "^7.12.0", + "webpack": ">=5" } }, "node_modules/babel-plugin-add-module-exports": { @@ -8032,9 +8022,9 @@ "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==" }, "node_modules/caniuse-lite": { - "version": "1.0.30001502", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001502.tgz", - "integrity": "sha512-AZ+9tFXw1sS0o0jcpJQIXvFTOB/xGiQ4OQ2t98QX3NDn2EZTSRBC801gxrsGgViuq2ak/NLkNgSNEPtCr5lfKg==", + "version": "1.0.30001503", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001503.tgz", + "integrity": "sha512-Sf9NiF+wZxPfzv8Z3iS0rXM1Do+iOy2Lxvib38glFX+08TCYYYGR5fRJXk4d77C4AYwhUjgYgMsMudbh2TqCKw==", "funding": [ { "type": "opencollective", @@ -8250,61 +8240,17 @@ } }, "node_modules/cliui": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, "dependencies": { "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^6.2.0" - } - }, - "node_modules/cliui/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/cliui/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/cliui/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" }, "engines": { - "node": ">=8" + "node": ">=12" } }, "node_modules/clone-response": { @@ -8596,20 +8542,6 @@ "node": ">=8" } }, - "node_modules/concurrently/node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/concurrently/node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -8652,42 +8584,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/concurrently/node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/concurrently/node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/concurrently/node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "engines": { - "node": ">=12" - } - }, "node_modules/confusing-browser-globals": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", @@ -9045,55 +8941,6 @@ } } }, - "node_modules/css-minimizer-webpack-plugin/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/css-minimizer-webpack-plugin/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/css-minimizer-webpack-plugin/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, - "node_modules/css-minimizer-webpack-plugin/node_modules/schema-utils": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.1.0.tgz", - "integrity": "sha512-Jw+GZVbP5IggB2WAn6UHI02LBwGmsIeYN/lNbSMZyDziQ7jmtAUrqKqDja+W89YHVs+KL/3IkIMltAklqB1vAw==", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/css-minimizer-webpack-plugin/node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -10319,9 +10166,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.428", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.428.tgz", - "integrity": "sha512-L7uUknyY286of0AYC8CKfgWstD0Smk2DvHDi9F0GWQhSH90Bzi7iDrmCbZKz75tYJxeGSAc7TYeKpmbjMDoh1w==" + "version": "1.4.430", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.430.tgz", + "integrity": "sha512-FytjTbGwz///F+ToZ5XSeXbbSaXalsVRXsz2mHityI5gfxft7ieW3HqFLkU5V1aIrY42aflICqbmFoDxW10etg==" }, "node_modules/elliptic": { "version": "6.5.4", @@ -10386,9 +10233,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.14.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.14.1.tgz", - "integrity": "sha512-Vklwq2vDKtl0y/vtwjSesgJ5MYS7Etuk5txS8VdKL4AOS1aUlD96zqIfsOSLQsdv3xgMRbtkWM8eG9XDfKUPow==", + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", + "integrity": "sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==", "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -11117,32 +10964,6 @@ "webpack": "^5.0.0" } }, - "node_modules/eslint-webpack-plugin/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/eslint-webpack-plugin/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, "node_modules/eslint-webpack-plugin/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -11164,29 +10985,6 @@ "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" } }, - "node_modules/eslint-webpack-plugin/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, - "node_modules/eslint-webpack-plugin/node_modules/schema-utils": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.1.0.tgz", - "integrity": "sha512-Jw+GZVbP5IggB2WAn6UHI02LBwGmsIeYN/lNbSMZyDziQ7jmtAUrqKqDja+W89YHVs+KL/3IkIMltAklqB1vAw==", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/eslint-webpack-plugin/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -11215,11 +11013,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/eslint/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" - }, "node_modules/eslint/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -11299,17 +11092,6 @@ "node": ">=8" } }, - "node_modules/eslint/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/eslint/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -11796,6 +11578,23 @@ "webpack": "^4.0.0 || ^5.0.0" } }, + "node_modules/file-loader/node_modules/schema-utils": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.2.0.tgz", + "integrity": "sha512-0zTyLGyDJYd/MBxG1AhJkKa6fpEBds4OQO2ut0w7OYG+ZGhGea09lijvzsqegYSik88zc7cUtIlnnO+/BvD6gQ==", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -12877,9 +12676,19 @@ } }, "node_modules/html-entities": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.5.tgz", - "integrity": "sha512-72TJlcMkYsEJASa/3HnX7VT59htM7iSHbH59NSZbtc+22Ap0Txnlx91sfeB+/A7wNZg7UxtZdhAW4y+/jimrdg==" + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.6.tgz", + "integrity": "sha512-9o0+dcpIw2/HxkNuYKxSJUF/MMRZQECK4GnF+oQOmJ83yCVHTWgCH5aOXxK5bozNRmM8wtgryjHD3uloPBDEGw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ] }, "node_modules/html-escaper": { "version": "2.0.2", @@ -14545,14 +14354,6 @@ "node": ">=8" } }, - "node_modules/jest-cli/node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "engines": { - "node": ">=10" - } - }, "node_modules/jest-cli/node_modules/yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", @@ -17504,12 +17305,11 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" @@ -18615,55 +18415,6 @@ "webpack": "^5.0.0" } }, - "node_modules/mini-css-extract-plugin/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/mini-css-extract-plugin/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/mini-css-extract-plugin/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, - "node_modules/mini-css-extract-plugin/node_modules/schema-utils": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.1.0.tgz", - "integrity": "sha512-Jw+GZVbP5IggB2WAn6UHI02LBwGmsIeYN/lNbSMZyDziQ7jmtAUrqKqDja+W89YHVs+KL/3IkIMltAklqB1vAw==", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -19029,6 +18780,50 @@ "node": ">=8.9" } }, + "node_modules/nyc/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/nyc/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/nyc/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/nyc/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, "node_modules/nyc/node_modules/p-map": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", @@ -19041,6 +18836,61 @@ "node": ">=8" } }, + "node_modules/nyc/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true + }, + "node_modules/nyc/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dev": true, + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -21683,6 +21533,24 @@ } } }, + "node_modules/react-scripts/node_modules/babel-loader": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.3.0.tgz", + "integrity": "sha512-H8SvsMF+m9t15HNLMipppzkC+Y2Yq+v3SonZyU70RBL/h1gxPkH08Ot8pEE9Z4Kd+czyWJClmFS8qzIP9OZ04Q==", + "dependencies": { + "find-cache-dir": "^3.3.1", + "loader-utils": "^2.0.0", + "make-dir": "^3.1.0", + "schema-utils": "^2.6.5" + }, + "engines": { + "node": ">= 8.9" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "webpack": ">=2" + } + }, "node_modules/react-scripts/node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -21718,6 +21586,23 @@ "node": ">=10" } }, + "node_modules/react-scripts/node_modules/schema-utils": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", + "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", + "dependencies": { + "@types/json-schema": "^7.0.5", + "ajv": "^6.12.4", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/react-scripts/node_modules/semver": { "version": "7.5.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.1.tgz", @@ -22501,22 +22386,54 @@ } }, "node_modules/schema-utils": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.2.0.tgz", - "integrity": "sha512-0zTyLGyDJYd/MBxG1AhJkKa6fpEBds4OQO2ut0w7OYG+ZGhGea09lijvzsqegYSik88zc7cUtIlnnO+/BvD6gQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.1.0.tgz", + "integrity": "sha512-Jw+GZVbP5IggB2WAn6UHI02LBwGmsIeYN/lNbSMZyDziQ7jmtAUrqKqDja+W89YHVs+KL/3IkIMltAklqB1vAw==", "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" }, "engines": { - "node": ">= 10.13.0" + "node": ">= 12.13.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/webpack" } }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", @@ -23566,12 +23483,6 @@ "stylelint": "^14.5.1 || ^15.0.0" } }, - "node_modules/stylelint/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, "node_modules/stylelint/node_modules/balanced-match": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-2.0.0.tgz", @@ -23625,18 +23536,6 @@ "node": ">=8" } }, - "node_modules/stylelint/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/stylelint/node_modules/signal-exit": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.0.2.tgz", @@ -23826,6 +23725,14 @@ "node": ">=4.0.0" } }, + "node_modules/svgo/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/svgo/node_modules/css-select": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-2.1.0.tgz", @@ -23883,6 +23790,18 @@ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==" }, + "node_modules/svgo/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/svgo/node_modules/mdn-data": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz", @@ -24183,6 +24102,23 @@ } } }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.2.0.tgz", + "integrity": "sha512-0zTyLGyDJYd/MBxG1AhJkKa6fpEBds4OQO2ut0w7OYG+ZGhGea09lijvzsqegYSik88zc7cUtIlnnO+/BvD6gQ==", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/terser/node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -25184,55 +25120,6 @@ "webpack": "^4.0.0 || ^5.0.0" } }, - "node_modules/webpack-dev-middleware/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/webpack-dev-middleware/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/webpack-dev-middleware/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, - "node_modules/webpack-dev-middleware/node_modules/schema-utils": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.1.0.tgz", - "integrity": "sha512-Jw+GZVbP5IggB2WAn6UHI02LBwGmsIeYN/lNbSMZyDziQ7jmtAUrqKqDja+W89YHVs+KL/3IkIMltAklqB1vAw==", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/webpack-dev-server": { "version": "4.15.1", "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.1.tgz", @@ -25291,55 +25178,6 @@ } } }, - "node_modules/webpack-dev-server/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/webpack-dev-server/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/webpack-dev-server/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, - "node_modules/webpack-dev-server/node_modules/schema-utils": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.1.0.tgz", - "integrity": "sha512-Jw+GZVbP5IggB2WAn6UHI02LBwGmsIeYN/lNbSMZyDziQ7jmtAUrqKqDja+W89YHVs+KL/3IkIMltAklqB1vAw==", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/webpack-dev-server/node_modules/ws": { "version": "8.13.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", @@ -25411,6 +25249,23 @@ "node": ">=0.8.x" } }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.2.0.tgz", + "integrity": "sha512-0zTyLGyDJYd/MBxG1AhJkKa6fpEBds4OQO2ut0w7OYG+ZGhGea09lijvzsqegYSik88zc7cUtIlnnO+/BvD6gQ==", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/websocket-driver": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", @@ -25936,10 +25791,12 @@ } }, "node_modules/y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "dev": true + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "engines": { + "node": ">=10" + } }, "node_modules/yallist": { "version": "3.1.1", @@ -25955,25 +25812,21 @@ } }, "node_modules/yargs": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "dev": true, "dependencies": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.0", - "get-caller-file": "^2.0.1", + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.2" + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" }, "engines": { - "node": ">=8" + "node": ">=12" } }, "node_modules/yargs-parser": { @@ -25985,16 +25838,12 @@ } }, "node_modules/yargs/node_modules/yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, - "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - }, "engines": { - "node": ">=6" + "node": ">=12" } }, "node_modules/yauzl": { diff --git a/package.json b/package.json index 242c90b..cddd2b5 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,13 @@ "dependencies": { "axios": "1.4.0", "classnames": "2.3.2", + "lodash": "4.17.21", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "6.3.0", "react-scripts": "5.0.1", - "sass": "1.49.11" + "sass": "1.49.11", + "@types/lodash": "4.14.195" }, "scripts": { "start": "react-scripts -r @cypress/instrument-cra start", diff --git a/src/adapters/Authentication/indes.test.ts b/src/adapters/Authentication/indes.test.ts new file mode 100644 index 0000000..102ab6b --- /dev/null +++ b/src/adapters/Authentication/indes.test.ts @@ -0,0 +1,44 @@ +import baseAdapter from 'adapters/Base'; +import { Config } from 'config'; + +import authenticationAdapter from '.'; + +jest.mock('adapters/Base'); +jest.mock('config'); + +const mockPostMethod = jest.fn(); + +describe('AuthenticationAdapter', () => { + beforeEach(() => { + Config.clientId = 'client id'; + Config.clientSecret = 'client secret'; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('signIn', () => { + describe('given an email and a password', () => { + it('calls the post method from the base adapter', () => { + baseAdapter.post = mockPostMethod; + + const email = 'test@test.com'; + const password = 'test'; + + const expectedPath = 'oauth/token'; + const expectedPayload = { + grantType: 'password', + clientId: Config.clientId, + clientSecret: Config.clientSecret, + email, + password, + }; + + authenticationAdapter.signIn(email, password); + + expect(mockPostMethod).toHaveBeenCalledWith(expectedPath, expectedPayload); + }); + }); + }); +}); diff --git a/src/adapters/Authentication/index.ts b/src/adapters/Authentication/index.ts new file mode 100644 index 0000000..c0f7e87 --- /dev/null +++ b/src/adapters/Authentication/index.ts @@ -0,0 +1,19 @@ +import baseAdapter from 'adapters/Base'; +import { Config } from 'config'; + +const AuthenticationAdapter = () => { + const signIn = (email: string, password: string) => + baseAdapter.post('oauth/token', { + clientId: Config.clientId, + clientSecret: Config.clientSecret, + grantType: 'password', + email: email, + password: password, + }); + + return { signIn }; +}; + +const authenticationAdapter = AuthenticationAdapter(); + +export default authenticationAdapter; diff --git a/src/adapters/Base/index.test.ts b/src/adapters/Base/index.test.ts new file mode 100644 index 0000000..d98fa43 --- /dev/null +++ b/src/adapters/Base/index.test.ts @@ -0,0 +1,53 @@ +/* eslint-disable camelcase */ +import requestManager from 'lib/requestManager'; + +import baseAdapter from '.'; + +jest.mock('lib/requestManager'); + +describe('BaseAdapter', () => { + const apiPath = '/sample'; + const params = { + testKey: 'test value', + }; + + beforeEach(() => { + (requestManager as jest.Mock).mockImplementation(() => jest.fn()); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('get', () => { + describe('given only the api path', () => { + it('calls the GET method from request manager with the path and no params', () => { + baseAdapter.get(apiPath); + + expect(requestManager).toHaveBeenCalledWith('get', apiPath, {}); + }); + }); + + describe('given the api path and url params', () => { + it('calls the get method from request manager with the path and params with snake case keys', () => { + baseAdapter.get(apiPath, params); + + expect(requestManager).toHaveBeenCalledWith('get', apiPath, { + params: { test_key: params.testKey }, + }); + }); + }); + }); + + describe('post', () => { + describe('given the path and url params', () => { + it('calls the post method from request manager with the path and data with snake case key', () => { + baseAdapter.post(apiPath, params); + + expect(requestManager).toHaveBeenCalledWith('post', apiPath, { + data: { test_key: params.testKey }, + }); + }); + }); + }); +}); diff --git a/src/adapters/Base/index.ts b/src/adapters/Base/index.ts new file mode 100644 index 0000000..b55c8e8 --- /dev/null +++ b/src/adapters/Base/index.ts @@ -0,0 +1,30 @@ +import { AxiosRequestConfig } from 'axios'; + +import { JSONObject, keysToSnakeCase } from 'helpers/json'; +import requestManager from 'lib/requestManager'; + +const BaseAdapter = () => { + const get = (path: string, params?: JSONObject) => { + const requestOptions: AxiosRequestConfig = {}; + if (params) { + requestOptions.params = keysToSnakeCase(params); + } + + return requestManager('get', path, requestOptions); + }; + + const post = (path: string, params: JSONObject) => { + const requestOptions: AxiosRequestConfig = { data: keysToSnakeCase(params) }; + + return requestManager('post', path, requestOptions); + }; + + return { + get, + post, + }; +}; + +const baseAdapter = BaseAdapter(); + +export default baseAdapter; diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..f2ee854 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,22 @@ +import { camelize } from 'helpers/string'; + +interface ConfigProps { + [key: string]: string; +} + +const envConfig = process.env; +const configurationPrefix = 'REACT_APP_'; + +const convertConfig = () => { + const config: ConfigProps = {}; + + Object.keys(envConfig).forEach((key: string) => { + const newKey = camelize(key.replace(configurationPrefix, '').toLowerCase()); + + config[newKey] = envConfig[key] || ''; + }); + + return config; +}; + +export const Config = convertConfig(); diff --git a/src/helpers/json.test.ts b/src/helpers/json.test.ts new file mode 100644 index 0000000..5904196 --- /dev/null +++ b/src/helpers/json.test.ts @@ -0,0 +1,176 @@ +/* eslint camelcase: ["error", {allow: ["snake_case", "kebab_case", "camel_case", "pascal_case", "html_event_json"]}] */ +import { JSONValue, keysToCamelCase, keysToSnakeCase } from './json'; + +describe('JSON helper', () => { + describe('keysToCamelCase', () => { + describe('given a JSON Object', () => { + it('returns the JSON Object with camel case keys', () => { + const jsonObject = { + snake_case: 'snake_case', + 'kebab-case': 'kebab-case', + camelCase: 'camelCase', + PascalCase: 'PascalCase', + normalcase: 'normalcase', + HTMLEventJSON: 'HTMLEventJSON', + SCREAMINGCASE: 'SCREAMINGCASE', + }; + + expect(keysToCamelCase(jsonObject)).toEqual({ + snakeCase: 'snake_case', + kebabCase: 'kebab-case', + camelCase: 'camelCase', + pascalCase: 'PascalCase', + normalcase: 'normalcase', + htmlEventJson: 'HTMLEventJSON', + screamingcase: 'SCREAMINGCASE', + }); + }); + }); + + describe('given a JSON Array', () => { + it('returns the JSON Array that has JSON Object with camel case keys', () => { + const jsonArray: JSONValue = [ + { + snake_case: 'snake_case', + }, + { + 'kebab-case': 'kebab-case', + }, + { + camelCase: 'camelCase', + }, + { + PascalCase: 'PascalCase', + }, + { + normalcase: 'normalcase', + }, + { + HTMLEventJSON: 'HTMLEventJSON', + }, + { + SCREAMINGCASE: 'SCREAMINGCASE', + }, + ]; + + expect(keysToCamelCase(jsonArray)).toEqual([ + { + snakeCase: 'snake_case', + }, + { + kebabCase: 'kebab-case', + }, + { + camelCase: 'camelCase', + }, + { + pascalCase: 'PascalCase', + }, + { + normalcase: 'normalcase', + }, + { + htmlEventJson: 'HTMLEventJSON', + }, + { + screamingcase: 'SCREAMINGCASE', + }, + ]); + }); + }); + + describe('given a non JSON Object or Array', () => { + it('returns the given values', () => { + expect(keysToCamelCase('string')).toBe('string'); + expect(keysToCamelCase(123)).toBe(123); + expect(keysToCamelCase(true)).toBe(true); + }); + }); + }); + + describe('keysToSnakeCase', () => { + describe('given a JSON Object', () => { + it('returns the JSON Object with camel case keys', () => { + const jsonObject = { + snake_case: 'snake_case', + 'kebab-case': 'kebab-case', + camelCase: 'camelCase', + PascalCase: 'PascalCase', + normalcase: 'normalcase', + HTMLEventJSON: 'HTMLEventJSON', + SCREAMINGCASE: 'SCREAMINGCASE', + }; + + expect(keysToSnakeCase(jsonObject)).toEqual({ + snake_case: 'snake_case', + kebab_case: 'kebab-case', + camel_case: 'camelCase', + pascal_case: 'PascalCase', + normalcase: 'normalcase', + html_event_json: 'HTMLEventJSON', + screamingcase: 'SCREAMINGCASE', + }); + }); + }); + + describe('given a JSON Array', () => { + it('returns the JSON Array that has JSON Object with camel case keys', () => { + const jsonArray: JSONValue = [ + { + snake_case: 'snake_case', + }, + { + 'kebab-case': 'kebab-case', + }, + { + camelCase: 'camelCase', + }, + { + PascalCase: 'PascalCase', + }, + { + normalcase: 'normalcase', + }, + { + HTMLEventJSON: 'HTMLEventJSON', + }, + { + SCREAMINGCASE: 'SCREAMINGCASE', + }, + ]; + + expect(keysToSnakeCase(jsonArray)).toEqual([ + { + snake_case: 'snake_case', + }, + { + kebab_case: 'kebab-case', + }, + { + camel_case: 'camelCase', + }, + { + pascal_case: 'PascalCase', + }, + { + normalcase: 'normalcase', + }, + { + html_event_json: 'HTMLEventJSON', + }, + { + screamingcase: 'SCREAMINGCASE', + }, + ]); + }); + }); + + describe('given a non JSON Object or Array', () => { + it('returns the given values', () => { + expect(keysToSnakeCase('string')).toBe('string'); + expect(keysToSnakeCase(123)).toBe(123); + expect(keysToSnakeCase(true)).toBe(true); + }); + }); + }); +}); diff --git a/src/helpers/json.ts b/src/helpers/json.ts new file mode 100644 index 0000000..c1df7f0 --- /dev/null +++ b/src/helpers/json.ts @@ -0,0 +1,36 @@ +import { camelCase, snakeCase } from 'lodash'; + +export type JSONValue = string | number | boolean | JSONObject | JSONArray; +export type JSONArray = Array; + +export interface JSONObject { + [key: string]: JSONValue; +} + +export const keysToCamelCase = (json: JSONValue | JSONObject | JSONArray): JSONValue | JSONObject | JSONArray => { + if (Array.isArray(json)) { + return json.map((jsonObject) => keysToCamelCase(jsonObject as JSONValue)); + } else if (json === Object(json)) { + const resultObject: JSONObject = {}; + Object.keys(json).forEach((key) => { + resultObject[camelCase(key)] = keysToCamelCase((json as JSONObject)[key]); + }); + + return resultObject; + } + return json; +}; + +export const keysToSnakeCase = (json: JSONValue | JSONObject | JSONArray): JSONValue | JSONObject | JSONArray => { + if (Array.isArray(json)) { + return json.map((jsonObject) => keysToSnakeCase(jsonObject as JSONValue)); + } else if (json === Object(json)) { + const resultObject: JSONObject = {}; + Object.keys(json).forEach((key) => { + resultObject[snakeCase(key)] = keysToSnakeCase((json as JSONObject)[key]); + }); + + return resultObject; + } + return json; +}; diff --git a/src/helpers/string.test.ts b/src/helpers/string.test.ts new file mode 100644 index 0000000..c778c43 --- /dev/null +++ b/src/helpers/string.test.ts @@ -0,0 +1,12 @@ +import { camelize } from './string'; + +describe('String helper', () => { + describe('camelize', () => { + it('returns camelized string', () => { + expect(camelize('SCREAMING_SNAKE_CASE')).toBe('screamingSnakeCase'); + expect(camelize('snake_case')).toBe('snakeCase'); + expect(camelize('normal case')).toBe('normalCase'); + expect(camelize('PascalCase')).toBe('pascalcase'); + }); + }); +}); diff --git a/src/helpers/string.ts b/src/helpers/string.ts new file mode 100644 index 0000000..10fa80c --- /dev/null +++ b/src/helpers/string.ts @@ -0,0 +1,3 @@ +export const camelize = (str: string): string => { + return str.toLowerCase().replace(/[^a-zA-Z0-9]+(.)/g, (m, chr) => chr.toUpperCase()); +}; diff --git a/src/lib/requestManager.test.ts b/src/lib/requestManager.test.ts index 93f3bec..0d4f46f 100644 --- a/src/lib/requestManager.test.ts +++ b/src/lib/requestManager.test.ts @@ -1,11 +1,22 @@ import axios from 'axios'; +import { Config } from 'config'; + import requestManager, { defaultOptions } from './requestManager'; jest.mock('axios'); +jest.mock('config'); describe('requestManager', () => { - const endPoint = 'https://sample-endpoint.com/api/'; + const endPoint = 'sample/endpoint'; + + beforeEach(() => { + Config.apiBaseUrl = 'http://sample.com'; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); it('fetches successfully data from an API', async () => { const responseData = { @@ -23,7 +34,7 @@ describe('requestManager', () => { }); it('fetches the provided endPoint', async () => { - const requestOptions = { ...defaultOptions, method: 'POST', url: endPoint }; + const requestOptions = { ...defaultOptions(), method: 'POST', url: endPoint }; const requestSpy = jest.spyOn(axios, 'request').mockImplementation(() => Promise.resolve({})); diff --git a/src/lib/requestManager.ts b/src/lib/requestManager.ts index 154b0f0..2ae2e28 100644 --- a/src/lib/requestManager.ts +++ b/src/lib/requestManager.ts @@ -1,9 +1,26 @@ -import axios, { Method as HTTPMethod, ResponseType, AxiosRequestConfig, AxiosResponse } from 'axios'; +import axios, { Method as HTTPMethod, ResponseType, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'; -export const defaultOptions: { responseType: ResponseType } = { - responseType: 'json', +import { Config } from 'config'; +import { JSONValue, keysToCamelCase } from 'helpers/json'; + +export const successResponseInterceptor = ( + response: AxiosResponse +): AxiosResponse | Promise> => { + const responseData = response.data as JSONValue; + const formattedData = keysToCamelCase(responseData); + response.data = formattedData; + + return response; }; +export const defaultOptions = (): { responseType: ResponseType; baseURL: string; headers?: { [key: string]: string } } => ({ + responseType: 'json', + baseURL: `${Config.apiBaseUrl}/api/v1`, + headers: { + 'Content-Type': 'application/json', + }, +}); + /** * The main API access function that comes preconfigured with useful defaults. * @@ -22,13 +39,20 @@ const requestManager = ( const requestParams: AxiosRequestConfig = { method, url: endpoint, - ...defaultOptions, + ...defaultOptions(), ...requestOptions, }; - return axios.request(requestParams).then((response: AxiosResponse) => { - return response.data; - }); + axios.interceptors.response.use(successResponseInterceptor); + + return axios + .request(requestParams) + .then((response: AxiosResponse) => { + return response.data; + }) + .catch((error: AxiosError) => { + throw error; + }); }; export default requestManager; diff --git a/src/screens/SignIn/index.tsx b/src/screens/SignIn/index.tsx index d7730f7..05c1168 100644 --- a/src/screens/SignIn/index.tsx +++ b/src/screens/SignIn/index.tsx @@ -16,11 +16,17 @@ export const signInScreenTestIds = { }; const SignInScreen = (): JSX.Element => { + const handleSubmit = async (event: React.SyntheticEvent) => { + event.preventDefault(); + + // TODO: call signIn + }; + return (
nimble logo
Sign in to Nimble
-
+
{
); diff --git a/src/helpers/authentication.test.ts b/src/helpers/authentication.test.ts new file mode 100644 index 0000000..1e1ec6f --- /dev/null +++ b/src/helpers/authentication.test.ts @@ -0,0 +1,87 @@ +import { SignIn } from 'types/signIn'; + +import { authTokenKey, clearToken, getToken, setToken } from './authentication'; + +describe('Authentication helper', () => { + const mockStorage: { [key: string]: string | null } = {}; + const mockLocalStorageGetItem = (key: string): string | null => { + return mockStorage[key]; + }; + const mockLocalStorageSetItem = (key: string, value: string): void => { + mockStorage[key] = value; + }; + const mockLocalStorageRemoveItem = (key: string): void => { + mockStorage[key] = null; + }; + + const mockAccessToken = 'access token'; + const mockRefreshToken = 'refresh token'; + const mockTokenType = 'token type'; + const mockId = 'id'; + const mockResourceType = 'resource type'; + + beforeEach(() => { + window.Storage.prototype.getItem = mockLocalStorageGetItem; + window.Storage.prototype.setItem = mockLocalStorageSetItem; + window.Storage.prototype.removeItem = mockLocalStorageRemoveItem; + }); + + describe('getToken', () => { + beforeEach(() => { + const mockTokens: SignIn = { + id: mockId, + resourceType: mockResourceType, + accessToken: mockAccessToken, + refreshToken: mockRefreshToken, + tokenType: mockTokenType, + }; + mockStorage[authTokenKey] = JSON.stringify(mockTokens); + }); + + it('returns the tokens from local storage', () => { + const tokens = getToken(); + + expect(tokens?.accessToken).toBe(mockAccessToken); + expect(tokens?.refreshToken).toBe(mockRefreshToken); + }); + }); + + describe('setToken', () => { + it('sets the tokens to local storage', () => { + const mockTokens: SignIn = { + id: mockId, + resourceType: mockResourceType, + accessToken: mockAccessToken, + refreshToken: mockRefreshToken, + tokenType: mockTokenType, + }; + + const setItemSpy = jest.spyOn(window.Storage.prototype, 'setItem'); + + setToken(mockTokens); + + expect(setItemSpy).toHaveBeenCalledWith(authTokenKey, JSON.stringify(mockTokens)); + }); + }); + + describe('clearToken', () => { + beforeEach(() => { + const mockTokens: SignIn = { + id: mockId, + resourceType: mockResourceType, + accessToken: mockAccessToken, + refreshToken: mockRefreshToken, + tokenType: mockTokenType, + }; + mockStorage[authTokenKey] = JSON.stringify(mockTokens); + }); + + it('clears the tokens from local storage', () => { + const removeItemSpy = jest.spyOn(window.Storage.prototype, 'removeItem'); + + clearToken(); + + expect(removeItemSpy).toHaveBeenCalledWith(authTokenKey); + }); + }); +}); diff --git a/src/helpers/authentication.ts b/src/helpers/authentication.ts new file mode 100644 index 0000000..7d52f49 --- /dev/null +++ b/src/helpers/authentication.ts @@ -0,0 +1,22 @@ +import { SignIn } from 'types/signIn'; + +export const authTokenKey = 'AuthToken'; + +export const getToken = () => { + const authToken = localStorage.getItem(authTokenKey); + if (!authToken) { + return; + } + + const token = JSON.parse(authToken) as SignIn; + + return token; +}; + +export const setToken = (token: SignIn) => { + localStorage.setItem(authTokenKey, JSON.stringify(token)); +}; + +export const clearToken = () => { + localStorage.removeItem(authTokenKey); +}; diff --git a/src/helpers/deserializer.test.ts b/src/helpers/deserializer.test.ts new file mode 100644 index 0000000..f024dd9 --- /dev/null +++ b/src/helpers/deserializer.test.ts @@ -0,0 +1,29 @@ +import { Resource } from 'types/resource'; + +import { deserialize } from './deserializer'; + +describe('Deserializer helper', () => { + interface TestType extends Resource { + name: string; + age: number; + } + + describe('deserialize', () => { + const jsonData = { + id: '1', + type: 'TestType', + attributes: { + name: 'Name', + age: 25, + }, + }; + + it('returns deserialized data', () => { + const deserializedData = deserialize(jsonData); + + expect(deserializedData.resourceType).toBe('TestType'); + expect(deserializedData.name).toBe(jsonData.attributes.name); + expect(deserializedData.age).toBe(jsonData.attributes.age); + }); + }); +}); diff --git a/src/helpers/deserializer.ts b/src/helpers/deserializer.ts new file mode 100644 index 0000000..a61d05a --- /dev/null +++ b/src/helpers/deserializer.ts @@ -0,0 +1,24 @@ +import { AxiosResponse } from 'axios'; + +import { Resource } from 'types/resource'; + +import { JSONObject } from './json'; + +export type DeserializableResponse = AxiosResponse; + +export interface Deserializable { + type: string; + id: string; + attributes: JSONObject; +} + +export const deserialize = (data: Deserializable): T => { + const attributes = data.attributes; + const resource = { + id: data.id, + resourceType: data.type, + ...attributes, + }; + + return resource as T; +}; diff --git a/src/helpers/error.ts b/src/helpers/error.ts new file mode 100644 index 0000000..c5a848f --- /dev/null +++ b/src/helpers/error.ts @@ -0,0 +1,11 @@ +import { AxiosResponse } from 'axios'; + +export interface APIError { + code: string; + detail: string; + title: string; +} + +export type ErrorList = { errors: APIError[] }; + +export type ErrorResponse = AxiosResponse; diff --git a/src/hooks/index.tsx b/src/hooks/index.tsx new file mode 100644 index 0000000..f538bc3 --- /dev/null +++ b/src/hooks/index.tsx @@ -0,0 +1,8 @@ +import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; + +import type { RootState, AppDispatch } from 'store'; + +// Use throughout your app instead of plain `useDispatch` and `useSelector` +type DispatchFunc = () => AppDispatch; +export const useAppDispatch: DispatchFunc = useDispatch; +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/src/index.tsx b/src/index.tsx index 5c6e448..58a6772 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,18 +1,23 @@ import React, { Suspense } from 'react'; +import { Provider } from 'react-redux'; import { BrowserRouter } from 'react-router-dom'; import { createRoot } from 'react-dom/client'; +import { store } from 'store'; + import App from './App'; const container = document.getElementById('root') as HTMLElement; const root = createRoot(container); root.render( - - - - - + + + + + + + ); diff --git a/src/lib/request/v1/requestManager.ts b/src/lib/request/v1/requestManager.ts index 479cc60..6776095 100644 --- a/src/lib/request/v1/requestManager.ts +++ b/src/lib/request/v1/requestManager.ts @@ -11,6 +11,16 @@ export const successResponseInterceptor = (response: AxiosResponse): Ax return response; }; +export const errorInterceptor = async (error: AxiosError): Promise => { + if (error.response) { + const errorData = error.response.data as JSONValue; + const formattedData = keysToCamelCase(errorData); + error.response.data = formattedData; + } + + return Promise.reject(error); +}; + export const defaultOptions = (): { responseType: ResponseType; baseURL: string; headers?: { [key: string]: string } } => ({ responseType: 'json', baseURL: `${config().apiBaseUrl}/api/v1`, @@ -41,7 +51,7 @@ const requestManager = ( ...requestOptions, }; - axios.interceptors.response.use(successResponseInterceptor); + axios.interceptors.response.use(successResponseInterceptor, errorInterceptor); return axios .request(requestParams) diff --git a/src/reducers/.keep b/src/reducers/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 8294bf8..2eb166d 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,13 +1,24 @@ import React from 'react'; import { RouteObject } from 'react-router-dom'; +import DashBoardScreen from 'screens/Dashboard'; import SignInScreen from 'screens/SignIn'; +// TODO Update dashboard path to root and apply redirect if the user haven't logged in +export const paths = { + root: '/', + dashboard: '/dashboard', +}; + const routes: RouteObject[] = [ { - path: '/', + path: paths.root, element: , }, + { + path: paths.dashboard, + element: , + }, ]; export default routes; diff --git a/src/screens/Dashboard/index.tsx b/src/screens/Dashboard/index.tsx new file mode 100644 index 0000000..65dd228 --- /dev/null +++ b/src/screens/Dashboard/index.tsx @@ -0,0 +1,22 @@ +import React, { useEffect, useState } from 'react'; + +import { getToken } from 'helpers/authentication'; + +const DashBoardScreen = (): JSX.Element => { + // TODO This is a test. Will remove it later. + const [token, setToken] = useState(''); + + useEffect(() => { + const userToken = getToken(); + if (userToken) { + setToken(userToken.accessToken); + } + }, []); + return ( +
+

Welcome to your Dashboard {token}

+
+ ); +}; + +export default DashBoardScreen; diff --git a/src/screens/SignIn/index.tsx b/src/screens/SignIn/index.tsx index 05c1168..81f65cc 100644 --- a/src/screens/SignIn/index.tsx +++ b/src/screens/SignIn/index.tsx @@ -1,8 +1,14 @@ -import React from 'react'; +import React, { useState } from 'react'; +import { Navigate } from 'react-router-dom'; import nimbleLogoWhite from 'assets/images/icons/nimble-logo-white.svg'; +import Alert from 'components/Alert'; import ElevatedButton from 'components/ElevatedButton'; +import LoadingDialog from 'components/LoadingDialog'; import TextInput from 'components/TextInput'; +import { useAppDispatch, useAppSelector } from 'hooks'; +import { paths } from 'routes'; +import { signIn } from 'store/reducers/authSlice'; export const signInScreenTestIds = { nimbleLogo: 'sign-in__nimble-logo', @@ -16,17 +22,26 @@ export const signInScreenTestIds = { }; const SignInScreen = (): JSX.Element => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const { loading, errors, success } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + const handleSubmit = async (event: React.SyntheticEvent) => { event.preventDefault(); - // TODO: call signIn + dispatch(signIn({ email, password })); }; return (
nimble logo -
Sign in to Nimble
- +
Sign in to Nimble
+ +
{errors && }
+ +
{ required: true, type: 'email', 'data-test-id': signInScreenTestIds.emailField, + onChange: (event) => setEmail(event.target.value), }} />
@@ -49,6 +65,7 @@ const SignInScreen = (): JSX.Element => { required: true, type: 'password', 'data-test-id': signInScreenTestIds.passwordField, + onChange: (event) => setPassword(event.target.value), }} className="pr-16" /> @@ -65,6 +82,10 @@ const SignInScreen = (): JSX.Element => { Sign in + + {success && } + + {loading && }
); }; diff --git a/src/store/index.tsx b/src/store/index.tsx new file mode 100644 index 0000000..ec1743c --- /dev/null +++ b/src/store/index.tsx @@ -0,0 +1,14 @@ +import { combineReducers, configureStore } from '@reduxjs/toolkit'; + +import { authSlice } from './reducers/authSlice'; + +const rootReducer = combineReducers({ auth: authSlice.reducer }); + +export const store = configureStore({ + reducer: rootReducer, +}); + +// Infer the `RootState` and `AppDispatch` types from the store itself +export type RootState = ReturnType; +// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState} +export type AppDispatch = typeof store.dispatch; diff --git a/src/store/reducers/authSlice.tsx b/src/store/reducers/authSlice.tsx new file mode 100644 index 0000000..90a1fcd --- /dev/null +++ b/src/store/reducers/authSlice.tsx @@ -0,0 +1,66 @@ +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; + +import authenticationAdapter from 'adapters/Authentication'; +import { setToken } from 'helpers/authentication'; +import { DeserializableResponse, deserialize } from 'helpers/deserializer'; +import { ErrorResponse } from 'helpers/error'; +import { JSONObject } from 'helpers/json'; +import { SignIn } from 'types/signIn'; + +export interface SignInInput { + email: string; + password: string; +} + +export interface AuthenticationState { + loading: boolean; + userToken?: string; + errors?: string[]; + success: boolean; +} + +const initialState: AuthenticationState = { + loading: false, + success: false, +}; + +export const signIn = createAsyncThunk('auth/signIn', async (input, { rejectWithValue }) => { + return authenticationAdapter + .signIn(input.email, input.password) + .then((response: DeserializableResponse) => { + const signInType = deserialize(response.data); + + setToken(signInType); + return signInType; + }) + .catch((error) => { + if (!error.response) { + throw error; + } + + const { data, status } = error.response; + + return rejectWithValue({ data, status }); + }); +}); + +export const authSlice = createSlice({ + name: 'auth', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(signIn.pending, (state) => { + state.errors = undefined; + state.loading = true; + }); + builder.addCase(signIn.fulfilled, (state, action) => { + state.loading = false; + state.success = true; + state.userToken = `${action.payload.id} ${action.payload.tokenType}`; + }); + builder.addCase(signIn.rejected, (state, action) => { + state.loading = false; + state.errors = (action.payload as ErrorResponse).data.errors.map((error) => error.detail); + }); + }, +}); diff --git a/src/types/resource.ts b/src/types/resource.ts new file mode 100644 index 0000000..da7f9a9 --- /dev/null +++ b/src/types/resource.ts @@ -0,0 +1,4 @@ +export interface Resource { + id: string; + resourceType: string; +} diff --git a/src/types/signIn.ts b/src/types/signIn.ts new file mode 100644 index 0000000..f7ae667 --- /dev/null +++ b/src/types/signIn.ts @@ -0,0 +1,7 @@ +import { Resource } from 'types/resource'; + +export interface SignIn extends Resource { + accessToken: string; + tokenType: string; + refreshToken: string; +} diff --git a/tailwind.config.js b/tailwind.config.js index 3c8af8d..169480f 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -8,8 +8,10 @@ module.exports = { }, colors: { 'black-chinese': '#15151A', + 'black-raisin': '#252525', }, fontSize: { + xSmall: ['13px', '18px'], small: ['15px', '20px'], regular: ['17px', '22px'], }, From 6dd003b22c5d2b6a663c45f236734bd8c7737297 Mon Sep 17 00:00:00 2001 From: Tran Manh Date: Mon, 19 Jun 2023 10:57:03 +0700 Subject: [PATCH 08/25] [#8] Write unittest for reducers --- src/screens/SignIn/index.test.tsx | 12 +- src/screens/SignIn/index.tsx | 2 +- src/store/index.tsx | 10 +- src/store/reducers/Authentication/actions.ts | 32 +++++ .../reducers/Authentication/index.test.ts | 117 ++++++++++++++++++ src/store/reducers/Authentication/index.ts | 38 ++++++ src/store/reducers/authSlice.tsx | 66 ---------- src/tests/TestWrapper/index.tsx | 27 ++++ src/tests/error.ts | 26 ++++ 9 files changed, 258 insertions(+), 72 deletions(-) create mode 100644 src/store/reducers/Authentication/actions.ts create mode 100644 src/store/reducers/Authentication/index.test.ts create mode 100644 src/store/reducers/Authentication/index.ts delete mode 100644 src/store/reducers/authSlice.tsx create mode 100644 src/tests/TestWrapper/index.tsx create mode 100644 src/tests/error.ts diff --git a/src/screens/SignIn/index.test.tsx b/src/screens/SignIn/index.test.tsx index a494ddd..e095a64 100644 --- a/src/screens/SignIn/index.test.tsx +++ b/src/screens/SignIn/index.test.tsx @@ -2,11 +2,21 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; +import TestWrapper from 'tests/TestWrapper'; + import SignInScreen, { signInScreenTestIds } from '.'; describe('SignInScreen', () => { + const TestComponent = (): JSX.Element => { + return ( + + + + ); + }; + it('renders Sign In form and its components', () => { - render(); + render(); const emailLabel = screen.getByTestId(signInScreenTestIds.emailLabel); const emailField = screen.getByTestId(signInScreenTestIds.emailField); const passwordLabel = screen.getByTestId(signInScreenTestIds.passwordLabel); diff --git a/src/screens/SignIn/index.tsx b/src/screens/SignIn/index.tsx index 81f65cc..e8b5192 100644 --- a/src/screens/SignIn/index.tsx +++ b/src/screens/SignIn/index.tsx @@ -8,7 +8,7 @@ import LoadingDialog from 'components/LoadingDialog'; import TextInput from 'components/TextInput'; import { useAppDispatch, useAppSelector } from 'hooks'; import { paths } from 'routes'; -import { signIn } from 'store/reducers/authSlice'; +import { signIn } from 'store/reducers/Authentication'; export const signInScreenTestIds = { nimbleLogo: 'sign-in__nimble-logo', diff --git a/src/store/index.tsx b/src/store/index.tsx index ec1743c..a95f0d0 100644 --- a/src/store/index.tsx +++ b/src/store/index.tsx @@ -1,11 +1,13 @@ -import { combineReducers, configureStore } from '@reduxjs/toolkit'; +import { configureStore } from '@reduxjs/toolkit'; -import { authSlice } from './reducers/authSlice'; +import { authSlice } from './reducers/Authentication'; -const rootReducer = combineReducers({ auth: authSlice.reducer }); +export const reducers = { + auth: authSlice.reducer, +}; export const store = configureStore({ - reducer: rootReducer, + reducer: reducers, }); // Infer the `RootState` and `AppDispatch` types from the store itself diff --git a/src/store/reducers/Authentication/actions.ts b/src/store/reducers/Authentication/actions.ts new file mode 100644 index 0000000..614aa23 --- /dev/null +++ b/src/store/reducers/Authentication/actions.ts @@ -0,0 +1,32 @@ +import { AsyncThunkPayloadCreator } from '@reduxjs/toolkit'; + +import authenticationAdapter from 'adapters/Authentication'; +import { setToken } from 'helpers/authentication'; +import { DeserializableResponse, deserialize } from 'helpers/deserializer'; +import { JSONObject } from 'helpers/json'; +import { SignIn } from 'types/signIn'; + +export interface SignInInput { + email: string; + password: string; +} + +export const signInAsync: AsyncThunkPayloadCreator = async (input, { rejectWithValue }) => { + return authenticationAdapter + .signIn(input.email, input.password) + .then((response: DeserializableResponse) => { + const signInType = deserialize(response.data); + + setToken(signInType); + return signInType; + }) + .catch((error) => { + if (!error.response) { + throw error; + } + + const { data, status } = error.response; + + return rejectWithValue({ data, status }); + }); +}; diff --git a/src/store/reducers/Authentication/index.test.ts b/src/store/reducers/Authentication/index.test.ts new file mode 100644 index 0000000..47c0857 --- /dev/null +++ b/src/store/reducers/Authentication/index.test.ts @@ -0,0 +1,117 @@ +import { AxiosResponse } from 'axios'; + +import authenticationAdapter from 'adapters/Authentication'; +import { APIError } from 'helpers/error'; +import { mockAxiosError } from 'tests/error'; + +import { authSlice, initialState, signIn } from '.'; +import { SignInInput, signInAsync } from './actions'; + +jest.mock('adapters/Authentication'); + +describe('auth slice', () => { + describe('signIn', () => { + const mockSignIn = jest.fn(); + + beforeEach(() => { + authenticationAdapter.signIn = mockSignIn; + }); + + describe('payload creator', () => { + const mockDispatch = jest.fn(); + const mockRejectWithValue = jest.fn(); + const mockThunkAPI = { + dispatch: mockDispatch, + getState: jest.fn(), + extra: undefined, + requestId: '', + signal: jest.fn() as unknown as AbortSignal, + abort: jest.fn(), + rejectWithValue: mockRejectWithValue, + fulfillWithValue: jest.fn(), + }; + const resourceId = 'resource id'; + const successResponse = { + data: { + id: resourceId, + type: 'resource type', + attributes: { accessToken: 'access token', refreshToken: 'refresh token', tokenType: 'token type' }, + }, + }; + + const errors: APIError[] = [ + { + title: 'Internal server error', + detail: 'error detail', + code: 'internal_server_error', + }, + ]; + const mockError = mockAxiosError(500, 'Internal server error', errors); + + it('calls signIn API successfully', async () => { + const payload: SignInInput = { email: 'test@test.com', password: 'password' }; + + const authenticationMock = jest.spyOn(authenticationAdapter, 'signIn'); + authenticationMock.mockResolvedValue(successResponse as AxiosResponse); + + await signInAsync(payload, mockThunkAPI); + + expect(authenticationMock).toHaveBeenCalledWith(...Object.values(payload)); + + authenticationMock.mockRestore(); + }); + + it('calls signIn API unsuccessfully WITH response data', async () => { + const payload: SignInInput = { email: 'test@test.com', password: 'password' }; + + const authenticationMock = jest.spyOn(authenticationAdapter, 'signIn'); + authenticationMock.mockRejectedValue(mockError); + + await signInAsync(payload, mockThunkAPI); + + expect(mockRejectWithValue).toHaveBeenCalledWith({ data: mockError.response?.data, status: mockError.response?.status }); + + authenticationMock.mockRestore(); + }); + }); + + describe('given the thunk action is pending', () => { + it('sets loading to true and resets errors', () => { + const action = { type: signIn.pending.type, payload: { email: 'test@test.com', password: 'password' } }; + const state = authSlice.reducer(initialState, action); + + expect(state.loading).toBe(true); + expect(state.errors).toBeUndefined(); + }); + }); + + describe('given the thunk action is fulfilled', () => { + it('sets success to true and loading to false', () => { + const action = { type: signIn.fulfilled.type, payload: { email: 'test@test.com', password: 'password' } }; + const state = authSlice.reducer(initialState, action); + + expect(state.loading).toBe(false); + expect(state.success).toBe(true); + }); + }); + + describe('given the thunk action is rejected', () => { + const errors: APIError[] = [ + { + title: 'Internal server error', + detail: 'error detail', + code: 'internal_server_error', + }, + ]; + const mockError = mockAxiosError(500, 'Internal server error', errors); + + it('sets loading to false and adds data to error', () => { + const action = { type: signIn.rejected.type, payload: mockError.response }; + const state = authSlice.reducer(initialState, action); + + expect(state.loading).toBe(false); + expect(state.errors).toEqual([errors[0].detail]); + }); + }); + }); +}); diff --git a/src/store/reducers/Authentication/index.ts b/src/store/reducers/Authentication/index.ts new file mode 100644 index 0000000..e84ecfe --- /dev/null +++ b/src/store/reducers/Authentication/index.ts @@ -0,0 +1,38 @@ +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; + +import { ErrorResponse } from 'helpers/error'; + +import { signInAsync } from './actions'; + +export interface AuthenticationState { + loading: boolean; + errors?: string[]; + success: boolean; +} + +export const initialState: AuthenticationState = { + loading: false, + success: false, +}; + +export const signIn = createAsyncThunk('auth/signIn', signInAsync); + +export const authSlice = createSlice({ + name: 'auth', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(signIn.pending, (state) => { + state.errors = undefined; + state.loading = true; + }); + builder.addCase(signIn.fulfilled, (state) => { + state.loading = false; + state.success = true; + }); + builder.addCase(signIn.rejected, (state, action) => { + state.loading = false; + state.errors = (action.payload as ErrorResponse).data.errors.map((error) => error.detail); + }); + }, +}); diff --git a/src/store/reducers/authSlice.tsx b/src/store/reducers/authSlice.tsx deleted file mode 100644 index 90a1fcd..0000000 --- a/src/store/reducers/authSlice.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; - -import authenticationAdapter from 'adapters/Authentication'; -import { setToken } from 'helpers/authentication'; -import { DeserializableResponse, deserialize } from 'helpers/deserializer'; -import { ErrorResponse } from 'helpers/error'; -import { JSONObject } from 'helpers/json'; -import { SignIn } from 'types/signIn'; - -export interface SignInInput { - email: string; - password: string; -} - -export interface AuthenticationState { - loading: boolean; - userToken?: string; - errors?: string[]; - success: boolean; -} - -const initialState: AuthenticationState = { - loading: false, - success: false, -}; - -export const signIn = createAsyncThunk('auth/signIn', async (input, { rejectWithValue }) => { - return authenticationAdapter - .signIn(input.email, input.password) - .then((response: DeserializableResponse) => { - const signInType = deserialize(response.data); - - setToken(signInType); - return signInType; - }) - .catch((error) => { - if (!error.response) { - throw error; - } - - const { data, status } = error.response; - - return rejectWithValue({ data, status }); - }); -}); - -export const authSlice = createSlice({ - name: 'auth', - initialState, - reducers: {}, - extraReducers: (builder) => { - builder.addCase(signIn.pending, (state) => { - state.errors = undefined; - state.loading = true; - }); - builder.addCase(signIn.fulfilled, (state, action) => { - state.loading = false; - state.success = true; - state.userToken = `${action.payload.id} ${action.payload.tokenType}`; - }); - builder.addCase(signIn.rejected, (state, action) => { - state.loading = false; - state.errors = (action.payload as ErrorResponse).data.errors.map((error) => error.detail); - }); - }, -}); diff --git a/src/tests/TestWrapper/index.tsx b/src/tests/TestWrapper/index.tsx new file mode 100644 index 0000000..89f33a0 --- /dev/null +++ b/src/tests/TestWrapper/index.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; + +import { configureStore } from '@reduxjs/toolkit'; + +import { reducers } from 'store'; + +interface TestWrapperProps { + children: React.ReactNode; +} + +let store: ReturnType; + +beforeEach(() => { + store = configureStore({ reducer: reducers }); +}); + +const TestWrapper = ({ children }: TestWrapperProps): JSX.Element => { + return ( + + {children} + + ); +}; + +export default TestWrapper; diff --git a/src/tests/error.ts b/src/tests/error.ts new file mode 100644 index 0000000..d73aee3 --- /dev/null +++ b/src/tests/error.ts @@ -0,0 +1,26 @@ +import { AxiosError, AxiosHeaders } from 'axios'; + +import { APIError, ErrorList } from 'helpers/error'; + +export const mockAxiosError = (status: number, statusText = '', data?: APIError[]): AxiosError => { + return { + isAxiosError: true, + name: '', + message: '', + toJSON: () => ({}), + config: { + headers: new AxiosHeaders(), + }, + response: { + data: { + errors: data || [], + }, + status: status, + statusText: statusText, + headers: {}, + config: { + headers: new AxiosHeaders(), + }, + }, + }; +}; From e28c0e939662d22ff75a14cb43c4cd9500435ca8 Mon Sep 17 00:00:00 2001 From: Tran Manh Date: Mon, 19 Jun 2023 14:37:13 +0700 Subject: [PATCH 09/25] [#8] Add useNavigate --- .github/workflows/deploy_preview.yml | 3 +++ src/screens/SignIn/index.tsx | 14 ++++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy_preview.yml b/.github/workflows/deploy_preview.yml index 19f8996..bf56b17 100644 --- a/.github/workflows/deploy_preview.yml +++ b/.github/workflows/deploy_preview.yml @@ -17,6 +17,9 @@ jobs: - name: Install modules run: npm ci + - name: Decode .env file + run: echo "${{ secrets.ENV_FILE_CONTENT }}" | base64 -d > .env + - name: Build run: npm run build env: diff --git a/src/screens/SignIn/index.tsx b/src/screens/SignIn/index.tsx index e8b5192..c03eedd 100644 --- a/src/screens/SignIn/index.tsx +++ b/src/screens/SignIn/index.tsx @@ -1,5 +1,5 @@ -import React, { useState } from 'react'; -import { Navigate } from 'react-router-dom'; +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import nimbleLogoWhite from 'assets/images/icons/nimble-logo-white.svg'; import Alert from 'components/Alert'; @@ -26,6 +26,8 @@ const SignInScreen = (): JSX.Element => { const [password, setPassword] = useState(''); const { loading, errors, success } = useAppSelector((state) => state.auth); + const navigate = useNavigate(); + const dispatch = useAppDispatch(); const handleSubmit = async (event: React.SyntheticEvent) => { @@ -34,6 +36,12 @@ const SignInScreen = (): JSX.Element => { dispatch(signIn({ email, password })); }; + useEffect(() => { + if (success) { + navigate(paths.dashboard, { replace: true }); + } + }, [navigate, success]); + return (
nimble logo @@ -83,8 +91,6 @@ const SignInScreen = (): JSX.Element => { - {success && } - {loading && }
); From 4b3fe9fcd3e36e639fe9e3dc651e6e2988abded5 Mon Sep 17 00:00:00 2001 From: Tran Manh Date: Mon, 19 Jun 2023 17:51:26 +0700 Subject: [PATCH 10/25] [#8] Write more test --- src/components/Alert/index.tsx | 5 +- src/components/LoadingDialog/index.tsx | 8 ++- src/screens/SignIn/index.test.tsx | 72 ++++++++++++++++++++++++++ src/screens/SignIn/index.tsx | 6 ++- 4 files changed, 85 insertions(+), 6 deletions(-) diff --git a/src/components/Alert/index.tsx b/src/components/Alert/index.tsx index 5d1132b..865d03f 100644 --- a/src/components/Alert/index.tsx +++ b/src/components/Alert/index.tsx @@ -4,9 +4,10 @@ import { ReactComponent as AlertIcon } from 'assets/images/icons/alert.svg'; interface AlertProps { errors: string[]; + 'data-test-id'?: string; } -const Alert = ({ errors }: AlertProps): JSX.Element => ( -
+const Alert = ({ errors, ...htmlAttributes }: AlertProps): JSX.Element => ( +
diff --git a/src/components/LoadingDialog/index.tsx b/src/components/LoadingDialog/index.tsx index 25ca196..687ce46 100644 --- a/src/components/LoadingDialog/index.tsx +++ b/src/components/LoadingDialog/index.tsx @@ -1,8 +1,12 @@ import React from 'react'; -const LoadingDialog = (): JSX.Element => { +interface LoadingDialogProps { + 'data-test-id'?: string; +} + +const LoadingDialog = ({ ...htmlAttributes }: LoadingDialogProps): JSX.Element => { return ( -
+
({ + ...(jest.requireActual('react-router-dom') as jest.Mock), + useNavigate: () => mockUseNavigate, +})); + describe('SignInScreen', () => { const TestComponent = (): JSX.Element => { return ( @@ -15,6 +27,22 @@ describe('SignInScreen', () => { ); }; + const mockState: { auth: AuthenticationState } = { + auth: { + loading: false, + success: false, + }, + }; + + beforeEach(() => { + (useAppSelector as jest.Mock).mockImplementation((callback) => callback(mockState)); + (useAppDispatch as jest.Mock).mockImplementation(() => mockDispatch); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + it('renders Sign In form and its components', () => { render(); const emailLabel = screen.getByTestId(signInScreenTestIds.emailLabel); @@ -45,4 +73,48 @@ describe('SignInScreen', () => { expect(nimbleLogo).toBeVisible(); }); + + describe('given the errors field has data', () => { + beforeEach(() => { + mockState.auth.errors = ['error']; + }); + + it('renders the Alert', () => { + render(); + + const errorAlert = screen.getByTestId(signInScreenTestIds.errorAlert); + + expect(errorAlert).toBeVisible(); + }); + }); + + describe('given the loading has data', () => { + it('renders the loading dialog if loading is true', () => { + mockState.auth.loading = true; + render(); + + const loadingDialog = screen.getByTestId(signInScreenTestIds.loadingDialog); + + expect(loadingDialog).toBeVisible(); + }); + + it('does NOT renders the loading dialog if loading is false', () => { + mockState.auth.loading = false; + render(); + + expect(screen.queryByTestId(signInScreenTestIds.loadingDialog)).not.toBeInTheDocument(); + }); + }); + + describe('given the success is true', () => { + beforeEach(() => { + mockState.auth.success = true; + }); + + it('navigate to the Dashboard screen', () => { + render(); + + expect(mockUseNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }); + }); + }); }); diff --git a/src/screens/SignIn/index.tsx b/src/screens/SignIn/index.tsx index c03eedd..8134e42 100644 --- a/src/screens/SignIn/index.tsx +++ b/src/screens/SignIn/index.tsx @@ -19,6 +19,8 @@ export const signInScreenTestIds = { passwordField: 'sign-in-form__input-password', forgotButton: 'sign-in-form__forgot-button', signInButton: 'sign-in-form__button', + errorAlert: 'sign-in__error-alert', + loadingDialog: 'sign-in__loading-dialog', }; const SignInScreen = (): JSX.Element => { @@ -47,7 +49,7 @@ const SignInScreen = (): JSX.Element => { nimble logo
Sign in to Nimble
-
{errors && }
+
{errors && }
@@ -91,7 +93,7 @@ const SignInScreen = (): JSX.Element => { - {loading && } + {loading && }
); }; From bad50ecae7a37dd5a8ce4d84154af19c0f6d1064 Mon Sep 17 00:00:00 2001 From: Tran Manh Date: Tue, 27 Jun 2023 15:32:24 +0700 Subject: [PATCH 11/25] [#8] Update unittest --- .github/workflows/deploy_preview.yml | 3 - src/store/reducers/Authentication/actions.ts | 5 +- .../reducers/Authentication/index.test.ts | 105 ++++++++++++------ 3 files changed, 74 insertions(+), 39 deletions(-) diff --git a/.github/workflows/deploy_preview.yml b/.github/workflows/deploy_preview.yml index bf56b17..19f8996 100644 --- a/.github/workflows/deploy_preview.yml +++ b/.github/workflows/deploy_preview.yml @@ -17,9 +17,6 @@ jobs: - name: Install modules run: npm ci - - name: Decode .env file - run: echo "${{ secrets.ENV_FILE_CONTENT }}" | base64 -d > .env - - name: Build run: npm run build env: diff --git a/src/store/reducers/Authentication/actions.ts b/src/store/reducers/Authentication/actions.ts index 614aa23..a0e1d90 100644 --- a/src/store/reducers/Authentication/actions.ts +++ b/src/store/reducers/Authentication/actions.ts @@ -1,6 +1,6 @@ import { AsyncThunkPayloadCreator } from '@reduxjs/toolkit'; -import authenticationAdapter from 'adapters/Authentication'; +import { signIn } from 'adapters/Authentication'; import { setToken } from 'helpers/authentication'; import { DeserializableResponse, deserialize } from 'helpers/deserializer'; import { JSONObject } from 'helpers/json'; @@ -12,8 +12,7 @@ export interface SignInInput { } export const signInAsync: AsyncThunkPayloadCreator = async (input, { rejectWithValue }) => { - return authenticationAdapter - .signIn(input.email, input.password) + return signIn(input.email, input.password) .then((response: DeserializableResponse) => { const signInType = deserialize(response.data); diff --git a/src/store/reducers/Authentication/index.test.ts b/src/store/reducers/Authentication/index.test.ts index 47c0857..0bfdf7d 100644 --- a/src/store/reducers/Authentication/index.test.ts +++ b/src/store/reducers/Authentication/index.test.ts @@ -1,41 +1,34 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ import { AxiosResponse } from 'axios'; -import authenticationAdapter from 'adapters/Authentication'; +import { signIn as authenticationSignIn } from 'adapters/Authentication'; import { APIError } from 'helpers/error'; import { mockAxiosError } from 'tests/error'; import { authSlice, initialState, signIn } from '.'; -import { SignInInput, signInAsync } from './actions'; +import { SignInInput } from './actions'; + +// The AsyncThunk test is following https://github.com/reduxjs/redux-toolkit/blob/635d6d5e513e13dd59cd717f600d501b30ca2381/src/tests/createAsyncThunk.test.ts jest.mock('adapters/Authentication'); describe('auth slice', () => { describe('signIn', () => { - const mockSignIn = jest.fn(); - - beforeEach(() => { - authenticationAdapter.signIn = mockSignIn; + afterEach(() => { + jest.restoreAllMocks(); }); describe('payload creator', () => { - const mockDispatch = jest.fn(); - const mockRejectWithValue = jest.fn(); - const mockThunkAPI = { - dispatch: mockDispatch, - getState: jest.fn(), - extra: undefined, - requestId: '', - signal: jest.fn() as unknown as AbortSignal, - abort: jest.fn(), - rejectWithValue: mockRejectWithValue, - fulfillWithValue: jest.fn(), - }; const resourceId = 'resource id'; + const resourceType = 'resource type'; + const accessToken = 'access token'; + const refreshToken = 'refresh token'; + const tokenType = 'token type'; const successResponse = { data: { id: resourceId, - type: 'resource type', - attributes: { accessToken: 'access token', refreshToken: 'refresh token', tokenType: 'token type' }, + type: resourceType, + attributes: { accessToken: accessToken, refreshToken: refreshToken, tokenType: tokenType }, }, }; @@ -49,29 +42,75 @@ describe('auth slice', () => { const mockError = mockAxiosError(500, 'Internal server error', errors); it('calls signIn API successfully', async () => { - const payload: SignInInput = { email: 'test@test.com', password: 'password' }; - - const authenticationMock = jest.spyOn(authenticationAdapter, 'signIn'); - authenticationMock.mockResolvedValue(successResponse as AxiosResponse); + (authenticationSignIn as jest.Mock).mockResolvedValue(successResponse as AxiosResponse); + const dispatch = jest.fn(); + const input: SignInInput = { email: 'test@test.com', password: 'password' }; - await signInAsync(payload, mockThunkAPI); + const signInFunction = signIn(input); - expect(authenticationMock).toHaveBeenCalledWith(...Object.values(payload)); + const signInPayload = await signInFunction(dispatch, () => {}, undefined); - authenticationMock.mockRestore(); + const expectedResult = { + accessToken: accessToken, + id: resourceId, + refreshToken: refreshToken, + resourceType: resourceType, + tokenType: tokenType, + }; + + expect(signInPayload.meta.arg).toBe(input); + expect(signInPayload.payload).toEqual({ + accessToken: 'access token', + id: 'resource id', + refreshToken: 'refresh token', + resourceType: 'resource type', + tokenType: 'token type', + }); + + expect(dispatch).toHaveBeenNthCalledWith(1, signIn.pending(signInPayload.meta.requestId, input)); + expect(dispatch).toHaveBeenNthCalledWith(2, signIn.fulfilled(expectedResult, signInPayload.meta.requestId, input)); }); it('calls signIn API unsuccessfully WITH response data', async () => { - const payload: SignInInput = { email: 'test@test.com', password: 'password' }; + (authenticationSignIn as jest.Mock).mockRejectedValue(mockError); + const dispatch = jest.fn(); + + const input: SignInInput = { email: 'test@test.com', password: 'password' }; + const signInFunction = signIn(input); + + try { + await signInFunction(dispatch, () => {}, undefined); + } catch (e) {} + + const errorAction = dispatch.mock.calls[1][0]; + + expect(errorAction.error.message).toBe('Rejected'); + expect(errorAction.payload).toEqual({ data: mockError.response?.data, status: mockError.response?.status }); + expect(errorAction.meta.arg).toBe(input); + + expect(dispatch).toHaveBeenNthCalledWith(1, signIn.pending(errorAction.meta.requestId, input)); + expect(dispatch).toHaveBeenCalledTimes(2); + }); + + it('calls signIn API unsuccessfully WITHOUT response data', async () => { + const error = Error('error test'); + (authenticationSignIn as jest.Mock).mockRejectedValue(error); + const dispatch = jest.fn(); + + const input: SignInInput = { email: 'test@test.com', password: 'password' }; + const signInFunction = signIn(input); - const authenticationMock = jest.spyOn(authenticationAdapter, 'signIn'); - authenticationMock.mockRejectedValue(mockError); + try { + await signInFunction(dispatch, () => {}, undefined); + } catch (e) {} - await signInAsync(payload, mockThunkAPI); + const errorAction = dispatch.mock.calls[1][0]; - expect(mockRejectWithValue).toHaveBeenCalledWith({ data: mockError.response?.data, status: mockError.response?.status }); + expect(errorAction.error.message).toBe(error.message); + expect(errorAction.meta.arg).toBe(input); - authenticationMock.mockRestore(); + expect(dispatch).toHaveBeenNthCalledWith(1, signIn.pending(errorAction.meta.requestId, input)); + expect(dispatch).toHaveBeenCalledTimes(2); }); }); From 0a3e0b4e0fdf90f6926c6315081fee6a471a6728 Mon Sep 17 00:00:00 2001 From: Tran Manh Date: Wed, 5 Jul 2023 16:03:06 +0700 Subject: [PATCH 12/25] [#8] Move error to type --- src/components/LoadingDialog/index.tsx | 2 +- src/store/reducers/Authentication/index.test.ts | 2 +- src/store/reducers/Authentication/index.ts | 2 +- src/tests/error.ts | 2 +- src/{helpers => types}/error.ts | 0 5 files changed, 4 insertions(+), 4 deletions(-) rename src/{helpers => types}/error.ts (100%) diff --git a/src/components/LoadingDialog/index.tsx b/src/components/LoadingDialog/index.tsx index 687ce46..9594b42 100644 --- a/src/components/LoadingDialog/index.tsx +++ b/src/components/LoadingDialog/index.tsx @@ -4,7 +4,7 @@ interface LoadingDialogProps { 'data-test-id'?: string; } -const LoadingDialog = ({ ...htmlAttributes }: LoadingDialogProps): JSX.Element => { +const LoadingDialog = (htmlAttributes: LoadingDialogProps): JSX.Element => { return (
=> { return { diff --git a/src/helpers/error.ts b/src/types/error.ts similarity index 100% rename from src/helpers/error.ts rename to src/types/error.ts From d92cb3e4d15bdc7b8bbf2698efaf0dc3f1a255d7 Mon Sep 17 00:00:00 2001 From: Tran Manh Date: Fri, 7 Jul 2023 10:11:18 +0700 Subject: [PATCH 13/25] [#8] Refactor the rest attributes to rest --- src/components/Alert/index.tsx | 4 ++-- src/components/ElevatedButton/index.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/Alert/index.tsx b/src/components/Alert/index.tsx index 865d03f..4e4aee5 100644 --- a/src/components/Alert/index.tsx +++ b/src/components/Alert/index.tsx @@ -6,8 +6,8 @@ interface AlertProps { errors: string[]; 'data-test-id'?: string; } -const Alert = ({ errors, ...htmlAttributes }: AlertProps): JSX.Element => ( -
+const Alert = ({ errors, ...rest }: AlertProps): JSX.Element => ( +
diff --git a/src/components/ElevatedButton/index.tsx b/src/components/ElevatedButton/index.tsx index 410386d..04d1961 100644 --- a/src/components/ElevatedButton/index.tsx +++ b/src/components/ElevatedButton/index.tsx @@ -7,12 +7,12 @@ interface ElevatedButtonProps extends React.ButtonHTMLAttributes { +const ElevatedButton = ({ children, isFullWidth, ...rest }: ElevatedButtonProps): JSX.Element => { const DEFAULT_CLASS_NAMES = 'bg-white text-black-chinese font-bold text-regular tracking-survey-tight rounded-[10px] focus:outline-none focus:shadow-outline h-14'; return ( - ); From 8c4aac9017dcc23a892dac91b277415d3d5a7a01 Mon Sep 17 00:00:00 2001 From: Tran Manh Date: Wed, 12 Jul 2023 11:49:17 +0700 Subject: [PATCH 14/25] [#8] Fix some minor issues --- src/components/LoadingDialog/index.tsx | 2 +- src/helpers/authentication.test.ts | 8 ++++---- src/helpers/authentication.ts | 6 +++--- src/helpers/deserializer.ts | 9 ++++----- src/screens/Dashboard/index.tsx | 4 ++-- src/screens/SignIn/index.tsx | 3 +-- src/store/reducers/Authentication/actions.ts | 4 ++-- 7 files changed, 17 insertions(+), 19 deletions(-) diff --git a/src/components/LoadingDialog/index.tsx b/src/components/LoadingDialog/index.tsx index 9594b42..e87b630 100644 --- a/src/components/LoadingDialog/index.tsx +++ b/src/components/LoadingDialog/index.tsx @@ -6,7 +6,7 @@ interface LoadingDialogProps { const LoadingDialog = (htmlAttributes: LoadingDialogProps): JSX.Element => { return ( -
+
{ const mockStorage: { [key: string]: string | null } = {}; @@ -39,7 +39,7 @@ describe('Authentication helper', () => { }); it('returns the tokens from local storage', () => { - const tokens = getToken(); + const tokens = getTokens(); expect(tokens?.accessToken).toBe(mockAccessToken); expect(tokens?.refreshToken).toBe(mockRefreshToken); @@ -58,7 +58,7 @@ describe('Authentication helper', () => { const setItemSpy = jest.spyOn(window.Storage.prototype, 'setItem'); - setToken(mockTokens); + setTokens(mockTokens); expect(setItemSpy).toHaveBeenCalledWith(authTokenKey, JSON.stringify(mockTokens)); }); @@ -79,7 +79,7 @@ describe('Authentication helper', () => { it('clears the tokens from local storage', () => { const removeItemSpy = jest.spyOn(window.Storage.prototype, 'removeItem'); - clearToken(); + clearTokens(); expect(removeItemSpy).toHaveBeenCalledWith(authTokenKey); }); diff --git a/src/helpers/authentication.ts b/src/helpers/authentication.ts index 7d52f49..147fcfb 100644 --- a/src/helpers/authentication.ts +++ b/src/helpers/authentication.ts @@ -2,7 +2,7 @@ import { SignIn } from 'types/signIn'; export const authTokenKey = 'AuthToken'; -export const getToken = () => { +export const getTokens = () => { const authToken = localStorage.getItem(authTokenKey); if (!authToken) { return; @@ -13,10 +13,10 @@ export const getToken = () => { return token; }; -export const setToken = (token: SignIn) => { +export const setTokens = (token: SignIn) => { localStorage.setItem(authTokenKey, JSON.stringify(token)); }; -export const clearToken = () => { +export const clearTokens = () => { localStorage.removeItem(authTokenKey); }; diff --git a/src/helpers/deserializer.ts b/src/helpers/deserializer.ts index a61d05a..de48e93 100644 --- a/src/helpers/deserializer.ts +++ b/src/helpers/deserializer.ts @@ -4,20 +4,19 @@ import { Resource } from 'types/resource'; import { JSONObject } from './json'; -export type DeserializableResponse = AxiosResponse; +export type DeserializableResponse = AxiosResponse; -export interface Deserializable { +export interface Deserializer { type: string; id: string; attributes: JSONObject; } -export const deserialize = (data: Deserializable): T => { - const attributes = data.attributes; +export const deserialize = (data: Deserializer): T => { const resource = { id: data.id, resourceType: data.type, - ...attributes, + ...data.attributes, }; return resource as T; diff --git a/src/screens/Dashboard/index.tsx b/src/screens/Dashboard/index.tsx index 65dd228..0c925e9 100644 --- a/src/screens/Dashboard/index.tsx +++ b/src/screens/Dashboard/index.tsx @@ -1,13 +1,13 @@ import React, { useEffect, useState } from 'react'; -import { getToken } from 'helpers/authentication'; +import { getTokens } from 'helpers/authentication'; const DashBoardScreen = (): JSX.Element => { // TODO This is a test. Will remove it later. const [token, setToken] = useState(''); useEffect(() => { - const userToken = getToken(); + const userToken = getTokens(); if (userToken) { setToken(userToken.accessToken); } diff --git a/src/screens/SignIn/index.tsx b/src/screens/SignIn/index.tsx index 8134e42..3ef06f3 100644 --- a/src/screens/SignIn/index.tsx +++ b/src/screens/SignIn/index.tsx @@ -32,7 +32,7 @@ const SignInScreen = (): JSX.Element => { const dispatch = useAppDispatch(); - const handleSubmit = async (event: React.SyntheticEvent) => { + const handleSubmit = (event: React.SyntheticEvent) => { event.preventDefault(); dispatch(signIn({ email, password })); @@ -48,7 +48,6 @@ const SignInScreen = (): JSX.Element => {
nimble logo
Sign in to Nimble
-
{errors && }
diff --git a/src/store/reducers/Authentication/actions.ts b/src/store/reducers/Authentication/actions.ts index a0e1d90..995a034 100644 --- a/src/store/reducers/Authentication/actions.ts +++ b/src/store/reducers/Authentication/actions.ts @@ -1,7 +1,7 @@ import { AsyncThunkPayloadCreator } from '@reduxjs/toolkit'; import { signIn } from 'adapters/Authentication'; -import { setToken } from 'helpers/authentication'; +import { setTokens } from 'helpers/authentication'; import { DeserializableResponse, deserialize } from 'helpers/deserializer'; import { JSONObject } from 'helpers/json'; import { SignIn } from 'types/signIn'; @@ -16,7 +16,7 @@ export const signInAsync: AsyncThunkPayloadCreator { const signInType = deserialize(response.data); - setToken(signInType); + setTokens(signInType); return signInType; }) .catch((error) => { From 3c957fe84f9bf62f4050dd9d9bb3841a376ae806 Mon Sep 17 00:00:00 2001 From: Tran Manh Date: Thu, 13 Jul 2023 17:48:28 +0700 Subject: [PATCH 15/25] [#8] Fix typo and remove important modifier --- src/components/LoadingDialog/index.tsx | 2 +- src/screens/SignIn/index.test.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/LoadingDialog/index.tsx b/src/components/LoadingDialog/index.tsx index e87b630..7071ca7 100644 --- a/src/components/LoadingDialog/index.tsx +++ b/src/components/LoadingDialog/index.tsx @@ -11,7 +11,7 @@ const LoadingDialog = (htmlAttributes: LoadingDialogProps): JSX.Element => { className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-white absolute z-[1000] left-1/2 top-1/2 border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]" role="status" > - + Loading...
diff --git a/src/screens/SignIn/index.test.tsx b/src/screens/SignIn/index.test.tsx index 0504287..272f800 100644 --- a/src/screens/SignIn/index.test.tsx +++ b/src/screens/SignIn/index.test.tsx @@ -98,7 +98,7 @@ describe('SignInScreen', () => { expect(loadingDialog).toBeVisible(); }); - it('does NOT renders the loading dialog if loading is false', () => { + it('does NOT render the loading dialog if loading is false', () => { mockState.auth.loading = false; render(); @@ -111,7 +111,7 @@ describe('SignInScreen', () => { mockState.auth.success = true; }); - it('navigate to the Dashboard screen', () => { + it('navigates to the Dashboard screen', () => { render(); expect(mockUseNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }); From e69d1247c0000b2c21c6e8dbf489ddcbdd4d1db6 Mon Sep 17 00:00:00 2001 From: Tran Manh Date: Mon, 17 Jul 2023 14:44:04 +0700 Subject: [PATCH 16/25] [#8] Write unit test for components and fix some comments --- src/components/Alert/index.test.tsx | 19 ++++++++++++++++ src/components/Alert/index.tsx | 2 +- src/components/LoadingDialog/index.test.tsx | 17 +++++++++++++++ src/helpers/deserializer.test.ts | 4 ++-- src/screens/SignIn/index.test.tsx | 24 ++++++++++++--------- 5 files changed, 53 insertions(+), 13 deletions(-) create mode 100644 src/components/Alert/index.test.tsx create mode 100644 src/components/LoadingDialog/index.test.tsx diff --git a/src/components/Alert/index.test.tsx b/src/components/Alert/index.test.tsx new file mode 100644 index 0000000..fb0b03a --- /dev/null +++ b/src/components/Alert/index.test.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react'; + +import Alert from '.'; + +describe('Alert', () => { + const errors = ['Error 1']; + const dataTestId = 'test-id__alert'; + + it('renders an alert and its component', () => { + render(); + + const alert = screen.getByTestId(dataTestId); + + expect(alert).toBeVisible(); + expect(alert).toHaveTextContent('Error 1'); + }); +}); diff --git a/src/components/Alert/index.tsx b/src/components/Alert/index.tsx index 4e4aee5..7dc305b 100644 --- a/src/components/Alert/index.tsx +++ b/src/components/Alert/index.tsx @@ -14,7 +14,7 @@ const Alert = ({ errors, ...rest }: AlertProps): JSX.Element => (

Error

    {errors.map((error, index) => ( -
  • {error}
  • +
  • {error}
  • ))}
diff --git a/src/components/LoadingDialog/index.test.tsx b/src/components/LoadingDialog/index.test.tsx new file mode 100644 index 0000000..7875141 --- /dev/null +++ b/src/components/LoadingDialog/index.test.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react'; + +import LoadingDialog from '.'; + +describe('LoadingDialog', () => { + const dataTestId = 'test-id__loading-dialog'; + + it('renders a loading dialog component', () => { + render(); + + const loadingDialog = screen.getByTestId(dataTestId); + + expect(loadingDialog).toBeVisible(); + }); +}); diff --git a/src/helpers/deserializer.test.ts b/src/helpers/deserializer.test.ts index f024dd9..8c9d1fa 100644 --- a/src/helpers/deserializer.test.ts +++ b/src/helpers/deserializer.test.ts @@ -22,8 +22,8 @@ describe('Deserializer helper', () => { const deserializedData = deserialize(jsonData); expect(deserializedData.resourceType).toBe('TestType'); - expect(deserializedData.name).toBe(jsonData.attributes.name); - expect(deserializedData.age).toBe(jsonData.attributes.age); + expect(deserializedData.name).toBe('Name'); + expect(deserializedData.age).toBe(25); }); }); }); diff --git a/src/screens/SignIn/index.test.tsx b/src/screens/SignIn/index.test.tsx index 272f800..f0489ca 100644 --- a/src/screens/SignIn/index.test.tsx +++ b/src/screens/SignIn/index.test.tsx @@ -88,21 +88,25 @@ describe('SignInScreen', () => { }); }); - describe('given the loading has data', () => { - it('renders the loading dialog if loading is true', () => { - mockState.auth.loading = true; - render(); + describe('given the loading field has data', () => { + describe('given loading is true', () => { + it('renders the loading dialog', () => { + mockState.auth.loading = true; + render(); - const loadingDialog = screen.getByTestId(signInScreenTestIds.loadingDialog); + const loadingDialog = screen.getByTestId(signInScreenTestIds.loadingDialog); - expect(loadingDialog).toBeVisible(); + expect(loadingDialog).toBeVisible(); + }); }); - it('does NOT render the loading dialog if loading is false', () => { - mockState.auth.loading = false; - render(); + describe('given the loading is false', () => { + it('does NOT render the loading dialog', () => { + mockState.auth.loading = false; + render(); - expect(screen.queryByTestId(signInScreenTestIds.loadingDialog)).not.toBeInTheDocument(); + expect(screen.queryByTestId(signInScreenTestIds.loadingDialog)).not.toBeInTheDocument(); + }); }); }); From 90287e88fb7a8b870a60719bb83fc9a8264dfcea Mon Sep 17 00:00:00 2001 From: Tran Manh Date: Wed, 19 Jul 2023 09:27:33 +0700 Subject: [PATCH 17/25] [#8] Rename signIn to Tokens and fix some comments --- src/assets/images/icons/alert.svg | 2 +- src/components/Alert/index.test.tsx | 2 +- src/components/Alert/index.tsx | 2 +- src/components/LoadingDialog/index.test.tsx | 2 +- src/helpers/authentication.test.ts | 8 ++++---- src/helpers/authentication.ts | 6 +++--- src/screens/SignIn/index.tsx | 2 +- src/store/reducers/Authentication/actions.ts | 10 +++++----- src/types/{signIn.ts => tokens.ts} | 2 +- 9 files changed, 18 insertions(+), 18 deletions(-) rename src/types/{signIn.ts => tokens.ts} (72%) diff --git a/src/assets/images/icons/alert.svg b/src/assets/images/icons/alert.svg index ad0905d..6c2622a 100644 --- a/src/assets/images/icons/alert.svg +++ b/src/assets/images/icons/alert.svg @@ -1,3 +1,3 @@ - + diff --git a/src/components/Alert/index.test.tsx b/src/components/Alert/index.test.tsx index fb0b03a..0804527 100644 --- a/src/components/Alert/index.test.tsx +++ b/src/components/Alert/index.test.tsx @@ -6,7 +6,7 @@ import Alert from '.'; describe('Alert', () => { const errors = ['Error 1']; - const dataTestId = 'test-id__alert'; + const dataTestId = 'alert'; it('renders an alert and its component', () => { render(); diff --git a/src/components/Alert/index.tsx b/src/components/Alert/index.tsx index 7dc305b..b9437a0 100644 --- a/src/components/Alert/index.tsx +++ b/src/components/Alert/index.tsx @@ -9,7 +9,7 @@ interface AlertProps { const Alert = ({ errors, ...rest }: AlertProps): JSX.Element => (
- +

Error

    diff --git a/src/components/LoadingDialog/index.test.tsx b/src/components/LoadingDialog/index.test.tsx index 7875141..20da80c 100644 --- a/src/components/LoadingDialog/index.test.tsx +++ b/src/components/LoadingDialog/index.test.tsx @@ -5,7 +5,7 @@ import { render, screen } from '@testing-library/react'; import LoadingDialog from '.'; describe('LoadingDialog', () => { - const dataTestId = 'test-id__loading-dialog'; + const dataTestId = 'loading-dialog'; it('renders a loading dialog component', () => { render(); diff --git a/src/helpers/authentication.test.ts b/src/helpers/authentication.test.ts index 8b469f2..e6e6cec 100644 --- a/src/helpers/authentication.test.ts +++ b/src/helpers/authentication.test.ts @@ -1,4 +1,4 @@ -import { SignIn } from 'types/signIn'; +import { Tokens } from 'types/tokens'; import { authTokenKey, clearTokens, getTokens, setTokens } from './authentication'; @@ -28,7 +28,7 @@ describe('Authentication helper', () => { describe('getToken', () => { beforeEach(() => { - const mockTokens: SignIn = { + const mockTokens: Tokens = { id: mockId, resourceType: mockResourceType, accessToken: mockAccessToken, @@ -48,7 +48,7 @@ describe('Authentication helper', () => { describe('setToken', () => { it('sets the tokens to local storage', () => { - const mockTokens: SignIn = { + const mockTokens: Tokens = { id: mockId, resourceType: mockResourceType, accessToken: mockAccessToken, @@ -66,7 +66,7 @@ describe('Authentication helper', () => { describe('clearToken', () => { beforeEach(() => { - const mockTokens: SignIn = { + const mockTokens: Tokens = { id: mockId, resourceType: mockResourceType, accessToken: mockAccessToken, diff --git a/src/helpers/authentication.ts b/src/helpers/authentication.ts index 147fcfb..910ae73 100644 --- a/src/helpers/authentication.ts +++ b/src/helpers/authentication.ts @@ -1,4 +1,4 @@ -import { SignIn } from 'types/signIn'; +import { Tokens } from 'types/tokens'; export const authTokenKey = 'AuthToken'; @@ -8,12 +8,12 @@ export const getTokens = () => { return; } - const token = JSON.parse(authToken) as SignIn; + const token = JSON.parse(authToken) as Tokens; return token; }; -export const setTokens = (token: SignIn) => { +export const setTokens = (token: Tokens) => { localStorage.setItem(authTokenKey, JSON.stringify(token)); }; diff --git a/src/screens/SignIn/index.tsx b/src/screens/SignIn/index.tsx index 3ef06f3..f4088a9 100644 --- a/src/screens/SignIn/index.tsx +++ b/src/screens/SignIn/index.tsx @@ -48,7 +48,7 @@ const SignInScreen = (): JSX.Element => {
    nimble logo
    Sign in to Nimble
    -
    {errors && }
    +
    {errors && }
    diff --git a/src/store/reducers/Authentication/actions.ts b/src/store/reducers/Authentication/actions.ts index 995a034..a175338 100644 --- a/src/store/reducers/Authentication/actions.ts +++ b/src/store/reducers/Authentication/actions.ts @@ -4,20 +4,20 @@ import { signIn } from 'adapters/Authentication'; import { setTokens } from 'helpers/authentication'; import { DeserializableResponse, deserialize } from 'helpers/deserializer'; import { JSONObject } from 'helpers/json'; -import { SignIn } from 'types/signIn'; +import { Tokens } from 'types/tokens'; export interface SignInInput { email: string; password: string; } -export const signInAsync: AsyncThunkPayloadCreator = async (input, { rejectWithValue }) => { +export const signInAsync: AsyncThunkPayloadCreator = async (input, { rejectWithValue }) => { return signIn(input.email, input.password) .then((response: DeserializableResponse) => { - const signInType = deserialize(response.data); + const tokens = deserialize(response.data); - setTokens(signInType); - return signInType; + setTokens(tokens); + return tokens; }) .catch((error) => { if (!error.response) { diff --git a/src/types/signIn.ts b/src/types/tokens.ts similarity index 72% rename from src/types/signIn.ts rename to src/types/tokens.ts index f7ae667..0c0cc8e 100644 --- a/src/types/signIn.ts +++ b/src/types/tokens.ts @@ -1,6 +1,6 @@ import { Resource } from 'types/resource'; -export interface SignIn extends Resource { +export interface Tokens extends Resource { accessToken: string; tokenType: string; refreshToken: string; From b559d8492c27757b2f9a4e1ee654dfb6ca099a08 Mon Sep 17 00:00:00 2001 From: Tran Manh Date: Wed, 19 Jul 2023 11:04:03 +0700 Subject: [PATCH 18/25] [#8] Refactor Tokens to Token --- src/helpers/authentication.test.ts | 32 ++++++++++---------- src/helpers/authentication.ts | 10 +++--- src/screens/Dashboard/index.tsx | 4 +-- src/store/reducers/Authentication/actions.ts | 12 ++++---- src/types/{tokens.ts => token.ts} | 2 +- 5 files changed, 30 insertions(+), 30 deletions(-) rename src/types/{tokens.ts => token.ts} (72%) diff --git a/src/helpers/authentication.test.ts b/src/helpers/authentication.test.ts index e6e6cec..19819fe 100644 --- a/src/helpers/authentication.test.ts +++ b/src/helpers/authentication.test.ts @@ -1,6 +1,6 @@ -import { Tokens } from 'types/tokens'; +import { Token } from 'types/token'; -import { authTokenKey, clearTokens, getTokens, setTokens } from './authentication'; +import { authTokenKey, clearToken, getToken, setToken } from './authentication'; describe('Authentication helper', () => { const mockStorage: { [key: string]: string | null } = {}; @@ -28,27 +28,27 @@ describe('Authentication helper', () => { describe('getToken', () => { beforeEach(() => { - const mockTokens: Tokens = { + const mockToken: Token = { id: mockId, resourceType: mockResourceType, accessToken: mockAccessToken, refreshToken: mockRefreshToken, tokenType: mockTokenType, }; - mockStorage[authTokenKey] = JSON.stringify(mockTokens); + mockStorage[authTokenKey] = JSON.stringify(mockToken); }); - it('returns the tokens from local storage', () => { - const tokens = getTokens(); + it('returns the token from local storage', () => { + const token = getToken(); - expect(tokens?.accessToken).toBe(mockAccessToken); - expect(tokens?.refreshToken).toBe(mockRefreshToken); + expect(token?.accessToken).toBe(mockAccessToken); + expect(token?.refreshToken).toBe(mockRefreshToken); }); }); describe('setToken', () => { - it('sets the tokens to local storage', () => { - const mockTokens: Tokens = { + it('sets the token to local storage', () => { + const mockToken: Token = { id: mockId, resourceType: mockResourceType, accessToken: mockAccessToken, @@ -58,28 +58,28 @@ describe('Authentication helper', () => { const setItemSpy = jest.spyOn(window.Storage.prototype, 'setItem'); - setTokens(mockTokens); + setToken(mockToken); - expect(setItemSpy).toHaveBeenCalledWith(authTokenKey, JSON.stringify(mockTokens)); + expect(setItemSpy).toHaveBeenCalledWith(authTokenKey, JSON.stringify(mockToken)); }); }); describe('clearToken', () => { beforeEach(() => { - const mockTokens: Tokens = { + const mockToken: Token = { id: mockId, resourceType: mockResourceType, accessToken: mockAccessToken, refreshToken: mockRefreshToken, tokenType: mockTokenType, }; - mockStorage[authTokenKey] = JSON.stringify(mockTokens); + mockStorage[authTokenKey] = JSON.stringify(mockToken); }); - it('clears the tokens from local storage', () => { + it('clears the token from local storage', () => { const removeItemSpy = jest.spyOn(window.Storage.prototype, 'removeItem'); - clearTokens(); + clearToken(); expect(removeItemSpy).toHaveBeenCalledWith(authTokenKey); }); diff --git a/src/helpers/authentication.ts b/src/helpers/authentication.ts index 910ae73..8307727 100644 --- a/src/helpers/authentication.ts +++ b/src/helpers/authentication.ts @@ -1,22 +1,22 @@ -import { Tokens } from 'types/tokens'; +import { Token } from 'types/token'; export const authTokenKey = 'AuthToken'; -export const getTokens = () => { +export const getToken = () => { const authToken = localStorage.getItem(authTokenKey); if (!authToken) { return; } - const token = JSON.parse(authToken) as Tokens; + const token = JSON.parse(authToken) as Token; return token; }; -export const setTokens = (token: Tokens) => { +export const setToken = (token: Token) => { localStorage.setItem(authTokenKey, JSON.stringify(token)); }; -export const clearTokens = () => { +export const clearToken = () => { localStorage.removeItem(authTokenKey); }; diff --git a/src/screens/Dashboard/index.tsx b/src/screens/Dashboard/index.tsx index 0c925e9..65dd228 100644 --- a/src/screens/Dashboard/index.tsx +++ b/src/screens/Dashboard/index.tsx @@ -1,13 +1,13 @@ import React, { useEffect, useState } from 'react'; -import { getTokens } from 'helpers/authentication'; +import { getToken } from 'helpers/authentication'; const DashBoardScreen = (): JSX.Element => { // TODO This is a test. Will remove it later. const [token, setToken] = useState(''); useEffect(() => { - const userToken = getTokens(); + const userToken = getToken(); if (userToken) { setToken(userToken.accessToken); } diff --git a/src/store/reducers/Authentication/actions.ts b/src/store/reducers/Authentication/actions.ts index a175338..edd1507 100644 --- a/src/store/reducers/Authentication/actions.ts +++ b/src/store/reducers/Authentication/actions.ts @@ -1,23 +1,23 @@ import { AsyncThunkPayloadCreator } from '@reduxjs/toolkit'; import { signIn } from 'adapters/Authentication'; -import { setTokens } from 'helpers/authentication'; +import { setToken } from 'helpers/authentication'; import { DeserializableResponse, deserialize } from 'helpers/deserializer'; import { JSONObject } from 'helpers/json'; -import { Tokens } from 'types/tokens'; +import { Token } from 'types/token'; export interface SignInInput { email: string; password: string; } -export const signInAsync: AsyncThunkPayloadCreator = async (input, { rejectWithValue }) => { +export const signInAsync: AsyncThunkPayloadCreator = async (input, { rejectWithValue }) => { return signIn(input.email, input.password) .then((response: DeserializableResponse) => { - const tokens = deserialize(response.data); + const token = deserialize(response.data); - setTokens(tokens); - return tokens; + setToken(token); + return token; }) .catch((error) => { if (!error.response) { diff --git a/src/types/tokens.ts b/src/types/token.ts similarity index 72% rename from src/types/tokens.ts rename to src/types/token.ts index 0c0cc8e..6fbc6b3 100644 --- a/src/types/tokens.ts +++ b/src/types/token.ts @@ -1,6 +1,6 @@ import { Resource } from 'types/resource'; -export interface Tokens extends Resource { +export interface Token extends Resource { accessToken: string; tokenType: string; refreshToken: string; From 66c6e1cdf32fdb46e036443564924a14d0f70520 Mon Sep 17 00:00:00 2001 From: Tran Manh Date: Tue, 20 Jun 2023 16:54:39 +0700 Subject: [PATCH 19/25] [#9] Create the empty dashboard --- src/components/Alert/index.tsx | 2 +- src/components/Dashboard/Empty/index.tsx | 16 +++++++++++ src/components/Dashboard/Header/index.tsx | 35 +++++++++++++++++++++++ src/screens/Dashboard/index.tsx | 22 ++++++-------- tailwind.config.js | 4 ++- 5 files changed, 64 insertions(+), 15 deletions(-) create mode 100644 src/components/Dashboard/Empty/index.tsx create mode 100644 src/components/Dashboard/Header/index.tsx diff --git a/src/components/Alert/index.tsx b/src/components/Alert/index.tsx index b9437a0..8522bc1 100644 --- a/src/components/Alert/index.tsx +++ b/src/components/Alert/index.tsx @@ -12,7 +12,7 @@ const Alert = ({ errors, ...rest }: AlertProps): JSX.Element => (

    Error

    -
      +
        {errors.map((error, index) => (
      • {error}
      • ))} diff --git a/src/components/Dashboard/Empty/index.tsx b/src/components/Dashboard/Empty/index.tsx new file mode 100644 index 0000000..51cd2e0 --- /dev/null +++ b/src/components/Dashboard/Empty/index.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +const DashboardEmpty = (): JSX.Element => { + return ( +
        +

        😎

        +

        + You‘ve completed all the surveys. +
        + Take a moment. +

        +
        + ); +}; + +export default DashboardEmpty; diff --git a/src/components/Dashboard/Header/index.tsx b/src/components/Dashboard/Header/index.tsx new file mode 100644 index 0000000..37f3bf2 --- /dev/null +++ b/src/components/Dashboard/Header/index.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +import { ReactComponent as NimbleLogoWhite } from 'assets/images/icons/nimble-logo-white.svg'; + +interface DashboardHeaderProps { + dateTime: string; + daysAgo: string; + profileUrl: string; + children: React.ReactNode; +} +const DashboardHeader = ({ dateTime, daysAgo, profileUrl, children }: DashboardHeaderProps): JSX.Element => { + return ( +
        +
        + + user avatar +
        +
        +
        +
        +

        {dateTime}

        +

        {daysAgo}

        +
        +
        +
        +
        +
        +
        {children}
        +
        +
        +
        + ); +}; + +export default DashboardHeader; diff --git a/src/screens/Dashboard/index.tsx b/src/screens/Dashboard/index.tsx index 65dd228..1b678de 100644 --- a/src/screens/Dashboard/index.tsx +++ b/src/screens/Dashboard/index.tsx @@ -1,20 +1,16 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; -import { getToken } from 'helpers/authentication'; +import DashboardEmpty from 'components/Dashboard/Empty'; +import DashboardHeader from 'components/Dashboard/Header'; const DashBoardScreen = (): JSX.Element => { - // TODO This is a test. Will remove it later. - const [token, setToken] = useState(''); - - useEffect(() => { - const userToken = getToken(); - if (userToken) { - setToken(userToken.accessToken); - } - }, []); return ( -
        -

        Welcome to your Dashboard {token}

        +
        + +
        + +
        +
        ); }; diff --git a/tailwind.config.js b/tailwind.config.js index 169480f..5170301 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -11,9 +11,11 @@ module.exports = { 'black-raisin': '#252525', }, fontSize: { - xSmall: ['13px', '18px'], + 'x-small': ['13px', '18px'], small: ['15px', '20px'], regular: ['17px', '22px'], + large: ['28px', '34px'], + 'x-large': ['34px', '41px'], }, backgroundImage: { 'sign-in': "url('assets/images/illustrations/background-sign-in.png')", From cd8eccb17bc1c54fcc15b8a4d3c788001d9215ce Mon Sep 17 00:00:00 2001 From: Tran Manh Date: Wed, 21 Jun 2023 14:39:42 +0700 Subject: [PATCH 20/25] [#9] Update layout for content --- src/screens/Dashboard/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/screens/Dashboard/index.tsx b/src/screens/Dashboard/index.tsx index 1b678de..49c72d1 100644 --- a/src/screens/Dashboard/index.tsx +++ b/src/screens/Dashboard/index.tsx @@ -7,7 +7,7 @@ const DashBoardScreen = (): JSX.Element => { return (
        -
        +
        From dc1fefd4e969cb173fbc925c57678029301d1d2e Mon Sep 17 00:00:00 2001 From: Tran Manh Date: Wed, 21 Jun 2023 14:59:53 +0700 Subject: [PATCH 21/25] [#9] Modify padding top --- src/components/Dashboard/Header/index.tsx | 2 +- src/screens/Dashboard/index.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Dashboard/Header/index.tsx b/src/components/Dashboard/Header/index.tsx index 37f3bf2..09fad3f 100644 --- a/src/components/Dashboard/Header/index.tsx +++ b/src/components/Dashboard/Header/index.tsx @@ -11,7 +11,7 @@ interface DashboardHeaderProps { const DashboardHeader = ({ dateTime, daysAgo, profileUrl, children }: DashboardHeaderProps): JSX.Element => { return (
        -
        +
        user avatar
        diff --git a/src/screens/Dashboard/index.tsx b/src/screens/Dashboard/index.tsx index 49c72d1..b02a0a2 100644 --- a/src/screens/Dashboard/index.tsx +++ b/src/screens/Dashboard/index.tsx @@ -5,9 +5,9 @@ import DashboardHeader from 'components/Dashboard/Header'; const DashBoardScreen = (): JSX.Element => { return ( -
        +
        -
        +
        From 67d9349ddf4e77c8530adf194139ffd2c6292d5b Mon Sep 17 00:00:00 2001 From: Tran Manh Date: Tue, 27 Jun 2023 17:08:38 +0700 Subject: [PATCH 22/25] [#9] Update unittest for components --- src/components/Dashboard/Empty/index.test.tsx | 17 +++++++++++ src/components/Dashboard/Empty/index.tsx | 8 +++-- .../Dashboard/Header/index.test.tsx | 30 +++++++++++++++++++ src/components/Dashboard/Header/index.tsx | 5 ++-- 4 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 src/components/Dashboard/Empty/index.test.tsx create mode 100644 src/components/Dashboard/Header/index.test.tsx diff --git a/src/components/Dashboard/Empty/index.test.tsx b/src/components/Dashboard/Empty/index.test.tsx new file mode 100644 index 0000000..226a65c --- /dev/null +++ b/src/components/Dashboard/Empty/index.test.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react'; + +import DashboardEmpty from '.'; + +describe('DashboardEmpty', () => { + const dataTestId = 'dashboard-empty'; + it('renders DashboardEmpty and its components', () => { + render(); + + const dashboardEmpty = screen.getByTestId(dataTestId); + + expect(dashboardEmpty).toBeVisible(); + expect(dashboardEmpty).toHaveTextContent('😎You‘ve completed all the surveys.Take a moment.'); + }); +}); diff --git a/src/components/Dashboard/Empty/index.tsx b/src/components/Dashboard/Empty/index.tsx index 51cd2e0..13a06f1 100644 --- a/src/components/Dashboard/Empty/index.tsx +++ b/src/components/Dashboard/Empty/index.tsx @@ -1,8 +1,12 @@ import React from 'react'; -const DashboardEmpty = (): JSX.Element => { +interface DashboardEmptyProps { + 'data-test-id'?: string; +} + +const DashboardEmpty = ({ ...attributes }: DashboardEmptyProps): JSX.Element => { return ( -
        +

        😎

        You‘ve completed all the surveys. diff --git a/src/components/Dashboard/Header/index.test.tsx b/src/components/Dashboard/Header/index.test.tsx new file mode 100644 index 0000000..ee05ac2 --- /dev/null +++ b/src/components/Dashboard/Header/index.test.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react'; + +import DashboardHeader from '.'; + +describe('DashboardHeader', () => { + const dataTestId = 'dashboard-header'; + it('renders DashboardHeader and its components', () => { + const dateTime = 'Monday, JUNE 15'; + const daysAgo = 'Today'; + const profileUrl = 'test url'; + render( + + Dashboard Header + + ); + + const dashboardHeader = screen.getByTestId(dataTestId); + const avatar = screen.getByAltText('user avatar'); + + expect(dashboardHeader).toBeVisible(); + expect(dashboardHeader).toHaveTextContent(dateTime); + expect(dashboardHeader).toHaveTextContent(dateTime); + expect(dashboardHeader).toHaveTextContent(dateTime); + + expect(avatar).toBeVisible(); + expect(avatar).toHaveAttribute('src', profileUrl); + }); +}); diff --git a/src/components/Dashboard/Header/index.tsx b/src/components/Dashboard/Header/index.tsx index 09fad3f..c59a538 100644 --- a/src/components/Dashboard/Header/index.tsx +++ b/src/components/Dashboard/Header/index.tsx @@ -7,10 +7,11 @@ interface DashboardHeaderProps { daysAgo: string; profileUrl: string; children: React.ReactNode; + 'data-test-id'?: string; } -const DashboardHeader = ({ dateTime, daysAgo, profileUrl, children }: DashboardHeaderProps): JSX.Element => { +const DashboardHeader = ({ dateTime, daysAgo, profileUrl, children, ...attributes }: DashboardHeaderProps): JSX.Element => { return ( -

        +
        user avatar From 81eb9bd49f35289e24796a7ca36bf527396192c7 Mon Sep 17 00:00:00 2001 From: Tran Manh Date: Wed, 19 Jul 2023 09:39:26 +0700 Subject: [PATCH 23/25] [#9] Switch div to header tag for DashboardHeader --- src/components/Dashboard/Header/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Dashboard/Header/index.tsx b/src/components/Dashboard/Header/index.tsx index c59a538..285dba3 100644 --- a/src/components/Dashboard/Header/index.tsx +++ b/src/components/Dashboard/Header/index.tsx @@ -11,7 +11,7 @@ interface DashboardHeaderProps { } const DashboardHeader = ({ dateTime, daysAgo, profileUrl, children, ...attributes }: DashboardHeaderProps): JSX.Element => { return ( -
        +
        user avatar @@ -29,7 +29,7 @@ const DashboardHeader = ({ dateTime, daysAgo, profileUrl, children, ...attribute
        {children}
        -
        + ); }; From ac950693a8137437acd64643d93730f16023b463 Mon Sep 17 00:00:00 2001 From: Tran Manh Date: Wed, 19 Jul 2023 10:54:46 +0700 Subject: [PATCH 24/25] [#9] Move once time used var to it() --- src/components/Dashboard/Empty/index.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Dashboard/Empty/index.test.tsx b/src/components/Dashboard/Empty/index.test.tsx index 226a65c..bd44ede 100644 --- a/src/components/Dashboard/Empty/index.test.tsx +++ b/src/components/Dashboard/Empty/index.test.tsx @@ -5,8 +5,8 @@ import { render, screen } from '@testing-library/react'; import DashboardEmpty from '.'; describe('DashboardEmpty', () => { - const dataTestId = 'dashboard-empty'; it('renders DashboardEmpty and its components', () => { + const dataTestId = 'dashboard-empty'; render(); const dashboardEmpty = screen.getByTestId(dataTestId); From 3ca77cbbf4f953f4cc3b23976c917a869c12cc1c Mon Sep 17 00:00:00 2001 From: Tran Manh Date: Wed, 19 Jul 2023 11:41:27 +0700 Subject: [PATCH 25/25] [#9] Remove the default flex-row --- src/components/Dashboard/Empty/index.tsx | 2 +- src/components/Dashboard/Header/index.tsx | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/Dashboard/Empty/index.tsx b/src/components/Dashboard/Empty/index.tsx index 13a06f1..babb541 100644 --- a/src/components/Dashboard/Empty/index.tsx +++ b/src/components/Dashboard/Empty/index.tsx @@ -4,7 +4,7 @@ interface DashboardEmptyProps { 'data-test-id'?: string; } -const DashboardEmpty = ({ ...attributes }: DashboardEmptyProps): JSX.Element => { +const DashboardEmpty = (attributes: DashboardEmptyProps): JSX.Element => { return (

        😎

        diff --git a/src/components/Dashboard/Header/index.tsx b/src/components/Dashboard/Header/index.tsx index 285dba3..74c9b12 100644 --- a/src/components/Dashboard/Header/index.tsx +++ b/src/components/Dashboard/Header/index.tsx @@ -9,14 +9,14 @@ interface DashboardHeaderProps { children: React.ReactNode; 'data-test-id'?: string; } -const DashboardHeader = ({ dateTime, daysAgo, profileUrl, children, ...attributes }: DashboardHeaderProps): JSX.Element => { +const DashboardHeader = ({ dateTime, daysAgo, profileUrl, children, ...rest }: DashboardHeaderProps): JSX.Element => { return ( -
        -
        +
        +
        user avatar
        -
        +

        {dateTime}

        @@ -24,7 +24,7 @@ const DashboardHeader = ({ dateTime, daysAgo, profileUrl, children, ...attribute
        -
        +
        {children}