From 69cfbc40355153ab5e7df47ca52c7628ebe56899 Mon Sep 17 00:00:00 2001 From: Tran Manh Date: Fri, 16 Jun 2023 17:02:22 +0700 Subject: [PATCH 01/12] [#8] Implement sign in feature and save the token to local storage --- package-lock.json | 140 +++++++++++++++++++++++-- package.json | 6 +- src/assets/images/icons/alert.svg | 3 + src/components/Alert/index.tsx | 24 +++++ src/components/LoadingDialog/index.tsx | 18 ++++ src/components/TextInput/index.tsx | 2 + src/helpers/authentication.test.ts | 87 +++++++++++++++ src/helpers/authentication.ts | 22 ++++ src/helpers/deserializer.test.ts | 29 +++++ src/helpers/deserializer.ts | 24 +++++ src/helpers/error.ts | 11 ++ src/hooks/index.tsx | 8 ++ src/index.tsx | 15 ++- src/lib/request/v1/requestManager.ts | 12 ++- src/reducers/.keep | 0 src/routes/index.tsx | 13 ++- src/screens/Dashboard/index.tsx | 22 ++++ src/screens/SignIn/index.tsx | 29 ++++- src/store/index.tsx | 14 +++ src/store/reducers/authSlice.tsx | 66 ++++++++++++ src/types/resource.ts | 4 + src/types/signIn.ts | 7 ++ tailwind.config.js | 2 + 23 files changed, 537 insertions(+), 21 deletions(-) create mode 100644 src/assets/images/icons/alert.svg create mode 100644 src/components/Alert/index.tsx create mode 100644 src/components/LoadingDialog/index.tsx create mode 100644 src/helpers/authentication.test.ts create mode 100644 src/helpers/authentication.ts create mode 100644 src/helpers/deserializer.test.ts create mode 100644 src/helpers/deserializer.ts create mode 100644 src/helpers/error.ts create mode 100644 src/hooks/index.tsx delete mode 100644 src/reducers/.keep create mode 100644 src/screens/Dashboard/index.tsx create mode 100644 src/store/index.tsx create mode 100644 src/store/reducers/authSlice.tsx create mode 100644 src/types/resource.ts create mode 100644 src/types/signIn.ts diff --git a/package-lock.json b/package-lock.json index 53a3816..629bab2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,14 @@ "name": "manh-react-survey", "version": "0.2.0", "dependencies": { + "@reduxjs/toolkit": "1.9.5", "@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-redux": "8.1.0", "react-router-dom": "6.3.0", "react-scripts": "5.0.1", "sass": "1.49.11" @@ -4811,6 +4813,29 @@ "node": ">= 8" } }, + "node_modules/@reduxjs/toolkit": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.5.tgz", + "integrity": "sha512-Rt97jHmfTeaxL4swLRNPD/zV4OxTes4la07Xc4hetpUW/vc75t5m1ANyxG6ymnEQ2FsLQsoMlYB2vV1sO3m8tQ==", + "dependencies": { + "immer": "^9.0.21", + "redux": "^4.2.1", + "redux-thunk": "^2.4.2", + "reselect": "^4.1.8" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18", + "react-redux": "^7.2.1 || ^8.0.2" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", @@ -5660,6 +5685,15 @@ "@types/node": "*" } }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", + "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "node_modules/@types/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -5801,8 +5835,7 @@ "node_modules/@types/prop-types": { "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "dev": true + "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" }, "node_modules/@types/q": { "version": "1.5.5", @@ -5823,7 +5856,6 @@ "version": "18.2.9", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.9.tgz", "integrity": "sha512-pL3JAesUkF7PEQGxh5XOwdXGV907te6m1/Qe1ERJLgomojS6Ne790QiA7GUl434JEkFA2aAaB6qJ5z4e1zJn/w==", - "dev": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -5834,7 +5866,7 @@ "version": "18.2.4", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.4.tgz", "integrity": "sha512-G2mHoTMTL4yoydITgOGwWdWMVd8sNgyEP85xVmMKAPUBwQWm9wBPQUmvbeF4V3WBY1P7mmL4BkjQ0SqUpf1snw==", - "dev": true, + "devOptional": true, "dependencies": { "@types/react": "*" } @@ -5864,8 +5896,7 @@ "node_modules/@types/scheduler": { "version": "0.16.3", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", - "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==", - "dev": true + "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==" }, "node_modules/@types/semver": { "version": "7.5.0", @@ -5937,6 +5968,11 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz", "integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", + "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==" + }, "node_modules/@types/ws": { "version": "8.5.5", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.5.tgz", @@ -9187,8 +9223,7 @@ "node_modules/csstype": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", - "dev": true + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" }, "node_modules/cypress": { "version": "12.14.0", @@ -12603,6 +12638,19 @@ "minimalistic-crypto-utils": "^1.0.1" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, "node_modules/homedir-polyfill": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", @@ -21429,6 +21477,53 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, + "node_modules/react-redux": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.0.tgz", + "integrity": "sha512-CtHZzAOxi7GQvTph4dVLWwZHAWUjV2kMEQtk50OrN8z3gKxpWg3Tz7JfDw32N3Rpd7fh02z73cF6yZkK467gbQ==", + "dependencies": { + "@babel/runtime": "^7.12.1", + "@types/hoist-non-react-statics": "^3.3.1", + "@types/use-sync-external-store": "^0.0.3", + "hoist-non-react-statics": "^3.3.2", + "react-is": "^18.0.0", + "use-sync-external-store": "^1.0.0" + }, + "peerDependencies": { + "@reduxjs/toolkit": "^1 || ^2.0.0-beta.0", + "@types/react": "^16.8 || ^17.0 || ^18.0", + "@types/react-dom": "^16.8 || ^17.0 || ^18.0", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0", + "react-native": ">=0.59", + "redux": "^4 || ^5.0.0-beta.0" + }, + "peerDependenciesMeta": { + "@reduxjs/toolkit": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-redux/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" + }, "node_modules/react-refresh": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", @@ -21787,6 +21882,22 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, + "node_modules/redux-thunk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz", + "integrity": "sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==", + "peerDependencies": { + "redux": "^4" + } + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -21951,6 +22062,11 @@ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, + "node_modules/reselect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", + "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==" + }, "node_modules/resolve": { "version": "1.22.2", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", @@ -24674,6 +24790,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util": { "version": "0.10.4", "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", diff --git a/package.json b/package.json index cddd2b5..4b1d04c 100644 --- a/package.json +++ b/package.json @@ -3,15 +3,17 @@ "version": "0.2.0", "private": true, "dependencies": { + "@reduxjs/toolkit": "1.9.5", + "@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-redux": "8.1.0", "react-router-dom": "6.3.0", "react-scripts": "5.0.1", - "sass": "1.49.11", - "@types/lodash": "4.14.195" + "sass": "1.49.11" }, "scripts": { "start": "react-scripts -r @cypress/instrument-cra start", diff --git a/src/assets/images/icons/alert.svg b/src/assets/images/icons/alert.svg new file mode 100644 index 0000000..ad0905d --- /dev/null +++ b/src/assets/images/icons/alert.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/Alert/index.tsx b/src/components/Alert/index.tsx new file mode 100644 index 0000000..5d1132b --- /dev/null +++ b/src/components/Alert/index.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import { ReactComponent as AlertIcon } from 'assets/images/icons/alert.svg'; + +interface AlertProps { + errors: string[]; +} +const Alert = ({ errors }: AlertProps): JSX.Element => ( +
+
+ +
+

Error

+
    + {errors.map((error, index) => ( +
  • {error}
  • + ))} +
+
+
+
+); + +export default Alert; diff --git a/src/components/LoadingDialog/index.tsx b/src/components/LoadingDialog/index.tsx new file mode 100644 index 0000000..25ca196 --- /dev/null +++ b/src/components/LoadingDialog/index.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +const LoadingDialog = (): JSX.Element => { + return ( +
+
+ + Loading... + +
+
+ ); +}; + +export default LoadingDialog; diff --git a/src/components/TextInput/index.tsx b/src/components/TextInput/index.tsx index 8095d17..4f1c63a 100644 --- a/src/components/TextInput/index.tsx +++ b/src/components/TextInput/index.tsx @@ -11,6 +11,7 @@ type TextInputProps = { required?: boolean; placeholder?: string; 'data-test-id'?: string; + onChange?: React.ChangeEventHandler; }; className?: string; }; @@ -34,6 +35,7 @@ const TextInput = ({ label, labelDataTestId, inputAttributes, className }: TextI {...inputAttributes} className={classNames(DEFAULT_CLASS_NAMES, className)} placeholder={inputAttributes.placeholder} + onChange={inputAttributes.onChange} /> ); 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 02/12] [#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 03/12] [#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 04/12] [#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 05/12] [#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 06/12] [#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 07/12] [#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 08/12] [#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 09/12] [#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 10/12] [#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 11/12] [#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 12/12] [#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;