| @@ -0,0 +1 @@ | |||
| REACT_APP_BASE_API_URL=https://portalgatewayapi.bullioninternational.info/ | |||
| @@ -0,0 +1,28 @@ | |||
| { | |||
| "env": { | |||
| "browser": true, | |||
| "es2021": true | |||
| }, | |||
| "extends": [ | |||
| "plugin:react/recommended", | |||
| "standard-with-typescript" | |||
| ], | |||
| "overrides": [ | |||
| ], | |||
| "parserOptions": { | |||
| "ecmaVersion": "latest", | |||
| "sourceType": "module", | |||
| "project": [ | |||
| "./tsconfig.json" | |||
| ] | |||
| }, | |||
| "plugins": [ | |||
| "react" | |||
| ], | |||
| "rules": { | |||
| "semi": "off", | |||
| "@typescript-eslint/semi": ["error"], | |||
| "no-use-before-define": "off", | |||
| "@typescript-eslint/no-use-before-define": ["error"] | |||
| } | |||
| } | |||
| @@ -6,38 +6,56 @@ | |||
| "@emotion/react": "^11.10.5", | |||
| "@emotion/styled": "^11.10.5", | |||
| "@faker-js/faker": "^7.6.0", | |||
| "@mui/icons-material": "^5.10.9", | |||
| "@mui/material": "^5.10.12", | |||
| "@mui/x-data-grid": "^5.17.10", | |||
| "@reduxjs/toolkit": "^1.9.0", | |||
| "@testing-library/jest-dom": "^5.14.1", | |||
| "@testing-library/react": "^13.0.0", | |||
| "@testing-library/user-event": "^13.2.1", | |||
| "@types/jest": "^27.0.1", | |||
| "@types/jsonwebtoken": "^8.5.9", | |||
| "@types/node": "^16.7.13", | |||
| "@types/react": "^18.0.0", | |||
| "@types/react-dom": "^18.0.0", | |||
| "@types/react-redux": "^7.1.24", | |||
| "@types/react-router-dom": "^5.3.3", | |||
| "@types/redux-saga": "^0.10.5", | |||
| "axios": "^1.1.3", | |||
| "date-fns": "^2.29.3", | |||
| "formik": "^2.2.9", | |||
| "i18next": "^22.0.4", | |||
| "json-server": "^0.17.1", | |||
| "jsonwebtoken": "^8.5.1", | |||
| "lodash": "^4.17.21", | |||
| "lodash.isempty": "^4.4.0", | |||
| "numeral": "^2.0.6", | |||
| "owasp-password-strength-test": "^1.3.0", | |||
| "qs": "^6.11.0", | |||
| "react": "^18.2.0", | |||
| "react-currency-input-field": "^3.6.9", | |||
| "react-dom": "^18.2.0", | |||
| "react-helmet-async": "^1.3.0", | |||
| "react-i18next": "^12.0.0", | |||
| "react-scripts": "5.0.1", | |||
| "scss": "^0.2.4", | |||
| "typescript": "^4.4.2", | |||
| "web-vitals": "^2.1.0" | |||
| "react-jwt": "^1.1.7", | |||
| "react-number-format": "^5.1.1", | |||
| "react-redux": "^8.0.5", | |||
| "react-router": "^6.4.3", | |||
| "react-router-dom": "^6.4.3", | |||
| "react-scripts": "^5.0.1", | |||
| "react-select": "^5.6.0", | |||
| "redux": "^4.2.0", | |||
| "redux-saga": "^1.2.1", | |||
| "typescript": "*", | |||
| "web-vitals": "^2.1.0", | |||
| "yup": "^0.32.11" | |||
| }, | |||
| "scripts": { | |||
| "start": "react-scripts start", | |||
| "build": "react-scripts build", | |||
| "test": "react-scripts test", | |||
| "eject": "react-scripts eject" | |||
| }, | |||
| "eslintConfig": { | |||
| "extends": [ | |||
| "react-app", | |||
| "react-app/jest" | |||
| ] | |||
| "eject": "react-scripts eject", | |||
| "json-serve": "json-server --watch src/db/db.json --port=4000" | |||
| }, | |||
| "browserslist": { | |||
| "production": [ | |||
| @@ -50,5 +68,27 @@ | |||
| "last 1 firefox version", | |||
| "last 1 safari version" | |||
| ] | |||
| }, | |||
| "devDependencies": { | |||
| "@types/lodash.isempty": "^4.4.7", | |||
| "@types/numeral": "^2.0.2", | |||
| "@types/owasp-password-strength-test": "^1.3.0", | |||
| "@types/sass": "^1.43.1", | |||
| "@types/sass-loader": "^8.0.3", | |||
| "@types/webpack": "^5.28.0", | |||
| "@typescript-eslint/eslint-plugin": "^5.0.0", | |||
| "@typescript-eslint/parser": "^5.43.0", | |||
| "eslint": "^8.0.1", | |||
| "eslint-config-airbnb": "^19.0.4", | |||
| "eslint-config-prettier": "^8.5.0", | |||
| "eslint-config-standard-with-typescript": "^23.0.0", | |||
| "eslint-plugin-import": "^2.25.2", | |||
| "eslint-plugin-jsx-a11y": "^6.6.1", | |||
| "eslint-plugin-n": "^15.0.0", | |||
| "eslint-plugin-promise": "^6.0.0", | |||
| "eslint-plugin-react": "^7.31.10", | |||
| "eslint-plugin-react-hooks": "^4.6.0", | |||
| "prettier": "^2.7.1", | |||
| "sass": "^1.56.0" | |||
| } | |||
| } | |||
| @@ -1,25 +1,23 @@ | |||
| import React from 'react'; | |||
| import logo from './logo.svg'; | |||
| import './App.css'; | |||
| import { Helmet } from 'react-helmet-async'; | |||
| import i18next from 'i18next'; | |||
| import { BrowserRouter } from 'react-router-dom'; | |||
| import AppRoutes from './AppRoutes'; | |||
| function App() { | |||
| return ( | |||
| <div className="App"> | |||
| <header className="App-header"> | |||
| <img src={logo} className="App-logo" alt="logo" /> | |||
| <p> | |||
| Edit <code>src/App.tsx</code> and save to reload. | |||
| </p> | |||
| <a | |||
| className="App-link" | |||
| href="https://reactjs.org" | |||
| target="_blank" | |||
| rel="noopener noreferrer" | |||
| > | |||
| Learn React | |||
| </a> | |||
| </header> | |||
| </div> | |||
| <> | |||
| <BrowserRouter> | |||
| <Helmet> | |||
| <title> | |||
| {i18next.t('app.title')} | |||
| </title> | |||
| </Helmet> | |||
| <main className='l-page'> | |||
| <AppRoutes /> | |||
| </main> | |||
| </BrowserRouter> | |||
| </> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,35 @@ | |||
| import React from "react"; | |||
| import { Route, Routes } from "react-router-dom"; | |||
| import { | |||
| LOGIN_PAGE, | |||
| HOME_PAGE, | |||
| FORGOT_PASSWORD_PAGE, | |||
| NOT_FOUND_PAGE, | |||
| ERROR_PAGE, | |||
| } from "./constants/pages"; | |||
| // import LoginPage from './pages/LoginPage/LoginPage'; | |||
| import LoginPage from "./pages/LoginPage/LoginPageMUI"; | |||
| // import HomePage from './pages/HomePage/HomePage'; | |||
| import HomePage from "./pages/HomePage/HomePageMUI"; | |||
| import NotFoundPage from "./pages/ErrorPages/NotFoundPage"; | |||
| import ErrorPage from "./pages/ErrorPages/ErrorPage"; | |||
| // import ForgotPasswordPage from './pages/ForgotPasswordPage/ForgotPasswordPage'; | |||
| import ForgotPasswordPage from "./pages/ForgotPasswordPage/ForgotPasswordPage"; | |||
| import PrivateRoute from "./components/Router/PrivateRoute"; | |||
| const AppRoutes = () => ( | |||
| <Routes> | |||
| <Route path="*" element={<NotFoundPage />} /> | |||
| <Route path={LOGIN_PAGE} element={<LoginPage />} /> | |||
| <Route path={NOT_FOUND_PAGE} element={<NotFoundPage />} /> | |||
| <Route path={ERROR_PAGE} element={<ErrorPage />} /> | |||
| <Route path={FORGOT_PASSWORD_PAGE} element={<ForgotPasswordPage />} /> | |||
| <Route path={HOME_PAGE} element={<PrivateRoute />}> | |||
| <Route path={HOME_PAGE} element={<HomePage />} /> | |||
| </Route> | |||
| </Routes> | |||
| ); | |||
| export default AppRoutes; | |||
| @@ -0,0 +1,19 @@ | |||
| import React, { ReactNode } from 'react'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| interface AuthProps { | |||
| children: ReactNode; | |||
| } | |||
| const Auth: React.FC<AuthProps> = ({ children }) => { | |||
| const { t } = useTranslation(); | |||
| return ( | |||
| <div className="c-auth"> | |||
| <h1 className="c-auth__title">{t(`login.welcome`)}</h1> | |||
| {children} | |||
| </div> | |||
| ); | |||
| }; | |||
| export default Auth; | |||
| @@ -0,0 +1,23 @@ | |||
| import React, { ReactNode } from 'react'; | |||
| import SectionLoader from '../Loader/SectionLoader'; | |||
| interface AuthCardProps { | |||
| children: ReactNode; | |||
| title?: string; | |||
| subtitle?: string; | |||
| isLoading?: boolean; | |||
| } | |||
| const AuthCard: React.FC<AuthCardProps> = ({ children, title, subtitle, isLoading }) => { | |||
| return ( | |||
| <div className="c-auth-card"> | |||
| <SectionLoader isLoading={isLoading ? isLoading : false}> | |||
| <h1 className="c-auth-card__title">{title}</h1> | |||
| <h2 className="c-auth-card__subtitle">{subtitle}</h2> | |||
| {children} | |||
| </SectionLoader> | |||
| </div> | |||
| ); | |||
| }; | |||
| export default AuthCard; | |||
| @@ -0,0 +1,102 @@ | |||
| import React, { useRef } from 'react'; | |||
| type Size = 'sm' | 'md' | 'lg' | 'xl'; | |||
| type Type = 'button' | 'submit' | 'reset'; | |||
| type TextTransform = 'uppercase' | 'capitalize'; | |||
| type MinWidth = 'auto' | 'none'; | |||
| interface ButtonProps { | |||
| variant?: string; | |||
| size?: Size; | |||
| children?: React.ReactNode; | |||
| authButton?: boolean; | |||
| type?: Type; | |||
| onClick?: () => void; | |||
| textTransform?: TextTransform; | |||
| className?: string; | |||
| disabled?: boolean; | |||
| hidden?: boolean; | |||
| minWidth?: MinWidth; | |||
| } | |||
| const Button: React.FC<ButtonProps> = ({ | |||
| variant, | |||
| size, | |||
| children, | |||
| authButton, | |||
| type, | |||
| onClick, | |||
| textTransform, | |||
| className, | |||
| disabled, | |||
| hidden, | |||
| minWidth, | |||
| ...restProps | |||
| }) => { | |||
| const buttonRef = useRef<HTMLButtonElement>(null); | |||
| function styles() { | |||
| let style = 'c-btn'; | |||
| if (variant) { | |||
| style += ` c-btn--${variant}`; | |||
| } | |||
| if (size) { | |||
| style += ` c-btn--${size}`; | |||
| } | |||
| if (textTransform) { | |||
| style += ` c-btn--${textTransform}`; | |||
| } | |||
| if (authButton) { | |||
| style += ` c-btn--auth`; | |||
| } | |||
| if (minWidth) { | |||
| style += ` c-btn--${minWidth}`; | |||
| } | |||
| if (hidden) { | |||
| style += ` c-btn--hidden`; | |||
| } | |||
| if (className) { | |||
| style += ` ${className}`; | |||
| } | |||
| return style; | |||
| } | |||
| function handleClick() { | |||
| if (buttonRef.current != null) { | |||
| buttonRef.current.blur(); | |||
| } | |||
| if (typeof onClick === 'function') { | |||
| onClick(); | |||
| } | |||
| } | |||
| return ( | |||
| <button | |||
| ref={buttonRef} | |||
| className={styles()} | |||
| onClick={handleClick} | |||
| type={type} | |||
| disabled={disabled} | |||
| {...restProps} | |||
| > | |||
| {children} | |||
| </button> | |||
| ); | |||
| }; | |||
| Button.defaultProps = { | |||
| type: 'button', | |||
| }; | |||
| export default Button; | |||
| @@ -0,0 +1,34 @@ | |||
| import React, { ReactNode, useRef } from 'react'; | |||
| interface IconButtonProps { | |||
| children: ReactNode; | |||
| onClick: () => void; | |||
| className: string; | |||
| } | |||
| const IconButton: React.FC<IconButtonProps> = ({ children, onClick, className }) => { | |||
| const buttonRef = useRef<HTMLButtonElement>(null); | |||
| function handleClick() { | |||
| if (buttonRef.current != null) { | |||
| buttonRef.current.blur(); | |||
| } | |||
| if (typeof onClick === 'function') { | |||
| onClick(); | |||
| } | |||
| } | |||
| return ( | |||
| <button | |||
| type="button" | |||
| ref={buttonRef} | |||
| onClick={handleClick} | |||
| className={`c-icon-button ${className && className}`} | |||
| > | |||
| {children} | |||
| </button> | |||
| ); | |||
| }; | |||
| export default IconButton; | |||
| @@ -0,0 +1,188 @@ | |||
| import React, { useEffect, useState, useRef, ReactNode } from 'react'; | |||
| import { ErrorMessage } from 'formik'; | |||
| import { ReactComponent as Search } from '../../assets/images/svg/search.svg'; | |||
| import { ReactComponent as EyeOn } from '../../assets/images/svg/eye-on.svg'; | |||
| import { ReactComponent as EyeOff } from '../../assets/images/svg/eye-off.svg'; | |||
| import { ReactComponent as CapsLock } from '../../assets/images/svg/caps-lock.svg'; | |||
| import IconButton from '../IconButton/IconButton'; | |||
| interface Form { | |||
| errors: Array<string>; | |||
| setFieldError: (fieldName: string, errorMessage: string) => void; | |||
| touched: any; | |||
| } | |||
| interface BaseInputFieldProps { | |||
| type: string; | |||
| label: string; | |||
| field: any; | |||
| placeholder: string; | |||
| clearPlaceholderOnFocus?: boolean; | |||
| isSearch?: boolean; | |||
| className?: string; | |||
| disabled: boolean; | |||
| centerText?: boolean; | |||
| link?: ReactNode; | |||
| errorMessage?: string; | |||
| autoFocus?: boolean; | |||
| isCapsLockOn?: boolean; | |||
| form: Form; | |||
| onKeyDown?: any; | |||
| onChange?: (e: any) => void; | |||
| } | |||
| const BaseInputField: React.FC<BaseInputFieldProps> = ({ | |||
| type, | |||
| label, | |||
| field, | |||
| form, | |||
| placeholder, | |||
| clearPlaceholderOnFocus = true, | |||
| isSearch, | |||
| className, | |||
| disabled, | |||
| centerText, | |||
| link, | |||
| errorMessage, | |||
| autoFocus, | |||
| isCapsLockOn, | |||
| ...props | |||
| }) => { | |||
| const [inputPlaceholder, setPlaceholder] = useState(placeholder); | |||
| const inputField = useRef<HTMLInputElement>(null); | |||
| useEffect(() => { | |||
| if (autoFocus) { | |||
| if (inputField.current !== null) { | |||
| inputField.current.focus(); | |||
| } | |||
| } | |||
| }, [autoFocus, inputField]); | |||
| useEffect(() => { | |||
| if (errorMessage) { | |||
| form.setFieldError(field.name, errorMessage); | |||
| } | |||
| }, [errorMessage]); // eslint-disable-line | |||
| useEffect(() => { | |||
| setPlaceholder(placeholder); | |||
| }, [placeholder]); | |||
| const [inputType, setInputType] = useState('password'); | |||
| const passwordInput = type === 'password' ? ' c-input--password' : ''; | |||
| const showPassword = () => { | |||
| if (inputType === 'password') { | |||
| setInputType('text'); | |||
| } else { | |||
| setInputType('password'); | |||
| } | |||
| }; | |||
| // Nester Formik Field Names get bugged because of Undefined values, so i had to fix it like this | |||
| // If you ask why 0 and 1? I dont see a need for forms to be nested more then 2 levels? | |||
| const fieldName: any = field.name.split('.'); | |||
| const formError = | |||
| fieldName[0] && fieldName[1] | |||
| ? form.errors[fieldName[0]] && form.errors[fieldName[0]][fieldName[1]] | |||
| : form.errors[fieldName[0]]; | |||
| const formTouched = | |||
| fieldName[0] && fieldName[1] | |||
| ? form.touched[fieldName[0]] && form.touched[fieldName[0]][fieldName[1]] | |||
| : form.touched[fieldName[0]]; | |||
| function styles() { | |||
| let style = 'c-input'; | |||
| if (formError && formTouched) { | |||
| style += ` c-input--error`; | |||
| } | |||
| if (type === 'password') { | |||
| style += ` c-input--password`; | |||
| } | |||
| if (isSearch) { | |||
| style += ` c-input--search`; | |||
| } | |||
| if (centerText) { | |||
| style += ` c-input--center-text`; | |||
| } | |||
| if (type === 'number') { | |||
| style += ` c-input--demi-bold`; | |||
| } | |||
| if (className) { | |||
| style += ` ${className}`; | |||
| } | |||
| return style; | |||
| } | |||
| const additionalActions = () => { | |||
| if (!clearPlaceholderOnFocus) { | |||
| return null; | |||
| } | |||
| return { | |||
| onFocus: () => { | |||
| setPlaceholder(''); | |||
| }, | |||
| onBlur: (e: any) => { | |||
| setPlaceholder(placeholder); | |||
| field.onBlur(e); | |||
| }, | |||
| }; | |||
| }; | |||
| return ( | |||
| <div className={styles()}> | |||
| {!!label && ( | |||
| <label className="c-input__label" htmlFor={field.name}> | |||
| {label} | |||
| </label> | |||
| )} | |||
| {link && <div className="c-input__link">{link}</div>} | |||
| <div className="c-input__field-wrap"> | |||
| <input | |||
| ref={inputField} | |||
| type={type === 'password' ? inputType : type} | |||
| placeholder={inputPlaceholder} | |||
| disabled={disabled} | |||
| {...field} | |||
| {...props} | |||
| {...additionalActions()} | |||
| className="c-input__field" | |||
| /> | |||
| {!!isSearch && <Search className="c-input__icon" />} | |||
| {!!passwordInput && ( | |||
| <> | |||
| {isCapsLockOn && <CapsLock className="c-input__caps-lock" />} | |||
| <IconButton | |||
| onClick={() => { | |||
| showPassword(); | |||
| }} | |||
| className="c-input__icon" | |||
| > | |||
| {inputType === 'password' ? <EyeOff /> : <EyeOn />} | |||
| </IconButton> | |||
| </> | |||
| )} | |||
| </div> | |||
| <ErrorMessage name={field.name}> | |||
| {(errorMessage) => ( | |||
| <span className="c-input__error">{errorMessage}</span> | |||
| )} | |||
| </ErrorMessage> | |||
| </div> | |||
| ); | |||
| }; | |||
| export default BaseInputField; | |||
| @@ -0,0 +1,40 @@ | |||
| import React from 'react'; | |||
| import PropTypes from 'prop-types'; | |||
| import { ReactComponent as Checked } from '../../assets/images/svg/checked.svg'; | |||
| import { ReactComponent as Unchecked } from '../../assets/images/svg/unchecked.svg'; | |||
| interface CheckboxProps { | |||
| className: string; | |||
| children: React.ReactNode; | |||
| name: string; | |||
| checked: boolean; | |||
| field: { | |||
| onChange: () => void; | |||
| }; | |||
| onChange: () => void; | |||
| } | |||
| const Checkbox: React.FC<CheckboxProps> = ({ className, children, name, onChange, checked, field }) => ( | |||
| <label htmlFor={name} className={`c-checkbox ${className || ''}`}> | |||
| <input | |||
| name={name} | |||
| id={name} | |||
| className="c-checkbox__field" | |||
| type="checkbox" | |||
| checked={checked} | |||
| {...field} | |||
| onChange={onChange || field.onChange} | |||
| /> | |||
| <div className="c-checkbox__indicator"> | |||
| {checked ? ( | |||
| <Checked className="c-checkbox__icon" /> | |||
| ) : ( | |||
| <Unchecked className="c-checkbox__icon" /> | |||
| )} | |||
| </div> | |||
| <div className="c-checkbox__text">{children}</div> | |||
| </label> | |||
| ); | |||
| export default Checkbox; | |||
| @@ -0,0 +1,121 @@ | |||
| import React, { useEffect, useRef } from 'react'; | |||
| import { ErrorMessage, useField } from 'formik'; | |||
| import CurrencyInput from 'react-currency-input-field'; | |||
| import { | |||
| PLUS_SYMBOL, | |||
| MINUS_SYMBOL, | |||
| NUMPAD_MINUS_SYMBOL, | |||
| NUMPAD_PLUS_SYMBOL, | |||
| K_KEYCODE, | |||
| } from '../../constants/keyCodeConstants'; | |||
| import { formatMoneyNumeral } from '../../util/helpers/numeralHelpers'; | |||
| interface CurrencyFieldProps { | |||
| autoFocus: boolean; | |||
| notCentered: boolean; | |||
| notBold: boolean; | |||
| label: string; | |||
| onChange: (value: number | undefined) => void; | |||
| value: string | number; | |||
| } | |||
| const CurrencyField: React.FC<CurrencyFieldProps> = ({ | |||
| autoFocus, | |||
| notCentered, | |||
| notBold, | |||
| label, | |||
| onChange, | |||
| value, | |||
| ...props | |||
| }) => { | |||
| /* @ts-ignore */ | |||
| const [field, meta] = useField(props); | |||
| const inputField = useRef<HTMLInputElement>(null); | |||
| function styles() { | |||
| let style = 'c-currency-field'; | |||
| if (meta.error && meta.touched) { | |||
| style += ` c-currency-field--error`; | |||
| } | |||
| if (notCentered) { | |||
| style += ` c-currency-field--not-centered`; | |||
| } | |||
| if (notBold) { | |||
| style += ` c-currency-field--not-bold`; | |||
| } | |||
| return style; | |||
| } | |||
| useEffect(() => { | |||
| if (autoFocus) { | |||
| if (inputField.current !== null) { | |||
| inputField.current.focus(); | |||
| } | |||
| } | |||
| }, [autoFocus, inputField]); | |||
| const onKeydownHandler = (event: React.KeyboardEvent<HTMLInputElement>) => { | |||
| if ( | |||
| event.keyCode === MINUS_SYMBOL || | |||
| event.keyCode === PLUS_SYMBOL || | |||
| event.keyCode === NUMPAD_MINUS_SYMBOL || | |||
| event.keyCode === NUMPAD_PLUS_SYMBOL || | |||
| event.keyCode === K_KEYCODE | |||
| ) { | |||
| event.preventDefault(); | |||
| } | |||
| }; | |||
| const prefix = formatMoneyNumeral(0); | |||
| const prefixSymbol = () => { | |||
| if (prefix.includes('CAD')) { | |||
| return 'CAD '; | |||
| } | |||
| return '$'; | |||
| }; | |||
| return ( | |||
| <div className={styles()}> | |||
| {!!label && ( | |||
| <label className="c-currency-field__label" htmlFor={field.name}> | |||
| {label} | |||
| </label> | |||
| )} | |||
| {value ? ( | |||
| <CurrencyInput | |||
| {...props} | |||
| prefix={prefixSymbol()} | |||
| onValueChange={(value) => { | |||
| onChange(Number(value)); | |||
| }} | |||
| onKeyDown={onKeydownHandler} | |||
| ref={inputField} | |||
| defaultValue={0} | |||
| value={value} | |||
| /> | |||
| ) : ( | |||
| <CurrencyInput | |||
| {...props} | |||
| prefix={prefixSymbol()} | |||
| onValueChange={(value) => { | |||
| onChange(Number(value)); | |||
| }} | |||
| onKeyDown={onKeydownHandler} | |||
| ref={inputField} | |||
| /> | |||
| )} | |||
| <ErrorMessage name={field.name}> | |||
| {(errorMessage) => ( | |||
| <span className="c-currency-field__error">{errorMessage}</span> | |||
| )} | |||
| </ErrorMessage> | |||
| </div> | |||
| ); | |||
| }; | |||
| export default CurrencyField; | |||
| @@ -0,0 +1,32 @@ | |||
| import React from 'react'; | |||
| import BaseInputField from './BaseInputField'; | |||
| interface EmailFieldProps{ | |||
| field: any; | |||
| form: any; | |||
| label: string; | |||
| placeholder: string; | |||
| disabled: boolean; | |||
| } | |||
| const EmailField: React.FC<EmailFieldProps> = ({ | |||
| field, | |||
| form, | |||
| label, | |||
| placeholder, | |||
| disabled, | |||
| ...props | |||
| }) => ( | |||
| <BaseInputField | |||
| type="email" | |||
| label={label} | |||
| placeholder={placeholder} | |||
| disabled={disabled} | |||
| form={form} | |||
| field={field} | |||
| {...props} | |||
| /> | |||
| ); | |||
| export default EmailField; | |||
| @@ -0,0 +1,72 @@ | |||
| import React from 'react'; | |||
| import BaseInputField from './BaseInputField'; | |||
| import { | |||
| PERIOD_SYMBOL, | |||
| COMMA_SYMBOL, | |||
| PLUS_SYMBOL, | |||
| MINUS_SYMBOL, | |||
| NUMPAD_PERIOD_SYMBOL, | |||
| NUMPAD_MINUS_SYMBOL, | |||
| NUMPAD_PLUS_SYMBOL, | |||
| DOWN_ARROW_KEYCODE, | |||
| UP_ARROW_KEYCODE, | |||
| } from '../../constants/keyCodeConstants'; | |||
| interface NumberFieldProps { | |||
| label: string; | |||
| placeholder: string; | |||
| disabled: boolean; | |||
| preventAllExceptNumbers: boolean; | |||
| field: any; | |||
| form: any; | |||
| } | |||
| const NumberField: React.FC<NumberFieldProps> = ({ | |||
| field, | |||
| form, | |||
| label, | |||
| placeholder, | |||
| disabled, | |||
| preventAllExceptNumbers, | |||
| ...props | |||
| }) => { | |||
| const onKeydownHandler = (event: React.KeyboardEvent) => { | |||
| if (preventAllExceptNumbers) { | |||
| if ( | |||
| event.keyCode === PERIOD_SYMBOL || | |||
| event.keyCode === COMMA_SYMBOL || | |||
| event.keyCode === NUMPAD_PERIOD_SYMBOL || | |||
| event.keyCode === DOWN_ARROW_KEYCODE || | |||
| event.keyCode === UP_ARROW_KEYCODE | |||
| ) { | |||
| event.preventDefault(); | |||
| } | |||
| } | |||
| if ( | |||
| event.keyCode === PLUS_SYMBOL || | |||
| event.keyCode === MINUS_SYMBOL || | |||
| event.keyCode === NUMPAD_MINUS_SYMBOL || | |||
| event.keyCode === NUMPAD_PLUS_SYMBOL || | |||
| event.keyCode === DOWN_ARROW_KEYCODE || | |||
| event.keyCode === UP_ARROW_KEYCODE | |||
| ) { | |||
| event.preventDefault(); | |||
| } | |||
| }; | |||
| return ( | |||
| <BaseInputField | |||
| type="number" | |||
| label={label} | |||
| placeholder={placeholder} | |||
| disabled={disabled} | |||
| form={form} | |||
| field={field} | |||
| {...props} | |||
| onKeyDown={onKeydownHandler} | |||
| /> | |||
| ); | |||
| }; | |||
| export default NumberField; | |||
| @@ -0,0 +1,84 @@ | |||
| import React, { ReactElement, useState } from 'react'; | |||
| import BaseInputField from './BaseInputField'; | |||
| import PasswordStrength from './PasswordStrength'; | |||
| interface Field { | |||
| name: string; | |||
| onFocus: () => void; | |||
| onBlur: () => void; | |||
| onChange: (e: any) => void; | |||
| } | |||
| interface Form { | |||
| errors: Array<string>; | |||
| setFieldError: (fieldName: string, errorMessage: string) => void; | |||
| touched: any; | |||
| } | |||
| interface PasswordFieldProps { | |||
| field: Field; | |||
| form: Form; | |||
| label: string; | |||
| placeholder: string; | |||
| disabled: boolean; | |||
| shouldTestPasswordStrength: boolean; | |||
| autoFocus: boolean; | |||
| } | |||
| const PasswordField: React.FC<PasswordFieldProps> = ({ | |||
| field, | |||
| form, | |||
| label, | |||
| placeholder, | |||
| disabled, | |||
| shouldTestPasswordStrength, | |||
| autoFocus, | |||
| ...props | |||
| }) => { | |||
| const [passwordValue, setPasswordValue] = useState(''); | |||
| const [isCapsLockOn, setIsCapsLockOn] = useState(false); | |||
| const onChange = (e: any) => { | |||
| if (shouldTestPasswordStrength) { | |||
| const { value } = e.target; | |||
| setPasswordValue(value); | |||
| } | |||
| field.onChange(e); | |||
| }; | |||
| const onKeyDown = (keyEvent: KeyboardEvent) => { | |||
| if (keyEvent.getModifierState('CapsLock')) { | |||
| setIsCapsLockOn(true); | |||
| } else { | |||
| setIsCapsLockOn(false); | |||
| } | |||
| }; | |||
| return ( | |||
| <div className="c-password"> | |||
| <BaseInputField | |||
| type="password" | |||
| label={label} | |||
| placeholder={placeholder} | |||
| disabled={disabled} | |||
| form={form} | |||
| field={field} | |||
| {...props} | |||
| onChange={onChange} | |||
| autoFocus={autoFocus} | |||
| onKeyDown={onKeyDown} | |||
| isCapsLockOn={isCapsLockOn} | |||
| /> | |||
| {shouldTestPasswordStrength && ( | |||
| <PasswordStrength | |||
| passwordValue={passwordValue} | |||
| shouldTestPasswordStrength | |||
| /> | |||
| )} | |||
| </div> | |||
| ); | |||
| }; | |||
| export default PasswordField; | |||
| @@ -0,0 +1,125 @@ | |||
| import React, { useEffect, useRef, useState } from 'react'; | |||
| import owasp from 'owasp-password-strength-test'; | |||
| import i18next from 'i18next'; | |||
| owasp.config({ | |||
| minOptionalTestsToPass: 3, | |||
| }); | |||
| const passwordStrengthOptions = [ | |||
| { | |||
| strength: 'weak', | |||
| color: '#FF5028', | |||
| }, | |||
| { | |||
| strength: 'average', | |||
| color: '#FDB942', | |||
| }, | |||
| { | |||
| strength: 'good', | |||
| color: '#06BEE7', | |||
| }, | |||
| { | |||
| strength: 'strong', | |||
| color: '#00876A', | |||
| }, | |||
| ]; | |||
| /** | |||
| * User must pass a required test and at least 3 optional. | |||
| * @param result - owasp result | |||
| * @returns {number} - index of password strength 0-3 | |||
| */ | |||
| function getPasswordStrengthIndex(result) { | |||
| // requirement for strong password is required test passed and at least 3 optional tests | |||
| if (result.strong) { | |||
| return 3; | |||
| } | |||
| if (!result.strong && result.optionalTestsPassed >= 3) { | |||
| return 2; | |||
| } | |||
| if (result.optionalTestsPassed <= 0) { | |||
| return 0; | |||
| } | |||
| return result.optionalTestsPassed - 1; | |||
| } | |||
| const PasswordStrength = ({ | |||
| shouldTestPasswordStrength, | |||
| passwordValue, | |||
| passwordStrengthTestsRequired, | |||
| }) => { | |||
| const strengthContainer = useRef(null); | |||
| const [passwordStrength, setPasswordStrength] = useState({ | |||
| width: 0, | |||
| color: 'red', | |||
| }); | |||
| const [error, setError] = useState(''); | |||
| useEffect(() => { | |||
| if (shouldTestPasswordStrength && passwordValue) { | |||
| const bBox = strengthContainer.current.getBoundingClientRect(); | |||
| const result = owasp.test(passwordValue); | |||
| console.log(typeof result); | |||
| const passwordStrengthIndex = getPasswordStrengthIndex(result); | |||
| const passwordOption = passwordStrengthOptions[passwordStrengthIndex]; | |||
| const width = !passwordValue | |||
| ? 0 | |||
| : (bBox.width * (passwordStrengthIndex + 1)) / | |||
| passwordStrengthTestsRequired; | |||
| setPasswordStrength({ width, color: passwordOption.color }); | |||
| const strength = i18next.t(`password.${passwordOption.strength}`); | |||
| setError(i18next.t('login.passwordStrength', { strength })); | |||
| } | |||
| }, [ | |||
| passwordValue, | |||
| shouldTestPasswordStrength, | |||
| passwordStrengthTestsRequired, | |||
| ]); | |||
| if (!shouldTestPasswordStrength || !passwordValue) { | |||
| return null; | |||
| } | |||
| const renderError = () => { | |||
| if (!error) { | |||
| return null; | |||
| } | |||
| return ( | |||
| <div | |||
| className="c-input--error" | |||
| style={{ | |||
| color: passwordStrength.color, | |||
| }} | |||
| > | |||
| {error} | |||
| </div> | |||
| ); | |||
| }; | |||
| return ( | |||
| <div ref={strengthContainer} className="c-password-strength__container"> | |||
| <div className="c-password-strength__line--wrapper"> | |||
| <div | |||
| className="c-password-strength__line" | |||
| style={{ | |||
| backgroundColor: passwordStrength.color, | |||
| width: passwordStrength.width, | |||
| }} | |||
| /> | |||
| </div> | |||
| {renderError()} | |||
| </div> | |||
| ); | |||
| }; | |||
| PasswordStrength.defaultProps = { | |||
| passwordStrengthTestsRequired: 4, | |||
| }; | |||
| export default PasswordStrength; | |||
| @@ -0,0 +1,45 @@ | |||
| import React from 'react'; | |||
| import PropTypes from 'prop-types'; | |||
| import NumberFormat from 'react-number-format'; | |||
| import TextField from './TextField'; | |||
| const PercentageField = ({ field, ...props }) => { | |||
| const handleOnChange = (percentageField) => { | |||
| const { floatValue } = percentageField; | |||
| if (!props.onChange) { | |||
| throw Error('Provide an onChange handler'); | |||
| } | |||
| if (floatValue > 100) { | |||
| return props.onChange('100'); | |||
| } | |||
| if (floatValue <= 0 || !floatValue) { | |||
| return props.onChange('0'); | |||
| } | |||
| return props.onChange(floatValue.toString()); | |||
| }; | |||
| return ( | |||
| <NumberFormat | |||
| format="###%" | |||
| value={field.value} | |||
| customInput={TextField} | |||
| field={field} | |||
| {...props} | |||
| onValueChange={handleOnChange} | |||
| onChange={() => {}} | |||
| /> | |||
| ); | |||
| }; | |||
| PercentageField.propTypes = { | |||
| onChange: PropTypes.func, | |||
| field: PropTypes.shape({ | |||
| value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), | |||
| }), | |||
| }; | |||
| export default PercentageField; | |||
| @@ -0,0 +1,82 @@ | |||
| import React from 'react'; | |||
| import { | |||
| BACKSPACE_KEYCODE, | |||
| TAB_KEYCODE, | |||
| RIGHT_ARROW_KEYCODE, | |||
| LEFT_ARROW_KEYCODE, | |||
| } from '../../constants/keyCodeConstants'; | |||
| import BaseInputField from './BaseInputField'; | |||
| interface Field { | |||
| name: string; | |||
| onFocus: () => void; | |||
| onBlur: () => void; | |||
| } | |||
| interface Form { | |||
| errors: Array<string>; | |||
| setFieldError: (fieldName: string, errorMessage: string) => void; | |||
| touched: any; | |||
| } | |||
| interface TextFieldProps { | |||
| field: Field; | |||
| form: Form; | |||
| label: string; | |||
| placeholder: string; | |||
| disabled: boolean; | |||
| centerText: boolean; | |||
| autoFocus: boolean; | |||
| preventAllExceptNumbers: boolean; | |||
| } | |||
| const TextField: React.FC<TextFieldProps> = ({ | |||
| field, | |||
| form, | |||
| label, | |||
| placeholder, | |||
| disabled, | |||
| centerText, | |||
| autoFocus, | |||
| preventAllExceptNumbers, | |||
| ...props | |||
| }) => { | |||
| const onKeydownHandler = (event: React.KeyboardEvent) => { | |||
| if (preventAllExceptNumbers) { | |||
| if ( | |||
| event.keyCode === BACKSPACE_KEYCODE || | |||
| event.keyCode === TAB_KEYCODE || | |||
| event.keyCode === RIGHT_ARROW_KEYCODE || | |||
| event.keyCode === LEFT_ARROW_KEYCODE | |||
| ) { | |||
| return; | |||
| } | |||
| if ( | |||
| (event.keyCode < 58 && event.keyCode > 47) || | |||
| (event.keyCode < 106 && event.keyCode > 95) | |||
| ) { | |||
| return; | |||
| } | |||
| event.preventDefault(); | |||
| } | |||
| }; | |||
| return ( | |||
| <BaseInputField | |||
| autoFocus={autoFocus} | |||
| type="text" | |||
| label={label} | |||
| placeholder={placeholder} | |||
| disabled={disabled} | |||
| form={form} | |||
| field={field} | |||
| centerText={centerText} | |||
| {...props} | |||
| onKeyDown={onKeydownHandler} | |||
| /> | |||
| ); | |||
| }; | |||
| export default TextField; | |||
| @@ -0,0 +1,24 @@ | |||
| import React, { ReactNode } from 'react'; | |||
| interface BlockSectionLoaderProps { | |||
| children: ReactNode; | |||
| isLoading: boolean; | |||
| fullHeight: boolean; | |||
| noShadow: boolean; | |||
| } | |||
| const BlockSectionLoader: React.FC<BlockSectionLoaderProps> = ({ children, isLoading, fullHeight, noShadow }) => ( | |||
| <div | |||
| className={`c-loader__wrapper c-loader__wrapper--block ${fullHeight ? 'c-loader__wrapper--full-height' : '' | |||
| } ${noShadow ? 'c-loader__wrapper--no-shadow' : ''}`} | |||
| > | |||
| {children} | |||
| {isLoading && ( | |||
| <div className="c-loader"> | |||
| <div className="c-loader__icon" /> | |||
| </div> | |||
| )} | |||
| </div> | |||
| ); | |||
| export default BlockSectionLoader; | |||
| @@ -0,0 +1,9 @@ | |||
| const FullPageLoader: React.FC = () => { | |||
| return ( | |||
| <div className="c-loader c-loader--page"> | |||
| <div className="c-loader__icon" /> | |||
| </div> | |||
| ); | |||
| }; | |||
| export default FullPageLoader; | |||
| @@ -0,0 +1,19 @@ | |||
| import React, { ReactNode } from 'react'; | |||
| interface SectionLoaderProps { | |||
| children: ReactNode; | |||
| isLoading: boolean; | |||
| } | |||
| const SectionLoader: React.FC<SectionLoaderProps> = ({ children, isLoading }) => ( | |||
| <div className="c-loader__wrapper"> | |||
| {children} | |||
| {isLoading && ( | |||
| <div className="c-loader"> | |||
| <div className="c-loader__icon" /> | |||
| </div> | |||
| )} | |||
| </div> | |||
| ); | |||
| export default SectionLoader; | |||
| @@ -0,0 +1,25 @@ | |||
| import React from 'react'; | |||
| import { Backdrop, CircularProgress } from '@mui/material'; | |||
| import { alpha } from '@mui/system'; | |||
| interface BackdropComponentProps { | |||
| position: 'absolute' | 'fixed'; | |||
| isLoading: boolean; | |||
| } | |||
| const BackdropComponent: React.FC<BackdropComponentProps> = ({ position = 'fixed', isLoading }) => ( | |||
| <Backdrop | |||
| sx={{ | |||
| // 'fixed' takes whole page, 'absolute' takes whole space of the parent element which needs to have 'relative' position | |||
| position, | |||
| backgroundColor: ({ palette }) => | |||
| alpha(palette.background.default, palette.action.disabledOpacity), | |||
| zIndex: ({ zIndex }) => zIndex.drawer + 1, | |||
| }} | |||
| open={isLoading} | |||
| > | |||
| <CircularProgress /> | |||
| </Backdrop> | |||
| ); | |||
| export default BackdropComponent; | |||
| @@ -0,0 +1,56 @@ | |||
| import React from 'react'; | |||
| import { | |||
| Dialog, | |||
| DialogContent, | |||
| DialogTitle, | |||
| DialogActions, | |||
| Button, | |||
| useMediaQuery, | |||
| useTheme, | |||
| } from '@mui/material'; | |||
| interface DialogComponentProps { | |||
| title: string; | |||
| content: any; | |||
| onClose: () => void; | |||
| open: boolean; | |||
| maxWidth: 'xs' | 'sm' | 'md' | 'lg'| 'xl'; | |||
| fullWidth: boolean; | |||
| responsive: boolean; | |||
| } | |||
| const DialogComponent: React.FC<DialogComponentProps> = ({ | |||
| title, | |||
| content, | |||
| onClose, | |||
| open, | |||
| maxWidth, | |||
| fullWidth, | |||
| responsive, | |||
| }) => { | |||
| const theme = useTheme(); | |||
| const fullScreen = useMediaQuery(theme.breakpoints.down('md')); | |||
| const handleClose = () => { | |||
| onClose(); | |||
| }; | |||
| return ( | |||
| <Dialog | |||
| maxWidth={maxWidth} | |||
| fullWidth={fullWidth} | |||
| fullScreen={responsive && fullScreen} | |||
| onClose={handleClose} | |||
| open={open} | |||
| > | |||
| <DialogTitle>{title}</DialogTitle> | |||
| {content && <DialogContent>{content}</DialogContent>} | |||
| <DialogActions> | |||
| <Button onClick={handleClose}>OK</Button> | |||
| <Button onClick={handleClose}>Cancel</Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| ); | |||
| }; | |||
| export default DialogComponent; | |||
| @@ -0,0 +1,27 @@ | |||
| import React from 'react'; | |||
| import { Drawer } from '@mui/material'; | |||
| interface DrawerComponentProps { | |||
| open: boolean; | |||
| toggleOpen: () => void; | |||
| content: any; | |||
| anchor?: 'top' | 'right' | 'left' | 'bottom'; | |||
| } | |||
| const DrawerComponent: React.FC<DrawerComponentProps> = ({ open, toggleOpen, content, anchor = 'right' }) => ( | |||
| <Drawer | |||
| sx={{ | |||
| minWidth: 250, | |||
| '& .MuiDrawer-paper': { | |||
| minWidth: 250, | |||
| }, | |||
| }} | |||
| anchor={anchor} | |||
| open={open} | |||
| onClose={toggleOpen} | |||
| > | |||
| {content ? content : null} | |||
| </Drawer> | |||
| ); | |||
| export default DrawerComponent; | |||
| @@ -0,0 +1,14 @@ | |||
| import React from 'react'; | |||
| import { Typography } from '@mui/material'; | |||
| interface ErrorMessageComponentProps { | |||
| error: string; | |||
| } | |||
| const ErrorMessageComponent: React.FC<ErrorMessageComponentProps> = ({ error }) => ( | |||
| <Typography variant="body1" color="error" my={2}> | |||
| {error} | |||
| </Typography> | |||
| ); | |||
| export default ErrorMessageComponent; | |||
| @@ -0,0 +1,29 @@ | |||
| import React from 'react'; | |||
| import { Paper, Typography } from '@mui/material'; | |||
| import { DataGrid } from '@mui/x-data-grid'; | |||
| // Use these values from REDUX? | |||
| const rows = [ | |||
| { id: 1, col1: 'Example', col2: 'Row', col3: '1' }, | |||
| { id: 2, col1: 'Row', col2: 'Example', col3: '2' }, | |||
| { id: 3, col1: '3', col2: 'Row', col3: 'Example' }, | |||
| ]; | |||
| const columns = [ | |||
| { field: 'col1', headerName: 'Column 1', flex: 1 }, | |||
| { field: 'col2', headerName: 'Column 2', flex: 1 }, | |||
| { field: 'col3', headerName: 'Column 2', flex: 1 }, | |||
| ]; | |||
| const DataGridExample: React.FC = () => { | |||
| return ( | |||
| <Paper sx={{ p: 2 }} elevation={5}> | |||
| <Typography variant="h4" gutterBottom align="center"> | |||
| DataGrid Example | |||
| </Typography> | |||
| <DataGrid autoHeight rows={rows} columns={columns} /> | |||
| </Paper> | |||
| ); | |||
| }; | |||
| export default DataGridExample; | |||
| @@ -0,0 +1,64 @@ | |||
| import React, { useState } from 'react'; | |||
| import { Button, Divider, Paper, Typography } from '@mui/material'; | |||
| import DialogComponent from '../DialogComponent'; | |||
| import DrawerComponent from '../DrawerComponent'; | |||
| import PopoverComponent from '../PopoverComponent'; | |||
| const Modals: React.FC = () => { | |||
| const [dialogOpen, setDialogOpen] = useState(false); | |||
| const [drawerOpen, setDrawerOpen] = useState(false); | |||
| const [popoverOpen, setPopoverOpen] = useState(false); | |||
| const [anchorEl, setAnchorEl] = useState(null); | |||
| return ( | |||
| <Paper | |||
| sx={{ | |||
| p: 2, | |||
| display: 'flex', | |||
| flexDirection: 'column', | |||
| }} | |||
| elevation={5} | |||
| > | |||
| <Typography variant="h4" gutterBottom align="center"> | |||
| Modals Example | |||
| </Typography> | |||
| <Divider /> | |||
| <Button onClick={() => setDialogOpen(true)}>Open Dialog</Button> | |||
| <Button onClick={() => setDrawerOpen(true)}>Open Drawer</Button> | |||
| <Button | |||
| onClick={(e: React.MouseEvent<any>) => { | |||
| setPopoverOpen(true); | |||
| setAnchorEl(e.currentTarget); | |||
| }} | |||
| > | |||
| Open Popover | |||
| </Button> | |||
| <DialogComponent | |||
| title="Dialog Title" | |||
| content={<Typography>Dialog Content</Typography>} | |||
| open={dialogOpen} | |||
| onClose={() => setDialogOpen(false)} | |||
| maxWidth="md" | |||
| fullWidth | |||
| responsive | |||
| /> | |||
| <DrawerComponent | |||
| anchor="left" | |||
| content={<Typography sx={{ p: 2 }}>Drawer Content</Typography>} | |||
| open={drawerOpen} | |||
| toggleOpen={() => setDrawerOpen(!drawerOpen)} | |||
| /> | |||
| <PopoverComponent | |||
| anchorEl={anchorEl} | |||
| open={popoverOpen} | |||
| onClose={() => { | |||
| setPopoverOpen(false); | |||
| setAnchorEl(null); | |||
| }} | |||
| content={<Typography sx={{ p: 2 }}>Popover Content</Typography>} | |||
| /> | |||
| </Paper> | |||
| ); | |||
| }; | |||
| export default Modals; | |||
| @@ -0,0 +1,181 @@ | |||
| import { loadRandomData, updatePage, updateItemsPerPage, updateFilter, updateSort } from '../../../store/features/randomData/randomDataSlice'; | |||
| import React, { useEffect, useState } from 'react'; | |||
| import { | |||
| Paper, | |||
| Box, | |||
| Grid, | |||
| Typography, | |||
| Divider, | |||
| TablePagination, | |||
| TextField, | |||
| FormControl, | |||
| InputLabel, | |||
| Select, | |||
| MenuItem, | |||
| SelectChangeEvent, | |||
| } from '@mui/material'; | |||
| // import { useTranslation } from 'react-i18next'; | |||
| import { useDispatch, useSelector, batch } from 'react-redux'; | |||
| import useDebounce from '../../../hooks/useDebounceHook'; | |||
| import { | |||
| itemsSelector, | |||
| pageSelector, | |||
| itemsPerPageSelector, | |||
| countSelector, | |||
| sortSelector, | |||
| } from '../../../store/selectors/randomDataSelectors'; | |||
| const PagingSortingFilteringExample = () => { | |||
| const [filterText, setFilterText] = useState(''); | |||
| const dispatch = useDispatch(); | |||
| // const { t } = useTranslation(); | |||
| const items = useSelector(itemsSelector); | |||
| const currentPage = useSelector(pageSelector); | |||
| const itemsPerPage = useSelector(itemsPerPageSelector); | |||
| const totalCount = useSelector(countSelector); | |||
| const sort = useSelector(sortSelector) || 'name-asc'; | |||
| console.log(items) | |||
| // Use debounce to prevent too many rerenders | |||
| const debouncedFilterText = useDebounce(filterText, 500); | |||
| useEffect(() => { | |||
| dispatch(loadRandomData(30)); | |||
| dispatch(updateSort(sort)); | |||
| }, []); | |||
| useEffect(() => { | |||
| batch(() => { | |||
| dispatch(updateFilter(filterText)); | |||
| currentPage > 0 && dispatch(updatePage(0)); | |||
| }); | |||
| }, [debouncedFilterText]); | |||
| const handleFilterTextChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { | |||
| const filterText = event.target.value; | |||
| setFilterText(filterText); | |||
| }; | |||
| const handleSortChange = (event: SelectChangeEvent<string>) => { | |||
| const sort = event.target.value; | |||
| dispatch(updateSort(sort)); | |||
| }; | |||
| const handlePageChange = (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null, newPage: number) => { | |||
| dispatch(updatePage(newPage)); | |||
| }; | |||
| const handleItemsPerPageChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { | |||
| const itemsPerPage = parseInt(event.target.value); | |||
| batch(() => { | |||
| dispatch(updateItemsPerPage(itemsPerPage)); | |||
| dispatch(updatePage(0)); | |||
| }); | |||
| }; | |||
| return ( | |||
| <Paper | |||
| sx={{ | |||
| display: 'flex', | |||
| flexDirection: 'column', | |||
| justifyContent: 'start', | |||
| py: 2, | |||
| minHeight: 500, | |||
| }} | |||
| elevation={5} | |||
| > | |||
| <Typography sx={{ my: 4 }} variant="h4" gutterBottom align="center"> | |||
| Pagination, Filtering and Sorting Example Client Side | |||
| </Typography> | |||
| <Box | |||
| sx={{ | |||
| display: 'flex', | |||
| justifyContent: 'space-between', | |||
| flexWrap: 'wrap', | |||
| mx: 2, | |||
| }} | |||
| > | |||
| <Box | |||
| sx={{ | |||
| display: 'flex', | |||
| justifyContent: 'space-between', | |||
| width: '100%', | |||
| }} | |||
| > | |||
| {/* TODO Separate into SelectComponent */} | |||
| <FormControl sx={{ flexGrow: 1 }}> | |||
| <InputLabel id="sort-label">Sort</InputLabel> | |||
| <Select | |||
| label="Sort" | |||
| labelId="sort-label" | |||
| id="sort-select-helper" | |||
| value={sort} | |||
| onChange={handleSortChange} | |||
| > | |||
| <MenuItem value="name-asc">Name - A-Z</MenuItem> | |||
| <MenuItem value="name-desc">Name - Z-A</MenuItem> | |||
| <MenuItem value="price-asc">Price - Lowest to Highest</MenuItem> | |||
| <MenuItem value="price-desc">Price - Highest to Lowest</MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| <TextField | |||
| sx={{ flexGrow: 1 }} | |||
| variant="outlined" | |||
| label="Filter" | |||
| placeholder="Filter" | |||
| value={filterText} | |||
| onChange={handleFilterTextChange} | |||
| /> | |||
| </Box> | |||
| </Box> | |||
| <Grid container> | |||
| {items && | |||
| items.length > 0 && | |||
| items | |||
| .slice( | |||
| currentPage * itemsPerPage, | |||
| currentPage * itemsPerPage + itemsPerPage | |||
| ) | |||
| .map((product, index) => ( | |||
| // ! DON'T USE index for key, this is for example only | |||
| <Grid item sx={{ p: 2 }} xs={12} sm={6} md={4} lg={3} key={index}> | |||
| {/* TODO separate into component */} | |||
| <Paper sx={{ p: 3, height: '100%' }} elevation={3}> | |||
| <Typography sx={{ fontWeight: 600 }}>Name: </Typography> | |||
| <Typography display="inline"> {product.name}</Typography> | |||
| <Divider /> | |||
| <Typography sx={{ fontWeight: 600 }}>Designer: </Typography> | |||
| <Typography display="inline"> {product.designer}</Typography> | |||
| <Divider /> | |||
| <Typography sx={{ fontWeight: 600 }}>Type: </Typography> | |||
| <Typography display="inline"> {product.type}</Typography> | |||
| <Divider /> | |||
| <Typography sx={{ fontWeight: 600 }}>Price: </Typography> | |||
| <Typography display="inline"> ${product.price}</Typography> | |||
| </Paper> | |||
| </Grid> | |||
| ))} | |||
| </Grid> | |||
| <Box sx={{ width: '100%' }}> | |||
| <TablePagination | |||
| component="div" | |||
| count={totalCount} | |||
| page={currentPage} | |||
| onPageChange={handlePageChange} | |||
| rowsPerPage={itemsPerPage} | |||
| onRowsPerPageChange={handleItemsPerPageChange} | |||
| rowsPerPageOptions={[12, 24, 48, 96]} | |||
| labelRowsPerPage="Items per page" | |||
| showFirstButton | |||
| showLastButton | |||
| /> | |||
| </Box> | |||
| </Paper> | |||
| ); | |||
| }; | |||
| export default PagingSortingFilteringExample; | |||
| @@ -0,0 +1,161 @@ | |||
| import React, { useEffect, useState } from 'react'; | |||
| import { | |||
| Paper, | |||
| Box, | |||
| Grid, | |||
| Typography, | |||
| Divider, | |||
| TablePagination, | |||
| TextField, | |||
| FormControl, | |||
| InputLabel, | |||
| Select, | |||
| MenuItem, | |||
| SelectChangeEvent, | |||
| } from '@mui/material'; | |||
| // import { useTranslation } from 'react-i18next'; | |||
| import Backdrop from '../BackdropComponent'; | |||
| import useDebounce from '../../../hooks/useDebounceHook'; | |||
| import { useRandomData } from '../../../context/RandomDataContext'; | |||
| const PagingSortingFilteringExampleServerSide = () => { | |||
| const [filterText, setFilterText] = useState(''); | |||
| const { state, data } = useRandomData(); | |||
| const { items, loading, totalCount, currentPage, itemsPerPage, sort } = data; | |||
| const { setPage, setItemsPerPage, setSort, setFilter } = state; | |||
| // const { t } = useTranslation(); | |||
| // Use debounce to prevent too many rerenders | |||
| const debouncedFilterText = useDebounce(filterText, 500); | |||
| useEffect(() => { | |||
| setFilter(filterText); | |||
| }, [debouncedFilterText]); | |||
| const handleFilterTextChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { | |||
| const filterText = event.target.value; | |||
| setFilterText(filterText); | |||
| }; | |||
| const handleSortChange = (event: SelectChangeEvent<any>) => { | |||
| const sort = event.target.value; | |||
| setSort(sort); | |||
| }; | |||
| const handlePageChange = (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null, newPage: number) => { | |||
| setPage(newPage); | |||
| }; | |||
| const handleItemsPerPageChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { | |||
| const itemsPerPage = parseInt(event.target.value); | |||
| setItemsPerPage(itemsPerPage); | |||
| setPage(0); | |||
| }; | |||
| return ( | |||
| <Paper | |||
| sx={{ | |||
| display: 'flex', | |||
| flexDirection: 'column', | |||
| justifyContent: 'start', | |||
| py: 2, | |||
| minHeight: 500, | |||
| position: 'relative', | |||
| }} | |||
| elevation={5} | |||
| > | |||
| {loading && <Backdrop isLoading position="absolute" />} | |||
| <Typography sx={{ my: 4 }} variant="h4" gutterBottom align="center"> | |||
| Pagination, Filtering and Sorting Example Server Side | |||
| </Typography> | |||
| <Box | |||
| sx={{ | |||
| display: 'flex', | |||
| justifyContent: 'space-between', | |||
| flexWrap: 'wrap', | |||
| mx: 2, | |||
| }} | |||
| > | |||
| <Box | |||
| sx={{ | |||
| display: 'flex', | |||
| justifyContent: 'space-between', | |||
| width: '100%', | |||
| }} | |||
| > | |||
| <FormControl sx={{ flexGrow: 1 }}> | |||
| <InputLabel id="sort-label">Sort</InputLabel> | |||
| <Select | |||
| label="Sort" | |||
| labelId="sort-label" | |||
| id="sort-select-helper" | |||
| value={sort || ''} | |||
| onChange={handleSortChange} | |||
| > | |||
| <MenuItem value="">None</MenuItem> | |||
| <MenuItem value="name-asc">Name - A-Z</MenuItem> | |||
| <MenuItem value="name-desc">Name - Z-A</MenuItem> | |||
| <MenuItem value="price-asc">Price - Lowest to Highest</MenuItem> | |||
| <MenuItem value="price-desc">Price - Highest to Lowest</MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| <TextField | |||
| sx={{ flexGrow: 1 }} | |||
| variant="outlined" | |||
| label="Filter" | |||
| placeholder="Filter" | |||
| value={filterText} | |||
| onChange={handleFilterTextChange} | |||
| /> | |||
| </Box> | |||
| <Grid container sx={{ position: 'relative' }}> | |||
| {items && | |||
| items.length > 0 && | |||
| /* @ts-ignore */ | |||
| items.map((item) => ( | |||
| <Grid | |||
| item | |||
| sx={{ p: 2 }} | |||
| xs={12} | |||
| sm={6} | |||
| md={4} | |||
| lg={3} | |||
| key={item.id} | |||
| > | |||
| {/* TODO separate into component */} | |||
| <Paper sx={{ p: 3, height: '100%' }} elevation={3}> | |||
| <Typography sx={{ fontWeight: 600 }}>Name: </Typography> | |||
| <Typography display="inline"> {item.name}</Typography> | |||
| <Divider /> | |||
| <Typography sx={{ fontWeight: 600 }}>Company: </Typography> | |||
| <Typography display="inline"> {item.company}</Typography> | |||
| <Divider /> | |||
| <Typography sx={{ fontWeight: 600 }}>Color: </Typography> | |||
| <Typography display="inline"> {item.color}</Typography> | |||
| <Divider /> | |||
| <Typography sx={{ fontWeight: 600 }}>Price: </Typography> | |||
| <Typography display="inline"> {item.price}</Typography> | |||
| </Paper> | |||
| </Grid> | |||
| ))} | |||
| </Grid> | |||
| <Box sx={{ width: '100%' }}> | |||
| <TablePagination | |||
| component="div" | |||
| count={totalCount} | |||
| page={currentPage} | |||
| onPageChange={handlePageChange} | |||
| rowsPerPage={itemsPerPage} | |||
| onRowsPerPageChange={handleItemsPerPageChange} | |||
| rowsPerPageOptions={[12, 24, 48, 96]} | |||
| labelRowsPerPage="Items per page" | |||
| showFirstButton | |||
| showLastButton | |||
| /> | |||
| </Box> | |||
| </Box> | |||
| </Paper> | |||
| ); | |||
| }; | |||
| export default PagingSortingFilteringExampleServerSide; | |||
| @@ -0,0 +1,26 @@ | |||
| import React, { useState } from 'react'; | |||
| import { Button, Menu, MenuItem } from '@mui/material'; | |||
| const MenuListComponent: React.FC = () => { | |||
| const [anchorEl, setAnchorEl] = useState(null); | |||
| const open = Boolean(anchorEl); | |||
| const handleClick = (event: React.MouseEvent<any>) => { | |||
| setAnchorEl(event.currentTarget); | |||
| }; | |||
| const handleClose = () => { | |||
| setAnchorEl(null); | |||
| }; | |||
| return ( | |||
| <div> | |||
| <Button onClick={handleClick}>Menu List</Button> | |||
| <Menu id="menu-list" anchorEl={anchorEl} open={open} onClose={handleClose}> | |||
| <MenuItem onClick={handleClose}>Menu Item 1</MenuItem> | |||
| <MenuItem onClick={handleClose}>Menu Item 2</MenuItem> | |||
| <MenuItem onClick={handleClose}>Menu Item 3</MenuItem> | |||
| </Menu> | |||
| </div> | |||
| ); | |||
| }; | |||
| export default MenuListComponent; | |||
| @@ -0,0 +1,162 @@ | |||
| import React, { useState, useMemo, useContext } from 'react'; | |||
| import { | |||
| AppBar, | |||
| Badge, | |||
| Box, | |||
| IconButton, | |||
| Toolbar, | |||
| Typography, | |||
| List, | |||
| ListItem, | |||
| ListItemButton, | |||
| ListItemIcon, | |||
| ListItemText, | |||
| useMediaQuery, | |||
| } from '@mui/material'; | |||
| import { useTheme } from '@mui/system'; | |||
| import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined'; | |||
| import ShoppingBasketIcon from '@mui/icons-material/ShoppingBasket'; | |||
| import Brightness4Icon from '@mui/icons-material/Brightness4'; | |||
| import Brightness7Icon from '@mui/icons-material/Brightness7'; | |||
| import MenuList from './MenuListComponent'; | |||
| import Drawer from './DrawerComponent'; | |||
| import { ColorModeContext } from '../../context/ColorModeContext'; | |||
| const NavbarComponent = () => { | |||
| const [openDrawer, setOpenDrawer] = useState(false); | |||
| const theme = useTheme(); | |||
| const matches = useMediaQuery(theme.breakpoints.down('sm')); | |||
| const toggleColorMode = useContext(ColorModeContext); | |||
| const handleToggleDrawer = () => { | |||
| setOpenDrawer(!openDrawer); | |||
| }; | |||
| const drawerContent = useMemo( | |||
| () => ( | |||
| <List> | |||
| <ListItemButton divider onClick={handleToggleDrawer}> | |||
| <ListItemIcon> | |||
| <ListItemText>Link 1</ListItemText> | |||
| </ListItemIcon> | |||
| </ListItemButton> | |||
| <ListItem divider onClick={handleToggleDrawer}> | |||
| <ListItemIcon> | |||
| <ListItemText>Link 2</ListItemText> | |||
| </ListItemIcon> | |||
| </ListItem> | |||
| <ListItem divider onClick={handleToggleDrawer}> | |||
| <ListItemText>Link 3</ListItemText> | |||
| </ListItem> | |||
| <ListItem divider> | |||
| {/*@ts-ignore*/} | |||
| <IconButton onClick={toggleColorMode}> | |||
| <ListItemText>Toggle {theme.palette.mode} mode</ListItemText> | |||
| {theme.palette.mode === 'dark' ? ( | |||
| <Brightness7Icon /> | |||
| ) : ( | |||
| <Brightness4Icon /> | |||
| )} | |||
| </IconButton> | |||
| </ListItem> | |||
| </List> | |||
| ), | |||
| [handleToggleDrawer] | |||
| ); | |||
| return ( | |||
| <AppBar | |||
| elevation={2} | |||
| sx={{ backgroundColor: 'background.default', position: 'relative' }} | |||
| > | |||
| <Toolbar> | |||
| <Box | |||
| component="div" | |||
| sx={{ | |||
| display: 'flex', | |||
| justifyContent: 'space-between', | |||
| alignItems: 'center', | |||
| width: '100%', | |||
| }} | |||
| > | |||
| {matches ? ( | |||
| <Drawer | |||
| open={openDrawer} | |||
| toggleOpen={handleToggleDrawer} | |||
| content={drawerContent} | |||
| /> | |||
| ) : ( | |||
| <Box sx={{ display: 'flex' }}> | |||
| <Typography | |||
| variant="h6" | |||
| sx={{ | |||
| marginRight: 3, | |||
| cursor: 'pointer', | |||
| color: 'text.primary', | |||
| }} | |||
| > | |||
| Link 1 | |||
| </Typography> | |||
| <Typography | |||
| variant="body1" | |||
| sx={{ | |||
| marginRight: 3, | |||
| cursor: 'pointer', | |||
| color: 'text.primary', | |||
| }} | |||
| > | |||
| Link 2 | |||
| </Typography> | |||
| <Typography | |||
| variant="subtitle1" | |||
| sx={{ | |||
| marginRight: 3, | |||
| cursor: 'pointer', | |||
| color: 'text.primary', | |||
| }} | |||
| > | |||
| Link 3 | |||
| </Typography> | |||
| </Box> | |||
| )} | |||
| <Box> | |||
| <MenuList /> | |||
| </Box> | |||
| <Box | |||
| sx={{ | |||
| display: 'flex', | |||
| justifyContent: 'center', | |||
| alignItems: 'center', | |||
| }} | |||
| > | |||
| {matches ? ( | |||
| <Box> | |||
| <IconButton onClick={handleToggleDrawer}> | |||
| <MenuOutlinedIcon /> | |||
| </IconButton> | |||
| </Box> | |||
| ) : ( | |||
| <Box> | |||
| <IconButton> | |||
| <Badge badgeContent={3} color="primary"> | |||
| <ShoppingBasketIcon color="action" /> | |||
| </Badge> | |||
| </IconButton> | |||
| {/*@ts-ignore*/} | |||
| <IconButton sx={{ ml: 1 }} onClick={toggleColorMode}> | |||
| {theme.palette.mode === 'dark' ? ( | |||
| <Brightness7Icon /> | |||
| ) : ( | |||
| <Brightness4Icon /> | |||
| )} | |||
| </IconButton> | |||
| </Box> | |||
| )} | |||
| </Box> | |||
| </Box> | |||
| </Toolbar> | |||
| </AppBar> | |||
| ); | |||
| }; | |||
| export default NavbarComponent; | |||
| @@ -0,0 +1,34 @@ | |||
| import React from 'react'; | |||
| import { Box, Popover } from '@mui/material'; | |||
| interface PopoverComponentProps { | |||
| open: boolean; | |||
| anchorEl: any; | |||
| onClose: () => void; | |||
| content: any; | |||
| } | |||
| const PopoverComponent: React.FC<PopoverComponentProps> = ({ open, anchorEl, onClose, content }) => { | |||
| const handleClose = () => { | |||
| onClose(); | |||
| }; | |||
| return ( | |||
| <Box component="div"> | |||
| <Popover | |||
| sx={{ p: 5 }} | |||
| open={open} | |||
| anchorEl={anchorEl} | |||
| onClose={handleClose} | |||
| anchorOrigin={{ | |||
| vertical: 'bottom', | |||
| horizontal: 'left', | |||
| }} | |||
| > | |||
| {content} | |||
| </Popover> | |||
| </Box> | |||
| ); | |||
| }; | |||
| export default PopoverComponent; | |||
| @@ -0,0 +1,25 @@ | |||
| import React, { useEffect } from 'react'; | |||
| import { Navigate, Outlet } from 'react-router'; | |||
| import { useDispatch, useSelector } from 'react-redux'; | |||
| import { authenticateUser } from '../../store/features/login/loginSlice'; | |||
| import { selectIsUserAuthenticated } from '../../store/selectors/userSelectors'; | |||
| import { LOGIN_PAGE } from '../../constants/pages'; | |||
| const PrivateRoute = () => { | |||
| const dispatch = useDispatch(); | |||
| const isUserAuthenticated = useSelector(selectIsUserAuthenticated); | |||
| useEffect(() => { | |||
| if (!isUserAuthenticated) { | |||
| dispatch(authenticateUser()); | |||
| } | |||
| }, [isUserAuthenticated]); // eslint-disable-line | |||
| return isUserAuthenticated ? ( | |||
| <Outlet /> | |||
| ) : ( | |||
| <Navigate to={LOGIN_PAGE} /> | |||
| ); | |||
| }; | |||
| export default PrivateRoute; | |||
| @@ -0,0 +1,12 @@ | |||
| import React, { ReactNode } from 'react'; | |||
| interface SectionProps { | |||
| children: ReactNode; | |||
| className?: string; | |||
| } | |||
| const Section: React.FC<SectionProps> = ({ children, className }) => ( | |||
| <section className={`l-section ${className || ''}`}>{children}</section> | |||
| ); | |||
| export default Section; | |||
| @@ -1,18 +0,0 @@ | |||
| import { createContext, FC, ReactNode } from "react"; | |||
| import { ThemeProvider } from "@mui/material/styles"; | |||
| import useToggleColorMode from "../hooks/useToggleColorMode"; | |||
| export const ColorModeContext = createContext({ | |||
| toggleColorMode: () => {}, | |||
| }); | |||
| const ColorModeProvider = ({ children }) => { | |||
| const [toggleColorMode, theme] = useToggleColorMode(); | |||
| return ( | |||
| <ColorModeContext.Provider value={toggleColorMode}> | |||
| <ThemeProvider theme={theme}> {children} </ThemeProvider> | |||
| </ColorModeContext.Provider> | |||
| ); | |||
| }; | |||
| export default ColorModeProvider; | |||
| @@ -0,0 +1,22 @@ | |||
| import React, { createContext } from 'react'; | |||
| import { ThemeProvider } from '@mui/material/styles'; | |||
| import useToggleColorMode from '../hooks/useToggleColorMode'; | |||
| /* @ts-ignore */ | |||
| export const ColorModeContext = createContext(); | |||
| interface Props { | |||
| children: React.ReactNode; | |||
| } | |||
| const ColorModeProvider: React.FC<Props> = ({ children }) => { | |||
| const [toggleColorMode, theme] = useToggleColorMode(); | |||
| return ( | |||
| <ColorModeContext.Provider value={toggleColorMode}> | |||
| {/* @ts-ignore */} | |||
| <ThemeProvider theme={theme}>{children}</ThemeProvider> | |||
| </ColorModeContext.Provider> | |||
| ); | |||
| }; | |||
| export default ColorModeProvider; | |||
| @@ -0,0 +1,63 @@ | |||
| import React, { createContext, useContext, useState } from 'react'; | |||
| import PropTypes from 'prop-types'; | |||
| import usePagingHook from '../hooks/usePagingHook'; | |||
| import { getRequest } from '../request/jsonServerRequest'; | |||
| const apiCall = (page, itemsPerPage, sort, sortDirection, filter) => | |||
| getRequest('/items', { | |||
| _page: page, | |||
| _limit: itemsPerPage, | |||
| // Conditionally add to params object if keys exist | |||
| ...(sort && { _sort: sort }), | |||
| ...(sortDirection && { _order: sortDirection }), | |||
| ...(filter && { q: filter }), | |||
| }); | |||
| const Context = createContext(); | |||
| export const useRandomData = () => useContext(Context); | |||
| const RandomDataProvider = ({ children }) => { | |||
| const setPage = (page) => { | |||
| setState({ ...state, page }); | |||
| }; | |||
| const setItemsPerPage = (itemsPerPage) => { | |||
| setState({ ...state, itemsPerPage }); | |||
| }; | |||
| const setSort = (sort) => { | |||
| setState({ ...state, sort }); | |||
| }; | |||
| const setFilter = (filter) => { | |||
| setState({ ...state, filter }); | |||
| }; | |||
| const [state, setState] = useState({ | |||
| page: 0, | |||
| setPage, | |||
| itemsPerPage: 12, | |||
| setItemsPerPage, | |||
| sort: '', | |||
| setSort, | |||
| filter: '', | |||
| setFilter, | |||
| }); | |||
| const data = usePagingHook( | |||
| state.page, | |||
| state.itemsPerPage, | |||
| state.sort, | |||
| state.filter, | |||
| apiCall | |||
| ); | |||
| return ( | |||
| <Context.Provider value={{ state, data }}>{children}</Context.Provider> | |||
| ); | |||
| }; | |||
| RandomDataProvider.propTypes = { | |||
| children: PropTypes.node, | |||
| }; | |||
| export default RandomDataProvider; | |||
| @@ -1,6 +1,6 @@ | |||
| import { faker } from '@faker-js/faker' | |||
| interface FakeData { | |||
| export interface FakeData { | |||
| id: number; | |||
| name: string; | |||
| color: string; | |||
| @@ -14,10 +14,10 @@ module.exports = () => { | |||
| items.push({ | |||
| id: id, | |||
| name: `${faker.commerce.productAdjective()} ${faker.commerce.productMaterial()} ${faker.commerce.product()}`, | |||
| color: faker.commerce.color(), | |||
| color: faker.color.human(), | |||
| price: `$${faker.commerce.price()}`, | |||
| company: faker.company.companyName(), | |||
| company: faker.company.name(), | |||
| }); | |||
| } | |||
| return { items }; | |||
| return {items} | |||
| }; | |||
| @@ -0,0 +1,72 @@ | |||
| import { AxiosResponse } from 'axios'; | |||
| import { useState, useCallback, useEffect } from 'react'; | |||
| import { unstable_batchedUpdates } from 'react-dom'; | |||
| import {FakeData} from '../db/db'; | |||
| type ApiCall = (page: number, itemsPerPage: number, sortColumn: string, sortDirection: string, filter: string) => AxiosResponse; | |||
| const usePagingHook = (page: number, itemsPerPage: number, sort: string, filter: string, apiCallback: ApiCall) => { | |||
| const [items, setItems] = useState<FakeData[]>([]); | |||
| const [totalPages, setTotalPages] = useState(0); | |||
| const [currentPage, setCurrentPage] = useState(0); | |||
| const [loading, setLoading] = useState(false); | |||
| const [totalCount, setTotalCount] = useState(0); | |||
| const reload = useCallback(async () => { | |||
| setLoading(true); | |||
| try { | |||
| const [sortColumn, sortDirection] = sort.split('-'); | |||
| const response = await apiCallback( | |||
| page, | |||
| itemsPerPage, | |||
| sortColumn, | |||
| sortDirection, | |||
| filter | |||
| ); | |||
| console.log('response',response); | |||
| if (response.status === 200) { | |||
| // Prevents multiple rerenders | |||
| unstable_batchedUpdates(() => { | |||
| setItems(response.data); | |||
| setTotalCount(parseInt(response.data.length)); | |||
| setTotalPages( | |||
| Math.ceil(response.data.length / itemsPerPage) | |||
| ); | |||
| setCurrentPage(page); | |||
| }); | |||
| } | |||
| } catch (e) { | |||
| console.error(e); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }, [ | |||
| setItems, | |||
| setLoading, | |||
| setTotalPages, | |||
| setCurrentPage, | |||
| apiCallback, | |||
| page, | |||
| itemsPerPage, | |||
| sort, | |||
| filter, | |||
| ]); | |||
| useEffect(() => { | |||
| reload(); | |||
| }, [reload]); | |||
| return { | |||
| items, | |||
| loading, | |||
| reload, | |||
| totalCount, | |||
| totalPages, | |||
| currentPage, | |||
| itemsPerPage, | |||
| sort, | |||
| }; | |||
| }; | |||
| export default usePagingHook; | |||
| @@ -7,8 +7,8 @@ import { | |||
| import { PaletteMode } from '@mui/material'; | |||
| const useToggleColorMode = () => { | |||
| const currentColorMode = authScopeStringGetHelper('colorMode') || 'light'; | |||
| const [mode, setMode] = useState<PaletteMode>(currentColorMode); | |||
| const currentColorMode = authScopeStringGetHelper('colorMode') || 'light'; | |||
| const [mode, setMode] = useState(currentColorMode); | |||
| const toggleColorMode = () => { | |||
| const nextMode = mode === 'light' ? 'dark' : 'light'; | |||
| @@ -20,7 +20,7 @@ const useToggleColorMode = () => { | |||
| () => | |||
| createTheme({ | |||
| palette: { | |||
| mode | |||
| mode: mode as PaletteMode | |||
| } | |||
| }), | |||
| [mode] | |||
| @@ -86,8 +86,12 @@ export default { | |||
| goBack: 'Go back to homepage', | |||
| logout: 'Logout', | |||
| }, | |||
| apiErrors: { | |||
| ClientIpAddressIsNullOrEmpty: "Client Ip address is null or empty", | |||
| UsernameDoesNotExist: "Username does not exist" | |||
| } | |||
| apiErrors:{ | |||
| ClientIpAddressIsNullOrEmpty:"Client Ip address is null or empty", | |||
| UsernameDoesNotExist: "Username does not exist", | |||
| WrongCredentials: "Wrong credentials", | |||
| SomethingWentWrong: "Something went wrong", | |||
| WrongPasswordAccountIsLocked: "Wrong credentials, account is locked", | |||
| AccountIsLocked: "Account is locked" | |||
| } | |||
| }; | |||
| @@ -5,6 +5,8 @@ import App from './App'; | |||
| import { HelmetProvider } from 'react-helmet-async'; | |||
| import './i18n'; | |||
| import ColorModeProvider from './context/ColorModeContext'; | |||
| import { Provider } from 'react-redux'; | |||
| import {store} from './store'; | |||
| const root = ReactDOM.createRoot( | |||
| document.getElementById('root') as HTMLElement | |||
| @@ -12,9 +14,11 @@ const root = ReactDOM.createRoot( | |||
| root.render( | |||
| <HelmetProvider> | |||
| <React.StrictMode> | |||
| <Provider store={store}> | |||
| <ColorModeProvider> | |||
| <App /> | |||
| </ColorModeProvider> | |||
| </Provider> | |||
| </React.StrictMode> | |||
| </HelmetProvider> | |||
| ); | |||
| @@ -0,0 +1,16 @@ | |||
| import { useTranslation } from 'react-i18next'; | |||
| const ErrorPage = () => { | |||
| const { t } = useTranslation(); | |||
| return ( | |||
| <div className="c-error-page"> | |||
| <div className="c-error-page__content"> | |||
| <h1 className="c-error-page__title">500</h1> | |||
| <p className="c-error-page__text">{t('errorPage.text')}</p> | |||
| </div> | |||
| </div> | |||
| ); | |||
| }; | |||
| export default ErrorPage; | |||
| @@ -0,0 +1,31 @@ | |||
| import { useTranslation } from 'react-i18next'; | |||
| import Button from '../../components/Button/Button'; | |||
| import Section from '../../components/Section/Section'; | |||
| import { useNavigate } from "react-router-dom"; | |||
| import { HOME_PAGE } from '../../constants/pages'; | |||
| const NotFoundPage = () => { | |||
| const { t } = useTranslation(); | |||
| const navigate = useNavigate(); | |||
| return ( | |||
| <div className="c-error-page"> | |||
| <Section className="c-error-page__content-container"> | |||
| <div className="c-error-page__content"> | |||
| <h1 className="c-error-page__title">404</h1> | |||
| <p className="c-error-page__text">{t('notFound.text')}</p> | |||
| <Button | |||
| className="c-error-page__button" | |||
| variant="primary-outlined" | |||
| onClick={() => navigate(HOME_PAGE)} | |||
| > | |||
| {t('notFound.goBack')} | |||
| </Button> | |||
| </div> | |||
| </Section> | |||
| </div> | |||
| ); | |||
| }; | |||
| export default NotFoundPage; | |||
| @@ -0,0 +1,63 @@ | |||
| import React from 'react'; | |||
| import { Formik, Form, Field } from 'formik'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import * as Yup from 'yup'; | |||
| import i18next from 'i18next'; | |||
| import Auth from '../../components/Auth/Auth'; | |||
| import AuthCard from '../../components/AuthCards/AuthCard'; | |||
| import Section from '../../components/Section/Section'; | |||
| import Button from '../../components/Button/Button'; | |||
| import TextField from '../../components/InputFields/TextField'; | |||
| const forgotPasswordValidationSchema = Yup.object().shape({ | |||
| email: Yup.string().required('Email is a required field!'), | |||
| }); | |||
| const ForgotPasswordPage = () => { | |||
| const { t } = useTranslation(); | |||
| const handleSubmit = (values: any) => { | |||
| console.log("Values",values) | |||
| }; | |||
| return ( | |||
| <Auth> | |||
| <AuthCard | |||
| title={t('forgotPassword.title')} | |||
| > | |||
| <Section> | |||
| <div className="c-reset-security"> | |||
| <div className="c-reset-security__form"> | |||
| <Formik | |||
| onSubmit={handleSubmit} | |||
| initialValues={{ email: '' }} | |||
| validationSchema={forgotPasswordValidationSchema} | |||
| > | |||
| <Form> | |||
| <Field | |||
| label={t('login.forgotPasswordEmail')} | |||
| name="email" | |||
| component={TextField} | |||
| /> | |||
| <Button | |||
| className="c-reset-security__button" | |||
| authButton | |||
| variant="primary" | |||
| type="submit" | |||
| > | |||
| {t('forgotPassword.label')} | |||
| </Button> | |||
| </Form> | |||
| </Formik> | |||
| </div> | |||
| </div> | |||
| </Section> | |||
| </AuthCard> | |||
| </Auth> | |||
| ); | |||
| }; | |||
| export default ForgotPasswordPage; | |||
| @@ -0,0 +1,13 @@ | |||
| import React from 'react'; | |||
| const HomePage = () => { | |||
| return ( | |||
| <div className="c-error-page"> | |||
| <div className="c-error-page__content"> | |||
| <h1 className="c-error-page__title">Home page</h1> | |||
| </div> | |||
| </div> | |||
| ); | |||
| }; | |||
| export default HomePage; | |||
| @@ -0,0 +1,37 @@ | |||
| import React from 'react'; | |||
| import { Box, Grid } from '@mui/material'; | |||
| import Modals from '../../components/MUI/Examples/ModalsExample'; | |||
| import DataGrid from '../../components/MUI/Examples/DataGridExample'; | |||
| import PagingSortingFiltering from '../../components/MUI/Examples/PagingSortingFilteringExample'; | |||
| import PagingSortingFilteringServerSide from '../../components/MUI/Examples/PagingSortingFilteringExampleServerSide'; | |||
| import RandomDataProvider from '../../context/RandomDataContext'; | |||
| import NavbarComponent from '../../components/MUI/NavbarComponent'; | |||
| const HomePage = () => { | |||
| return ( | |||
| <> | |||
| <NavbarComponent /> | |||
| <Box sx={{ mt: 4, mx: 4 }}> | |||
| <Grid container spacing={2} justifyContent="center"> | |||
| <Grid item xs={12} md={3}> | |||
| <Modals /> | |||
| </Grid> | |||
| <Grid item xs={12} md={6}> | |||
| <DataGrid /> | |||
| </Grid> | |||
| <Grid item xs={12} md={9}> | |||
| <PagingSortingFiltering /> | |||
| </Grid> | |||
| <Grid item xs={12} md={9}> | |||
| {/* Move to higher components? */} | |||
| <RandomDataProvider> | |||
| <PagingSortingFilteringServerSide /> | |||
| </RandomDataProvider> | |||
| </Grid> | |||
| </Grid> | |||
| </Box> | |||
| </> | |||
| ); | |||
| }; | |||
| export default HomePage; | |||
| @@ -0,0 +1,178 @@ | |||
| import React, { useState } from "react"; | |||
| import { useFormik } from "formik"; | |||
| import { useDispatch, useSelector } from "react-redux"; | |||
| import { NavLink } from "react-router-dom"; | |||
| import * as Yup from "yup"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { FORGOT_PASSWORD_PAGE, HOME_PAGE } from "../../constants/pages"; | |||
| import { | |||
| Box, | |||
| Button, | |||
| Container, | |||
| Grid, | |||
| IconButton, | |||
| InputAdornment, | |||
| Link, | |||
| TextField, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { Visibility, VisibilityOff } from "@mui/icons-material"; | |||
| import Backdrop from "../../components/MUI/BackdropComponent"; | |||
| import ErrorMessage from "../../components/MUI/ErrorMessageComponent"; | |||
| import { clearLoginErrors, fetchUserData } from "../../store/features/login/loginSlice"; | |||
| import {useNavigate} from 'react-router-dom'; | |||
| import { selectLoginError, selectLoginLoading } from "../../store/selectors/loginSelectors"; | |||
| const LoginPage = () => { | |||
| const dispatch = useDispatch(); | |||
| const { t } = useTranslation(); | |||
| const error = useSelector(selectLoginError); | |||
| const history = useNavigate(); | |||
| const [showPassword, setShowPassword] = useState(false); | |||
| const handleClickShowPassword = () => setShowPassword(!showPassword); | |||
| const handleMouseDownPassword = () => setShowPassword(!showPassword); | |||
| // When user refreshes page | |||
| // useEffect(() => { | |||
| // function redirectClient() { | |||
| // if (!tokens.RefreshToken && !tokens.JwtToken) { | |||
| // return; | |||
| // } | |||
| // } | |||
| // redirectClient(); | |||
| // }, [history, tokens]); | |||
| const isLoading = useSelector(selectLoginLoading); | |||
| const handleApiResponseSuccess = () => { | |||
| history(HOME_PAGE); | |||
| }; | |||
| type SubmitValues = { | |||
| username: string; | |||
| password: string; | |||
| } | |||
| const handleSubmit = (values: SubmitValues) => { | |||
| const { username: Username, password: Password } = values; | |||
| dispatch(clearLoginErrors()); | |||
| dispatch(fetchUserData({ Username, Password, handleApiResponseSuccess })); | |||
| }; | |||
| const formik = useFormik({ | |||
| initialValues: { | |||
| username: "", | |||
| password: "", | |||
| }, | |||
| validationSchema: Yup.object().shape({ | |||
| username: Yup.string().required('Username is required'), | |||
| password: Yup.string().required('Passwor is required'), | |||
| }), | |||
| onSubmit: handleSubmit, | |||
| validateOnBlur: true, | |||
| enableReinitialize: true, | |||
| }); | |||
| return ( | |||
| <Container component="main" maxWidth="md"> | |||
| <Box | |||
| sx={{ | |||
| marginTop: 32, | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| alignItems: "center", | |||
| }} | |||
| > | |||
| <Typography component="h1" variant="h5"> | |||
| {t("login.logInTitle")} | |||
| </Typography> | |||
| {error && <ErrorMessage error={error} />} | |||
| <Box | |||
| component="form" | |||
| onSubmit={formik.handleSubmit} | |||
| sx={{ position: "relative", mt: 1, p: 1 }} | |||
| > | |||
| <Backdrop position="absolute" isLoading={isLoading} /> | |||
| <TextField | |||
| name="username" | |||
| label={t("common.labelUsername")} | |||
| margin="normal" | |||
| value={formik.values.username} | |||
| onChange={formik.handleChange} | |||
| error={formik.touched.username && Boolean(formik.errors.username)} | |||
| helperText={formik.touched.username && formik.errors.username} | |||
| autoFocus | |||
| fullWidth | |||
| /> | |||
| <TextField | |||
| name="password" | |||
| label={t("common.labelPassword")} | |||
| margin="normal" | |||
| type={showPassword ? "text" : "password"} | |||
| value={formik.values.password} | |||
| onChange={formik.handleChange} | |||
| error={formik.touched.password && Boolean(formik.errors.password)} | |||
| helperText={formik.touched.password && formik.errors.password} | |||
| fullWidth | |||
| InputProps={{ | |||
| endAdornment: ( | |||
| <InputAdornment position="end"> | |||
| <IconButton | |||
| onClick={handleClickShowPassword} | |||
| onMouseDown={handleMouseDownPassword} | |||
| > | |||
| {showPassword ? <Visibility /> : <VisibilityOff />} | |||
| </IconButton> | |||
| </InputAdornment> | |||
| ), | |||
| }} | |||
| /> | |||
| <Button | |||
| type="submit" | |||
| variant="contained" | |||
| sx={{ mt: 3, mb: 2 }} | |||
| fullWidth | |||
| > | |||
| {t("login.logIn")} | |||
| </Button> | |||
| <Grid container> | |||
| <Grid | |||
| item | |||
| xs={12} | |||
| md={6} | |||
| sx={{ textAlign: { xs: "center", md: "left" } }} | |||
| > | |||
| <Link | |||
| to={FORGOT_PASSWORD_PAGE} | |||
| component={NavLink} | |||
| variant="body2" | |||
| underline="hover" | |||
| > | |||
| {t("login.forgotYourPassword")} | |||
| </Link> | |||
| </Grid> | |||
| <Grid | |||
| item | |||
| xs={12} | |||
| md={6} | |||
| sx={{ textAlign: { xs: "center", md: "right" } }} | |||
| > | |||
| <Link | |||
| to="#" | |||
| component={NavLink} | |||
| variant="body2" | |||
| underline="hover" | |||
| > | |||
| {t("login.dontHaveAccount")} | |||
| </Link> | |||
| </Grid> | |||
| </Grid> | |||
| </Box> | |||
| </Box> | |||
| </Container> | |||
| ); | |||
| }; | |||
| export default LoginPage; | |||
| @@ -1,4 +1,5 @@ | |||
| import axios from 'axios'; | |||
| import { FakeData } from '../db/db'; | |||
| const JSON_SERVER_ENDPOINT = 'http://localhost:4000'; | |||
| @@ -10,4 +11,4 @@ const request = axios.create({ | |||
| }); | |||
| export const getRequest = (url: string, params = null, options = {}) => | |||
| request.get(url, { params, ...options }); | |||
| request.get<FakeData[]>(url, { params, ...options }); | |||
| @@ -6,7 +6,7 @@ export const getUsernames = (emailorusername: string) => | |||
| emailorusername, | |||
| }); | |||
| export const attemptLogin = (payload: string) => | |||
| export const attemptLogin = (payload: {}) => | |||
| postRequest(apiEndpoints.authentications.login, payload); | |||
| export const updateSecurityAnswer = (payload: any) => | |||
| @@ -0,0 +1,22 @@ | |||
| import { createSlice } from '@reduxjs/toolkit'; | |||
| import type { PayloadAction } from '@reduxjs/toolkit'; | |||
| export interface AppState { | |||
| } | |||
| const initialState: AppState = { | |||
| } | |||
| export const appSlice = createSlice({ | |||
| name: 'app', | |||
| initialState, | |||
| reducers: { | |||
| appLoading: () => {}, | |||
| setAppReady: () => {} | |||
| } | |||
| }); | |||
| export const { setAppReady, appLoading } = appSlice.actions; | |||
| export default appSlice.reducer; | |||
| @@ -0,0 +1,70 @@ | |||
| import { createSlice } from '@reduxjs/toolkit'; | |||
| import type { PayloadAction } from '@reduxjs/toolkit'; | |||
| import { boolean } from 'yup/lib/locale'; | |||
| export interface LoginState { | |||
| email: string; | |||
| token: { | |||
| RefreshToken: string; | |||
| JwtToken: string; | |||
| }; | |||
| logging: boolean; | |||
| errorMessage: string; | |||
| } | |||
| const initialState: LoginState = { | |||
| email: '', | |||
| token: { | |||
| RefreshToken: '', | |||
| JwtToken: '' | |||
| }, | |||
| logging: false, | |||
| errorMessage: '' | |||
| } | |||
| interface SetUserPayload { | |||
| JwtToken: string, | |||
| RefreshToken: string; | |||
| } | |||
| export interface LoginPayload { | |||
| Username: string; | |||
| Password: string; | |||
| handleApiResponseSuccess: () => void; | |||
| } | |||
| export const loginSlice = createSlice({ | |||
| name: 'login', | |||
| initialState, | |||
| reducers: { | |||
| fetchUserData: (state, action: PayloadAction<LoginPayload>) => { | |||
| state.logging = true; | |||
| }, | |||
| setToken: (state, action: PayloadAction<SetUserPayload>) => { | |||
| state.token = action.payload; | |||
| state.logging = false; | |||
| }, | |||
| setUserJwtToken: (state, action: PayloadAction<string>) => { | |||
| state.token.JwtToken = action.payload; | |||
| state.logging = false; | |||
| }, | |||
| logoutUser: () => {}, | |||
| authenticateUser: () => {}, | |||
| setError: (state, action: PayloadAction<string>) => { | |||
| state.errorMessage = action.payload; | |||
| state.logging = false; | |||
| }, | |||
| generateToken: () => {}, | |||
| refreshUserToken: () => {}, | |||
| resetLoginState: () => { return initialState }, | |||
| clearLoginErrors: (state) => { | |||
| state.errorMessage = ''; | |||
| }, | |||
| } | |||
| }); | |||
| export const loginActions = loginSlice.actions; | |||
| export const {fetchUserData, setToken,authenticateUser, generateToken, setUserJwtToken, refreshUserToken, logoutUser, setError, resetLoginState, clearLoginErrors } = loginSlice.actions; | |||
| export default loginSlice.reducer; | |||
| @@ -0,0 +1,92 @@ | |||
| import { createSlice } from '@reduxjs/toolkit'; | |||
| import type { PayloadAction } from '@reduxjs/toolkit'; | |||
| import generate from '../../../util/helpers/randomData'; | |||
| export interface Item { | |||
| name: string; | |||
| color: string; | |||
| size: string; | |||
| price: string; | |||
| designer: string; | |||
| type: string; | |||
| salesPrice: string; | |||
| } | |||
| export interface RandomDataState { | |||
| items: Item[]; | |||
| filteredItems: Item[]; | |||
| count: number; | |||
| page: number; | |||
| itemsPerPage: number; | |||
| filter: string, | |||
| sort: string; | |||
| } | |||
| const initialState: RandomDataState = { | |||
| items: [], | |||
| filteredItems: [], | |||
| count: 0, | |||
| page: 0, | |||
| itemsPerPage: 12, | |||
| filter: '', | |||
| sort: 'name-asc' | |||
| } | |||
| export const randomDataSlice = createSlice({ | |||
| name: 'randomData', | |||
| initialState, | |||
| reducers: { | |||
| loadRandomData: (state, action: PayloadAction<number>) => { | |||
| const count = action.payload; | |||
| const items = generate(count); | |||
| state.items = items; | |||
| state.filteredItems = items; | |||
| state.count = items.length; | |||
| }, | |||
| updatePage: (state, action: PayloadAction<number>) => { | |||
| const page = action.payload; | |||
| state.page = page; | |||
| }, | |||
| updateItemsPerPage: (state, action: PayloadAction<number>) => { | |||
| const itemsPerPage = action.payload; | |||
| state.itemsPerPage = itemsPerPage; | |||
| }, | |||
| updateFilter: (state, action: PayloadAction<string>) => { | |||
| const filter = action.payload; | |||
| const filteredItems = filter | |||
| ? state.items.filter((item) => item.name.toLowerCase().includes(filter.toLowerCase())) : state.items; | |||
| state.filter = filter; | |||
| state.filteredItems = filteredItems; | |||
| state.count = filteredItems.length; | |||
| }, | |||
| updateSort: (state, action: PayloadAction<string>) => { | |||
| const sort = action.payload; | |||
| const [field, direction] = sort.split('-'); | |||
| const sortDirection = direction === 'asc' ? 1 : -1; | |||
| const dataItems = state.filteredItems.length ? state.filteredItems : state.items; | |||
| const sorted = dataItems.sort((a: any, b: any) => { | |||
| if (a[field] > b[field]) { | |||
| return sortDirection; | |||
| } | |||
| if (b[field] > a[field]) { | |||
| return sortDirection * -1; | |||
| } | |||
| return 0; | |||
| }); | |||
| const filteredItems = state.filteredItems.length ? sorted : state.filteredItems; | |||
| state.sort = sort; | |||
| state.filteredItems = filteredItems; | |||
| }, | |||
| } | |||
| }); | |||
| export const { loadRandomData, updatePage, updateItemsPerPage, updateFilter, updateSort } = randomDataSlice.actions; | |||
| export default randomDataSlice.reducer; | |||
| @@ -0,0 +1,42 @@ | |||
| import { createSlice } from '@reduxjs/toolkit'; | |||
| import type { PayloadAction } from '@reduxjs/toolkit'; | |||
| export interface User { | |||
| UserUid: string; | |||
| CustomerUid: string; | |||
| Email: string; | |||
| Username: string; | |||
| StateType: string; | |||
| SecurityQuestionType: string; | |||
| FirstName: string; | |||
| LastName: string; | |||
| MiddleName: string; | |||
| isRequired2FA: string; | |||
| } | |||
| export interface UserState { | |||
| user: User | {}; | |||
| errorMessage: string; | |||
| } | |||
| const initialState: UserState = { | |||
| user: {}, | |||
| errorMessage: '' | |||
| } | |||
| export const userSlice = createSlice({ | |||
| name: 'user', | |||
| initialState, | |||
| reducers: { | |||
| setUser: (state, action: PayloadAction<User>) => { | |||
| state.user = action.payload; | |||
| }, | |||
| setUserError: (state, action: PayloadAction<string>) => { | |||
| state.errorMessage = action.payload; | |||
| } | |||
| } | |||
| }); | |||
| export const { setUser, setUserError } = userSlice.actions; | |||
| export default userSlice.reducer; | |||
| @@ -0,0 +1,26 @@ | |||
| import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit'; | |||
| import loginReducer from './features/login/loginSlice'; | |||
| import randomDataReducer from './features/randomData/randomDataSlice'; | |||
| import userReducer from './features/user/userSlice'; | |||
| import createSagaMiddleware from 'redux-saga'; | |||
| const saga = createSagaMiddleware(); | |||
| import rootSaga from './saga'; | |||
| import internalServerErrorMiddleware from './middleware/internalServerErrorMiddleware'; | |||
| import requestStatusMiddleware from './middleware/requestStatusMiddleware'; | |||
| export const store = configureStore({ | |||
| reducer: { | |||
| login: loginReducer, | |||
| randomData: randomDataReducer, | |||
| user: userReducer, | |||
| }, | |||
| middleware: [...getDefaultMiddleware({thunk: false}), saga, requestStatusMiddleware, internalServerErrorMiddleware] | |||
| }) | |||
| saga.run(rootSaga); | |||
| // Infer the `RootState` and `AppDispatch` types from the store itself | |||
| export type RootState = ReturnType<typeof store.getState> | |||
| // Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState} | |||
| export type AppDispatch = typeof store.dispatch | |||
| @@ -0,0 +1,18 @@ | |||
| import { ERROR_PAGE } from '../../constants/pages'; | |||
| import { attachPostRequestListener } from '../../request'; | |||
| // import history from '../utils/history'; | |||
| export default () => (next: any) => (action: any) => { | |||
| attachPostRequestListener((error) => { | |||
| if (!error.response) { | |||
| return Promise.reject(error); | |||
| } | |||
| if (error.response.status === 500) { | |||
| // return history.push(ERROR_PAGE); | |||
| console.log('ERROR'); | |||
| } | |||
| return Promise.reject(error); | |||
| }); | |||
| next(action); | |||
| }; | |||
| @@ -0,0 +1,22 @@ | |||
| import { attachPostRequestListener } from '../../request'; | |||
| import apiEndpoints from '../../request/apiEndpoints'; | |||
| import { logoutUser } from '../features/login/loginSlice'; | |||
| export default ({ dispatch }: {dispatch: any}) => (next: any) => (action: any) => { | |||
| attachPostRequestListener((error) => { | |||
| if (!error.response) { | |||
| return Promise.reject(error); | |||
| } | |||
| if ( | |||
| error.response.config.url !== apiEndpoints.authentications.login && | |||
| error.response.config.url !== | |||
| apiEndpoints.authentications.confirmSecurityQuestion && | |||
| error.response.status === 401 | |||
| ) { | |||
| return dispatch(logoutUser()); | |||
| } | |||
| return Promise.reject(error); | |||
| }); | |||
| next(action); | |||
| }; | |||
| @@ -0,0 +1,9 @@ | |||
| import { all } from 'redux-saga/effects'; | |||
| import loginSaga from './loginSaga'; | |||
| export default function* rootSaga() { | |||
| yield all([ | |||
| loginSaga(), | |||
| ]); | |||
| } | |||
| @@ -0,0 +1,152 @@ | |||
| import { all, call, put, takeLatest } from '@redux-saga/core/effects'; | |||
| import { decodeToken } from 'react-jwt'; | |||
| import { | |||
| attemptLogin, generateTokenRequest, logoutUserRequest, refreshTokenRequest | |||
| } from '../../request/loginRequest'; | |||
| import { loginActions, LoginPayload, resetLoginState, setError, setToken, setUserJwtToken } from '../features/login/loginSlice'; | |||
| import { | |||
| authScopeClearHelper, | |||
| authScopeRemoveHelper, | |||
| authScopeSetHelper, authScopeStringGetHelper, | |||
| } from '../../util/helpers/authScopeHelpers'; | |||
| import { | |||
| JWT_REFRESH_TOKEN, | |||
| JWT_TOKEN, | |||
| REFRESH_TOKEN_CONST, | |||
| } from '../../constants/localStorage'; | |||
| import { | |||
| addHeaderToken, removeHeaderToken, | |||
| } from '../../request'; | |||
| import { setUser, User } from '../features/user/userSlice'; | |||
| import { rejectErrorCodeHelper } from '../../util/helpers/rejectErrorCodeHelper'; | |||
| //import { LOGIN_PAGE } from '../../constants/pages'; | |||
| interface Payload { | |||
| payload: LoginPayload; | |||
| } | |||
| function* fetchUser({ payload}: Payload) { | |||
| try { | |||
| const { data } = yield call(attemptLogin, payload); | |||
| if (data.JwtToken) { | |||
| const user = decodeToken(data.JwtToken); | |||
| yield call(authScopeSetHelper, JWT_TOKEN, data.JwtToken); | |||
| yield call(authScopeSetHelper, JWT_REFRESH_TOKEN, data.JwtRefreshToken); | |||
| yield call(authScopeSetHelper, REFRESH_TOKEN_CONST, data.RefreshToken); | |||
| yield call(addHeaderToken, data.JwtToken); | |||
| yield put(setUser(user as User)); | |||
| } | |||
| yield put(setToken(data)); | |||
| if (payload.handleApiResponseSuccess) { | |||
| yield call(payload.handleApiResponseSuccess); | |||
| } | |||
| } catch (e: any) { | |||
| if (e.response && e.response.data) { | |||
| if (payload.handleApiResponseSuccess) { | |||
| yield call(payload.handleApiResponseSuccess); | |||
| } | |||
| const errorMessage: string = yield call(rejectErrorCodeHelper, e); | |||
| console.log('error message', errorMessage); | |||
| yield put(setError(errorMessage)); | |||
| } | |||
| } | |||
| } | |||
| function* authenticateUser() { | |||
| try { | |||
| const JwtToken: string = yield call(authScopeStringGetHelper, JWT_TOKEN); | |||
| if (!JwtToken) { | |||
| //yield call(history, LOGIN_PAGE); | |||
| console.log('No JWT Token'); | |||
| } | |||
| yield put(setUserJwtToken(JwtToken)); | |||
| } catch (error: any) { | |||
| const errorMessage: string = yield call(rejectErrorCodeHelper, error); | |||
| yield put(setError(errorMessage)); | |||
| yield call(authScopeRemoveHelper, JWT_TOKEN); | |||
| yield call(authScopeRemoveHelper, JWT_REFRESH_TOKEN); | |||
| yield call(authScopeRemoveHelper, REFRESH_TOKEN_CONST); | |||
| } | |||
| } | |||
| function* logoutUser () { | |||
| try { | |||
| const JwtToken: string = yield call(authScopeStringGetHelper, JWT_TOKEN); | |||
| const user = decodeToken(JwtToken) as User; | |||
| if (user) { | |||
| yield call(logoutUserRequest, user.UserUid); | |||
| } | |||
| } catch (error) { | |||
| console.log(error); // eslint-disable-line | |||
| } finally { | |||
| yield call(authScopeClearHelper); | |||
| yield call(removeHeaderToken); | |||
| yield put(resetLoginState()); | |||
| //yield call(history, LOGIN_PAGE); | |||
| } | |||
| } | |||
| export function* refreshToken() { | |||
| try { | |||
| const JwtToken: string = yield call(authScopeStringGetHelper, JWT_TOKEN); | |||
| const JwtRefreshToken: string = yield call( | |||
| authScopeStringGetHelper, | |||
| JWT_REFRESH_TOKEN, | |||
| ); | |||
| if (JwtToken && JwtRefreshToken) { | |||
| const { data } = yield call(refreshTokenRequest, { | |||
| JwtRefreshToken, | |||
| JwtToken, | |||
| }); | |||
| yield call(authScopeSetHelper, JWT_TOKEN, data.JwtToken); | |||
| yield call(authScopeSetHelper, JWT_REFRESH_TOKEN, data.JwtRefreshToken); | |||
| const user = decodeToken(data.JwtToken); | |||
| addHeaderToken(data.JwtToken); | |||
| yield put(setUser(user as User)); | |||
| yield put(setUserJwtToken(data.JwtToken)); | |||
| } | |||
| } catch (error) { | |||
| yield call(logoutUser); | |||
| console.log(error); // eslint-disable-line | |||
| } | |||
| } | |||
| export function* generateToken({ payload } : any) { | |||
| try { | |||
| const { data } = yield call(generateTokenRequest, payload.data); | |||
| const { JwtToken, JwtRefreshToken } = data; | |||
| if (JwtToken && JwtRefreshToken) { | |||
| yield call(authScopeSetHelper, JWT_TOKEN, data.JwtToken); | |||
| yield call(authScopeSetHelper, JWT_REFRESH_TOKEN, data.JwtRefreshToken); | |||
| const user = decodeToken(data.JwtToken); | |||
| addHeaderToken(data.JwtToken); | |||
| if (user) { | |||
| yield put(setUser(user as User)); | |||
| } | |||
| yield put(setUserJwtToken(data.JwtToken)); | |||
| if (payload.onSuccess) { | |||
| yield call(payload.onSuccess); | |||
| } | |||
| } | |||
| } catch (error) { | |||
| yield call(logoutUser); | |||
| console.log(error); // eslint-disable-line | |||
| } | |||
| } | |||
| export default function* loginSaga() { | |||
| yield all([ | |||
| takeLatest(loginActions.fetchUserData, fetchUser), | |||
| takeLatest(loginActions.authenticateUser, authenticateUser), | |||
| takeLatest(loginActions.logoutUser, logoutUser), | |||
| takeLatest(loginActions.refreshUserToken, refreshToken), | |||
| takeLatest(loginActions.generateToken, generateToken) | |||
| ]); | |||
| } | |||
| @@ -0,0 +1,16 @@ | |||
| import { createSelector } from 'reselect'; | |||
| import { JWT_TOKEN } from '../../constants/localStorage'; | |||
| import type { RootState } from '../../store/index'; | |||
| import { authScopeStringGetHelper } from '../../util/helpers/authScopeHelpers'; | |||
| const loginSelector = (state: RootState) => state.login; | |||
| export const selectLoginLoading = createSelector(loginSelector, (state) => state.logging); | |||
| export const selectLoginEmail = createSelector(loginSelector, (state) => state.email); | |||
| export const selectTokens = createSelector(loginSelector, (state) => state.token); | |||
| export const selectJWTToken = createSelector(loginSelector, (state) => state.token.JwtToken || authScopeStringGetHelper(JWT_TOKEN)); | |||
| export const selectLoginError = createSelector(loginSelector, (state) => state.errorMessage); | |||
| @@ -0,0 +1,33 @@ | |||
| import { createSelector } from 'reselect'; | |||
| import type { RootState } from '../../store/index'; | |||
| const randomDataSelector = (state: RootState) => state.randomData; | |||
| export const itemsSelector = createSelector(randomDataSelector, (state) => | |||
| (state.filter) ? state.filteredItems : state.items | |||
| ); | |||
| export const pageSelector = createSelector( | |||
| randomDataSelector, | |||
| (state) => state.page | |||
| ); | |||
| export const itemsPerPageSelector = createSelector( | |||
| randomDataSelector, | |||
| (state) => state.itemsPerPage | |||
| ); | |||
| export const countSelector = createSelector( | |||
| randomDataSelector, | |||
| (state) => state.count | |||
| ); | |||
| export const filterSelector = createSelector( | |||
| randomDataSelector, | |||
| (state) => state.filter | |||
| ); | |||
| export const sortSelector = createSelector( | |||
| randomDataSelector, | |||
| (state) => state.sort | |||
| ); | |||
| @@ -0,0 +1,32 @@ | |||
| import { createSelector } from 'reselect'; | |||
| import isEmpty from 'lodash.isempty'; | |||
| import { authScopeStringGetHelper } from '../../util/helpers/authScopeHelpers'; | |||
| import { JWT_TOKEN } from '../../constants/localStorage'; | |||
| import type { RootState } from '../../store/index'; | |||
| export const userSelector = (state: RootState) => state.user; | |||
| export const selectAuthUser = createSelector( | |||
| userSelector, | |||
| (state) => state.user, | |||
| ); | |||
| export const selectIsUserAuthenticated = createSelector( | |||
| selectAuthUser, | |||
| (state) => !isEmpty(state) || authScopeStringGetHelper(JWT_TOKEN), | |||
| ); | |||
| export const getForgotPasswordRequest = createSelector( | |||
| userSelector, | |||
| (state) => state.user, | |||
| ); | |||
| export const selectForgotPasswordError = createSelector( | |||
| userSelector, | |||
| (state) => state.errorMessage, | |||
| ); | |||
| export const getResetPasswordRequest = createSelector( | |||
| userSelector, | |||
| (state) => state.user, | |||
| ); | |||
| @@ -0,0 +1,3 @@ | |||
| import { createBrowserHistory } from 'history'; | |||
| export default createBrowserHistory(); | |||
| @@ -0,0 +1,6 @@ | |||
| import Numeral from 'numeral'; | |||
| export function formatMoneyNumeral(value: number, formatString: string = '$0,0.00') { | |||
| const isNegativeValue = value < 0; | |||
| return `${isNegativeValue ? `(` : ''}${Numeral(value).format(formatString)}${isNegativeValue ? `)` : ''}` | |||
| } | |||
| @@ -1,7 +1,7 @@ | |||
| import i18next from 'i18next'; | |||
| import { AxiosError } from 'axios'; | |||
| interface ErrorResponse { | |||
| export interface ErrorResponse { | |||
| Errors: { Code: string }[] | |||
| } | |||
| @@ -14,7 +14,7 @@ | |||
| "forceConsistentCasingInFileNames": true, | |||
| "noFallthroughCasesInSwitch": true, | |||
| "module": "esnext", | |||
| "moduleResolution": "node", | |||
| "moduleResolution": "Node", | |||
| "resolveJsonModule": true, | |||
| "isolatedModules": true, | |||
| "noEmit": true, | |||
| @@ -0,0 +1,17 @@ | |||
| module.exports = { | |||
| module: { | |||
| rules: [ | |||
| { | |||
| test: /\.s[ac]ss$/i, | |||
| use: [ | |||
| // Creates `style` nodes from JS strings | |||
| "style-loader", | |||
| // Translates CSS into CommonJS | |||
| "css-loader", | |||
| // Compiles Sass to CSS | |||
| "sass-loader", | |||
| ], | |||
| }, | |||
| ], | |||
| }, | |||
| }; | |||