From f8755cf3e4312b471aeb87a610ac07aa72e737fe Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Thu, 1 Apr 2021 22:14:11 +0200 Subject: [PATCH 01/98] Extracted API requests for sendLoginCode and register --- src/api/axios.ts | 41 ++++++++++++ src/api/constants.ts | 1 + src/api/register.ts | 17 +++++ src/api/sendLoginCode.ts | 41 ++++++++++++ .../login => components}/CopyLoginCode.tsx | 0 src/features/auth/login/AutomaticLogin.tsx | 2 +- .../auth/login/LoginWithCodeRoute.tsx | 2 +- src/features/auth/login/index.tsx | 42 +++---------- src/features/auth/register/index.tsx | 55 ++++++---------- src/i18n.ts | 46 +++++++------- src/resources/locales/en.json | 2 +- src/resources/locales/sv.json | 2 +- src/types/user.ts | 8 +++ src/utils/formatErrors.ts | 62 +++++++++++++++++++ src/utils/showCode.tsx | 21 +++++++ 15 files changed, 246 insertions(+), 96 deletions(-) create mode 100644 src/api/axios.ts create mode 100644 src/api/constants.ts create mode 100644 src/api/register.ts create mode 100644 src/api/sendLoginCode.ts rename src/{features/auth/login => components}/CopyLoginCode.tsx (100%) create mode 100644 src/types/user.ts create mode 100644 src/utils/formatErrors.ts create mode 100644 src/utils/showCode.tsx diff --git a/src/api/axios.ts b/src/api/axios.ts new file mode 100644 index 0000000..7263205 --- /dev/null +++ b/src/api/axios.ts @@ -0,0 +1,41 @@ +import axios, { AxiosRequestConfig } from "axios"; + +import { DEV_API_BASE_URL } from "./constants"; +import formatErrors from "utils/formatErrors"; + +export const api = axios.create({ + baseURL: process.env.REACT_APP_API_URL || DEV_API_BASE_URL, +}); + +interface FormattedRequests { + post(url: string, data?: unknown, config?: AxiosRequestConfig): Promise; + get(url: string, config?: AxiosRequestConfig): Promise; + patch( + url: string, + data?: unknown, + config?: AxiosRequestConfig + ): Promise; +} + +const format: FormattedRequests = { + post: (url, data, config) => + api + .post(url, data, config) + .then((res) => res.data) + .catch(formatErrors), + get: (url, config) => + api + .get(url, config) + .then((res) => res.data) + .catch(formatErrors), + patch: (url, data, config) => + api + .patch(url, data, config) + .then((res) => res.data) + .catch(formatErrors), +}; + +export default { + ...api, + format, +}; diff --git a/src/api/constants.ts b/src/api/constants.ts new file mode 100644 index 0000000..21891fb --- /dev/null +++ b/src/api/constants.ts @@ -0,0 +1 @@ +export const DEV_API_BASE_URL = "https://devapi.infrarays.digitalungdom.se"; diff --git a/src/api/register.ts b/src/api/register.ts new file mode 100644 index 0000000..f07e931 --- /dev/null +++ b/src/api/register.ts @@ -0,0 +1,17 @@ +import { Applicant } from "types/user"; +import api from "./axios"; + +/** + * Required values in a registration form for an applicant + */ +export type RegistrationForm = Pick< + Applicant, + "firstName" | "lastName" | "birthdate" | "email" | "finnish" +>; + +/** + * Register an application + * @param {RegistrationForm} form values to register with + */ +export const register = (form: RegistrationForm): Promise => + api.format.post("application", form); diff --git a/src/api/sendLoginCode.ts b/src/api/sendLoginCode.ts new file mode 100644 index 0000000..46264fc --- /dev/null +++ b/src/api/sendLoginCode.ts @@ -0,0 +1,41 @@ +import formatErrors, { FormattedErrors } from "utils/formatErrors"; + +import { DEV_API_BASE_URL } from "./constants"; +import api from "./axios"; + +/** + * Sends login code to your email + * @param {email} + * @returns {Promise} returns either nothing or the login code + */ +const sendLoginCode = (email: string): Promise => + api.format.post("/user/send_email_login_code", { + email, + }); + +/** + * Required parameters for send login code request + */ +type SendLoginCodeParams = { + email: string; // the email which you signed up with +}; + +/** + * Sends login code to your email and displays it in a notification if it is run on the dev api + * @param email + * @returns the string or formatted errors + */ +export const sendLoginCodeAndShowCode = ( + email: string +): Promise> => + api + .post("/user/send_email_login_code", { email }) + .then((res) => { + if (res.data && res.config.baseURL === DEV_API_BASE_URL) return res.data; + return; + }) + .catch((err) => { + throw formatErrors(err); + }); + +export default sendLoginCode; diff --git a/src/features/auth/login/CopyLoginCode.tsx b/src/components/CopyLoginCode.tsx similarity index 100% rename from src/features/auth/login/CopyLoginCode.tsx rename to src/components/CopyLoginCode.tsx diff --git a/src/features/auth/login/AutomaticLogin.tsx b/src/features/auth/login/AutomaticLogin.tsx index 6e91bd9..0df5e84 100644 --- a/src/features/auth/login/AutomaticLogin.tsx +++ b/src/features/auth/login/AutomaticLogin.tsx @@ -14,7 +14,7 @@ const AutomaticLogin: React.FC = () => { useEffect(() => { loginWithToken(token).catch((err) => { if (!err.request.status) - setError(["fetch error", "fetch error description"]); + setError(["Network error", "fetch error description"]); else setError(["Bad link", "Bad link description"]); }); }, [token]); diff --git a/src/features/auth/login/LoginWithCodeRoute.tsx b/src/features/auth/login/LoginWithCodeRoute.tsx index 61c9ccc..f375dfe 100644 --- a/src/features/auth/login/LoginWithCodeRoute.tsx +++ b/src/features/auth/login/LoginWithCodeRoute.tsx @@ -12,7 +12,7 @@ const LoginWithCodeRoute = (): React.ReactElement => { loginWithCode(atob(emailInBase64), values.code).catch((err) => { setSubmitting(false); if (err.request.status) setErrors({ code: "Wrong code" }); - else setErrors({ code: "fetch error" }); + else setErrors({ code: "Network error" }); }); }} /> diff --git a/src/features/auth/login/index.tsx b/src/features/auth/login/index.tsx index c562ff6..98d2e3f 100644 --- a/src/features/auth/login/index.tsx +++ b/src/features/auth/login/index.tsx @@ -3,10 +3,8 @@ import { Link, useHistory } from "react-router-dom"; import { Trans, useTranslation } from "react-i18next"; import Alert from "react-bootstrap/Alert"; -import Axios from "axios"; import Button from "react-bootstrap/Button"; import Center from "components/Center"; -import CopyLoginCode from "./CopyLoginCode"; import FormControl from "react-bootstrap/FormControl"; import FormGroup from "react-bootstrap/FormGroup"; import FormLabel from "react-bootstrap/FormLabel"; @@ -14,12 +12,13 @@ import Logo from "components/Logo"; import Plate from "components/Plate"; import React from "react"; import StyledGroup from "components/StyledGroup"; -import { toast } from "react-toastify"; +import { sendLoginCodeAndShowCode } from "api/sendLoginCode"; +import useShowCode from "utils/showCode"; const Login = (): React.ReactElement => { const history = useHistory(); const { t } = useTranslation(); - const toastId = React.useRef(null); + const showCode = useShowCode(); return (
@@ -32,37 +31,16 @@ const Login = (): React.ReactElement => { }} onSubmit={({ email }, { setSubmitting, setErrors }) => { setSubmitting(true); - Axios.post("/user/send_email_login_code", { - email, - }) - .then((res) => { - if ( - res.data && - res.config.baseURL === - "https://devapi.infrarays.digitalungdom.se" - ) { - const update = () => - toast.update(toastId.current as string, { - autoClose: 5000, - }); - const notify = () => - ((toastId.current as React.ReactText) = toast( - , - { - position: "bottom-center", - autoClose: false, - closeOnClick: false, - } - )); - notify(); - } + sendLoginCodeAndShowCode(email) + .then((code) => { history.push(`/login/${btoa(email)}`); setSubmitting(false); + code && showCode(code as string); }) .catch((err) => { + if (err.general) setErrors({ dummy: err.general.message }); + else setErrors(err); setSubmitting(false); - if (!err.request.status) setErrors({ dummy: "fetch error" }); - else setErrors({ email: "no user" }); }); }} > @@ -84,7 +62,7 @@ const Login = (): React.ReactElement => { {errors.email && t(errors.email)} - + {errors.dummy && ( {t(errors.dummy)} @@ -108,7 +86,7 @@ const Login = (): React.ReactElement => { diff --git a/src/features/auth/register/index.tsx b/src/features/auth/register/index.tsx index e8f90eb..a425542 100644 --- a/src/features/auth/register/index.tsx +++ b/src/features/auth/register/index.tsx @@ -1,25 +1,26 @@ import "./signup.css"; -import { Alert, FormControlProps, Spinner } from "react-bootstrap"; import { Form, Formik } from "formik"; +import FormControl, { FormControlProps } from "react-bootstrap/FormControl"; import { Link, useHistory } from "react-router-dom"; import MaskedInput, { MaskedInputProps } from "react-maskedinput"; import { Trans, WithTranslation, withTranslation } from "react-i18next"; -import Axios from "axios"; +import Alert from "react-bootstrap/Alert"; import Button from "react-bootstrap/Button"; import Center from "components/Center"; -import CopyLoginCode from "../login/CopyLoginCode"; import FormCheck from "react-bootstrap/FormCheck"; -import FormControl from "react-bootstrap/FormControl"; import FormGroup from "react-bootstrap/FormGroup"; import FormLabel from "react-bootstrap/FormLabel"; import Logo from "components/Logo"; import Plate from "components/Plate"; import React from "react"; +import Spinner from "react-bootstrap/Spinner"; import StyledGroup from "components/StyledGroup"; import moment from "moment"; -import { toast } from "react-toastify"; +import { register } from "api/register"; +import sendLoginCodeAndShowCode from "api/sendLoginCode"; +import useShowCode from "utils/showCode"; type MaskedFieldProps = Omit & Omit; @@ -31,9 +32,9 @@ const MaskedField = (props: MaskedFieldProps) => ( const Register: React.FC = ({ t }) => { const { push } = useHistory(); - const toastId = React.useRef(null); + const showCode = useShowCode(); const applicationHasClosed = - moment.utc().month(2).endOf("month").diff(Date.now()) < 0; + moment.utc().month(2).endOf("month").diff(Date.now()) > 0; return (
@@ -63,44 +64,24 @@ const Register: React.FC = ({ t }) => { return; } setSubmitting(true); - Axios.post("/application", { + const form = { email, firstName, lastName, birthdate, finnish: finnish === "Yes", - }) + }; + register(form) .then(() => { - Axios.post("/user/send_email_login_code", { email }).then( - (res) => { - push(`/login/${btoa(email)}`); - if ( - res.data && - res.config.baseURL === - "https://devapi.infrarays.digitalungdom.se" - ) { - const update = () => - toast.update(toastId.current as string, { - autoClose: 5000, - }); - const notify = () => - ((toastId.current as React.ReactText) = toast( - , - { - position: "bottom-center", - autoClose: false, - closeOnClick: false, - } - )); - notify(); - } - } - ); + sendLoginCodeAndShowCode(email).then((code) => { + push(`/login/${btoa(email)}`); + code && showCode(code); + }); }) - .catch((err) => { + .catch((error) => { setSubmitting(false); - if (!err.request.status) setErrors({ dummy: "fetch error" }); - else setErrors({ email: "email exists" }); + if (error.general) setErrors({ dummy: error.general.message }); + else setErrors(error.params); }); }} > diff --git a/src/i18n.ts b/src/i18n.ts index 423b06a..1c68fbb 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -1,10 +1,10 @@ -import i18n from 'i18next'; -import { initReactI18next } from 'react-i18next'; -import en from 'resources/locales/en.json'; -import portalEn from 'resources/locales/portal_en.json'; -import sv from 'resources/locales/sv.json'; -import portalSv from 'resources/locales/portal_sv.json'; -import LanguageDetector from 'i18next-browser-languagedetector'; +import LanguageDetector from "i18next-browser-languagedetector"; +import en from "resources/locales/en.json"; +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; +import portalEn from "resources/locales/portal_en.json"; +import portalSv from "resources/locales/portal_sv.json"; +import sv from "resources/locales/sv.json"; // the translations // (tip move them in a JSON file and import them) @@ -12,15 +12,15 @@ const resources = { en: { translation: { ...en, - ...portalEn - } + ...portalEn, + }, }, sv: { translation: { ...sv, - ...portalSv - } - } + ...portalSv, + }, + }, }; i18n @@ -28,22 +28,22 @@ i18n .use(LanguageDetector) .init({ resources, - fallbackLng: 'sv', + fallbackLng: "sv", detection: { order: [ - 'querystring', - 'cookie', - 'localStorage', - 'htmlTag', - 'navigator', - 'path', - 'subdomain' - ] + "querystring", + "cookie", + "localStorage", + "htmlTag", + "navigator", + "path", + "subdomain", + ], }, interpolation: { - escapeValue: false // react already safes from xss - } + escapeValue: false, // react already safes from xss + }, }); export default i18n; diff --git a/src/resources/locales/en.json b/src/resources/locales/en.json index 9b07e25..b59ce3a 100644 --- a/src/resources/locales/en.json +++ b/src/resources/locales/en.json @@ -9,7 +9,7 @@ "Invalid value": "Invalid format", "email exists": "Email is already registered", "not verified": "Verify your e-mail first.", - "fetch error": "Network error.", + "Network error": "Network error.", "Register here": "Register here", "First name": "First name", "Surname": "Surname", diff --git a/src/resources/locales/sv.json b/src/resources/locales/sv.json index 543ae7d..f737317 100644 --- a/src/resources/locales/sv.json +++ b/src/resources/locales/sv.json @@ -9,7 +9,7 @@ "Invalid value": "Ogiltig format", "email exists": "Email är redan registrerad", "not verified": "Bekräfta din e-postadress först.", - "fetch error": "Nätverksproblem.", + "Network error": "Nätverksproblem.", "Register here": "Registrera dig här", "First name": "Förnamn", "Surname": "Efternamn", diff --git a/src/types/user.ts b/src/types/user.ts new file mode 100644 index 0000000..5aec6cd --- /dev/null +++ b/src/types/user.ts @@ -0,0 +1,8 @@ +export interface Applicant { + firstName: string; + lastName: string; + birthdate: string; + email: string; + id: string; + finnish: boolean; +} diff --git a/src/utils/formatErrors.ts b/src/utils/formatErrors.ts new file mode 100644 index 0000000..d367e20 --- /dev/null +++ b/src/utils/formatErrors.ts @@ -0,0 +1,62 @@ +import { AxiosResponse } from "axios"; + +/** + * ServerErrorsByParam is an object containing information about a specific error + */ +type ServerErrorsByParam = { + param: K; + message: string; + code: string; + statusCode: number; +}; + +/** + * Values of fields in the form + */ +export interface Values { + [field: string]: never; +} + +/** + * FormattedErrors is an object containing errors for specific parameters + * and general errors that don't rely on specific parameters + */ +export type FormattedErrors = { + params: { + [K in keyof Values]?: string; + }; + general?: { + message: string; + code: string; + }; +}; + +/** + * ServerErrorResponse is the object returned for an erroneous response + */ +type ServerErrorResponse = AxiosResponse<{ + errors: ServerErrorsByParam[]; +}>; + +/** + * formatErrors is a function that formats an erroneous server response into a flattened error structure + * @param err the error response from the Axios request + */ + +type FormatErrors = (err: { + response: ServerErrorResponse; +}) => FormattedErrors; +const formatErrors: FormatErrors = (err) => { + const errors: FormattedErrors = { + params: {}, + }; + if (err.response) + err.response.data.errors.forEach((error) => { + if (error.param) errors.params[error.param] = error.message; + else errors.general = error; + }); + else errors.general = { message: "Network error", code: "-1" }; + throw errors; +}; + +export default formatErrors; diff --git a/src/utils/showCode.tsx b/src/utils/showCode.tsx new file mode 100644 index 0000000..653a486 --- /dev/null +++ b/src/utils/showCode.tsx @@ -0,0 +1,21 @@ +import CopyLoginCode from "components/CopyLoginCode"; +import React from "react"; +import { toast } from "react-toastify"; + +export default function useShowCode(): (code: string) => React.ReactText { + const toastId = React.useRef(null); + const update = () => + toast.update(toastId.current as string, { + autoClose: 5000, + }); + const notify = (code: string) => + ((toastId.current as React.ReactText) = toast( + , + { + position: "bottom-center", + autoClose: false, + closeOnClick: false, + } + )); + return (code: string) => notify(code); +} From 3e6fe3c16c4f68b914140a590fc272ceda9f48c9 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Thu, 1 Apr 2021 23:48:43 +0200 Subject: [PATCH 02/98] Changed all instances of axios to API axios Also restructured the OpenPDF component --- src/App.tsx | 4 -- src/api/downloadPDF.ts | 25 +++++++ src/components/LoadingButton.tsx | 51 +++++++++++++ src/components/portal/OpenPDF/index.jsx | 72 ------------------- src/components/portal/OpenPDF/index.tsx | 35 +++++++++ src/features/admin/Administration/index.tsx | 2 +- src/features/admin/Grading/Grade.tsx | 2 +- src/features/admin/Grading/RandomiseOrder.tsx | 2 +- src/features/admin/Grading/index.tsx | 5 +- src/features/admin/TopList/index.tsx | 5 +- src/features/auth/AuthenticatedLayer.tsx | 2 +- src/features/auth/api.ts | 4 +- src/features/portal/Delete.tsx | 2 +- src/features/portal/Download.tsx | 2 +- src/features/portal/References/index.tsx | 2 +- src/features/portal/Survey/index.tsx | 2 +- src/features/portal/Upload/UploadMultiple.tsx | 2 +- src/features/portal/Upload/index.tsx | 2 +- src/features/portal/index.tsx | 2 +- src/features/recommendation/index.tsx | 2 +- src/utils/showFile.ts | 37 ++++++++++ src/utils/tokenInterceptor.ts | 3 +- 22 files changed, 171 insertions(+), 94 deletions(-) create mode 100644 src/api/downloadPDF.ts create mode 100644 src/components/LoadingButton.tsx delete mode 100644 src/components/portal/OpenPDF/index.jsx create mode 100644 src/components/portal/OpenPDF/index.tsx create mode 100644 src/utils/showFile.ts diff --git a/src/App.tsx b/src/App.tsx index badd1e6..6d7b45a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,7 +13,6 @@ import { Provider } from "react-redux"; import React from "react"; import Router from "features/router"; import { ToastContainer } from "react-toastify"; -import axios from "axios"; import styled from "styled-components"; const StyledApp = styled.div` @@ -30,9 +29,6 @@ const StyledApp = styled.div` } `; -axios.defaults.baseURL = - process.env.REACT_APP_API_URL || "https://devapi.infrarays.digitalungdom.se"; - function App() { return ( diff --git a/src/api/downloadPDF.ts b/src/api/downloadPDF.ts new file mode 100644 index 0000000..098a894 --- /dev/null +++ b/src/api/downloadPDF.ts @@ -0,0 +1,25 @@ +import api from "./axios"; +import showFile from "utils/showFile"; + +/** + * Downloads PDF and returns the blob and the file name + * @param applicantID + * @returns {[Blob, string]} Blob and file name + */ +export const downloadPDF = (applicantID: string): Promise<[Blob, string]> => + api + .get(`/application/${applicantID}/pdf`, { responseType: "blob" }) + .then((res) => { + const name = res.headers["content-disposition"].split("filename=")[1]; + return [res.data, name]; + }); + +/** + * Download and open PDF in new tab + * @param applicantID + * @returns void + */ +export const downloadAndOpen = (applicantID: string): Promise => + downloadPDF(applicantID).then((args) => showFile(...args)); + +export default downloadPDF; diff --git a/src/components/LoadingButton.tsx b/src/components/LoadingButton.tsx new file mode 100644 index 0000000..9c75d72 --- /dev/null +++ b/src/components/LoadingButton.tsx @@ -0,0 +1,51 @@ +import BootstrapButton, { + ButtonProps as BootstrapButtonProps, +} from "react-bootstrap/Button"; +import React, { useState } from "react"; + +import Spinner from "react-bootstrap/Spinner"; + +interface ButtonProps extends Omit { + /** + * Promise that will change if the component is loading + */ + onClick: () => Promise; + + /** + * Enable to only show the spinning icon and not any other children + */ + showOnlyLoading?: boolean; +} + +/** + * Button that displays uses a promise to display a loading icon + */ +const LoadingButton: React.FC = ({ + children, + disabled, + onClick, + showOnlyLoading, + ...props +}) => { + const [loading, setLoading] = useState(false); + const changeLoading = () => setLoading(false); + return ( + { + setLoading(true); + onClick().then(changeLoading).catch(changeLoading); + }} + disabled={loading || disabled} + {...props} + > + {loading ? ( + + ) : ( + showOnlyLoading && children + )}{" "} + {!showOnlyLoading && children} + + ); +}; + +export default LoadingButton; diff --git a/src/components/portal/OpenPDF/index.jsx b/src/components/portal/OpenPDF/index.jsx deleted file mode 100644 index a468b38..0000000 --- a/src/components/portal/OpenPDF/index.jsx +++ /dev/null @@ -1,72 +0,0 @@ -import { Button, Spinner } from "react-bootstrap"; -import React, { useState } from "react"; - -import axios from "axios"; -import { toast } from "react-toastify"; -import { useTranslation } from "react-i18next"; - -export function showFile(blob, name, callback) { - // It is necessary to create a new blob object with mime-type explicitly set - // otherwise only Chrome works like it should - const newBlob = new Blob([blob], { type: "application/pdf" }); - - // IE doesn't allow using a blob object directly as link href - // instead it is necessary to use msSaveOrOpenBlob - if (window.navigator && window.navigator.msSaveOrOpenBlob) { - window.navigator.msSaveOrOpenBlob(newBlob, name); - return; - } - - // For other browsers: - // Create a link pointing to the ObjectURL containing the blob. - const data = window.URL.createObjectURL(newBlob); - // const isFirefox = typeof InstallTrigger !== "undefined"; - // if (!isFirefox) { - window.open(data); - // } else { - // const link = document.createElement("a"); - // link.href = data; - // link.target = "_blank"; - // link.download = name; - // link.click(); - // } - setTimeout(function () { - // For Firefox it is necessary to delay revoking the ObjectURL - // document.removeChild(link); - window.URL.revokeObjectURL(data); - }, 100); - if (callback) callback(); -} - -const OpenPDF = ({ url, children, variant = "primary" }) => { - const [loading, setLoading] = useState(false); - const { t } = useTranslation(); - return ( - - ); -}; - -export default OpenPDF; diff --git a/src/components/portal/OpenPDF/index.tsx b/src/components/portal/OpenPDF/index.tsx new file mode 100644 index 0000000..d6ad650 --- /dev/null +++ b/src/components/portal/OpenPDF/index.tsx @@ -0,0 +1,35 @@ +import { ButtonProps } from "react-bootstrap/Button"; +import LoadingButton from "components/LoadingButton"; +import React from "react"; +import { toast } from "react-toastify"; +import { useTranslation } from "react-i18next"; + +interface OpenPDFProps extends ButtonProps { + /** + * A function returning a promise that will change the loading state of the button + */ + onDownload: () => Promise; +} + +const OpenPDF: React.FC = ({ + onDownload, + children, + variant = "primary", +}) => { + const { t } = useTranslation(); + return ( + + onDownload().catch(() => { + toast.error(t("Couldnt get file")); + }) + } + showOnlyLoading + > + {children} + + ); +}; + +export default OpenPDF; diff --git a/src/features/admin/Administration/index.tsx b/src/features/admin/Administration/index.tsx index 97e595e..b141c6f 100644 --- a/src/features/admin/Administration/index.tsx +++ b/src/features/admin/Administration/index.tsx @@ -5,7 +5,7 @@ import { useDispatch, useSelector } from "react-redux"; import AddButton from "components/AddButton"; import AdminContact from "components/AdminContact"; import { Spinner } from "react-bootstrap"; -import axios from "axios"; +import axios from "api/axios"; import { selectUserType } from "features/auth/authSlice"; import useAxios from "axios-hooks"; diff --git a/src/features/admin/Grading/Grade.tsx b/src/features/admin/Grading/Grade.tsx index 7bb9575..1224494 100644 --- a/src/features/admin/Grading/Grade.tsx +++ b/src/features/admin/Grading/Grade.tsx @@ -7,7 +7,7 @@ import { useDispatch, useSelector } from "react-redux"; import { ButtonProps } from "react-bootstrap/Button"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { RootState } from "store"; -import axios from "axios"; +import axios from "api/axios"; import { faEdit } from "@fortawesome/free-solid-svg-icons"; interface GradeProps { diff --git a/src/features/admin/Grading/RandomiseOrder.tsx b/src/features/admin/Grading/RandomiseOrder.tsx index 3f93ec6..af16c85 100644 --- a/src/features/admin/Grading/RandomiseOrder.tsx +++ b/src/features/admin/Grading/RandomiseOrder.tsx @@ -1,7 +1,7 @@ import { Button, Spinner } from "react-bootstrap"; import React, { useState } from "react"; -import axios from "axios"; +import axios from "api/axios"; import { toast } from "react-toastify"; import { updateGradingOrder } from "../adminSlice"; import { useDispatch } from "react-redux"; diff --git a/src/features/admin/Grading/index.tsx b/src/features/admin/Grading/index.tsx index d864b7d..d3a46ef 100644 --- a/src/features/admin/Grading/index.tsx +++ b/src/features/admin/Grading/index.tsx @@ -16,7 +16,8 @@ import RandomiseOrder from "./RandomiseOrder"; import React from "react"; import { RootState } from "store"; import Spinner from "react-bootstrap/Spinner"; -import axios from "axios"; +import axios from "api/axios"; +import { downloadAndOpen } from "api/downloadPDF"; import { faFileDownload } from "@fortawesome/free-solid-svg-icons"; interface ApplicationInfo { @@ -79,7 +80,7 @@ class Grading extends React.Component { formatter: (id: string, row: any) => ( downloadAndOpen(id)} > diff --git a/src/features/admin/TopList/index.tsx b/src/features/admin/TopList/index.tsx index c2d65b2..c97643e 100644 --- a/src/features/admin/TopList/index.tsx +++ b/src/features/admin/TopList/index.tsx @@ -12,7 +12,8 @@ import OpenPDF from "components/portal/OpenPDF"; import React from "react"; import { RootState } from "store"; import Spinner from "react-bootstrap/Spinner"; -import axios from "axios"; +import axios from "api/axios"; +import { downloadAndOpen } from "api/downloadPDF"; import { faFileDownload } from "@fortawesome/free-solid-svg-icons"; import { useGrades } from "../adminHooks"; @@ -84,7 +85,7 @@ class TopList extends React.Component { dataField: "id", text: "Visa", formatter: (id: string) => ( - + downloadAndOpen(id)}> ), diff --git a/src/features/auth/AuthenticatedLayer.tsx b/src/features/auth/AuthenticatedLayer.tsx index 2832e46..20f83d6 100644 --- a/src/features/auth/AuthenticatedLayer.tsx +++ b/src/features/auth/AuthenticatedLayer.tsx @@ -5,7 +5,7 @@ import { userInfoSuccess, } from "features/auth/authSlice"; -import Axios from "axios"; +import Axios from "api/axios"; import { TokenStorage } from "utils/tokenInterceptor"; import { useDispatch } from "react-redux"; import { useSelector } from "react-redux"; diff --git a/src/features/auth/api.ts b/src/features/auth/api.ts index 87ac1de..0213c3e 100644 --- a/src/features/auth/api.ts +++ b/src/features/auth/api.ts @@ -1,6 +1,8 @@ -import Axios, { AxiosResponse } from "axios"; import { ServerTokenResponse, TokenStorage } from "utils/tokenInterceptor"; +import Axios from "api/axios"; +import { AxiosResponse } from "axios"; + export const loginWithCode = ( email: string, loginCode: string diff --git a/src/features/portal/Delete.tsx b/src/features/portal/Delete.tsx index 41556ba..7055745 100644 --- a/src/features/portal/Delete.tsx +++ b/src/features/portal/Delete.tsx @@ -1,7 +1,7 @@ import { Button, Modal, Spinner } from "react-bootstrap"; import React, { useState } from "react"; -import Axios from "axios"; +import Axios from "api/axios"; import { TokenStorage } from "utils/tokenInterceptor"; import { useTranslation } from "react-i18next"; diff --git a/src/features/portal/Download.tsx b/src/features/portal/Download.tsx index ddccce1..9f72fa9 100644 --- a/src/features/portal/Download.tsx +++ b/src/features/portal/Download.tsx @@ -1,7 +1,7 @@ import { Button, Spinner } from "react-bootstrap"; import React, { useState } from "react"; -import Axios from "axios"; +import Axios from "api/axios"; import CSS from "csstype"; import FileSaver from "file-saver"; import { useTranslation } from "react-i18next"; diff --git a/src/features/portal/References/index.tsx b/src/features/portal/References/index.tsx index d9b901f..58d2714 100644 --- a/src/features/portal/References/index.tsx +++ b/src/features/portal/References/index.tsx @@ -3,7 +3,7 @@ import { Recommendation, addPersonSuccess } from "features/portal/portalSlice"; import { Trans, withTranslation } from "react-i18next"; import { useDispatch, useSelector } from "react-redux"; -import Axios from "axios"; +import Axios from "api/axios"; import ContactPerson from "components/portal/ContactPerson"; import { RootState } from "store"; import moment from "moment"; diff --git a/src/features/portal/Survey/index.tsx b/src/features/portal/Survey/index.tsx index ea60683..427748d 100644 --- a/src/features/portal/Survey/index.tsx +++ b/src/features/portal/Survey/index.tsx @@ -2,7 +2,7 @@ import Survey, { SurveyAnswers } from "components/Survey"; import { selectSurvey, setSurvey } from "../portalSlice"; import { useDispatch, useSelector } from "react-redux"; -import Axios from "axios"; +import Axios from "api/axios"; import React from "react"; import moment from "moment"; import useAxios from "axios-hooks"; diff --git a/src/features/portal/Upload/UploadMultiple.tsx b/src/features/portal/Upload/UploadMultiple.tsx index 44a8e0e..324c482 100644 --- a/src/features/portal/Upload/UploadMultiple.tsx +++ b/src/features/portal/Upload/UploadMultiple.tsx @@ -8,7 +8,7 @@ import { import React, { useState } from "react"; import { useDispatch, useSelector } from "react-redux"; -import Axios from "axios"; +import Axios from "api/axios"; import FileSaver from "file-saver"; import { RootState } from "store"; import Upload from "components/portal/Upload"; diff --git a/src/features/portal/Upload/index.tsx b/src/features/portal/Upload/index.tsx index fc91e07..492de6a 100644 --- a/src/features/portal/Upload/index.tsx +++ b/src/features/portal/Upload/index.tsx @@ -7,7 +7,7 @@ import { import React, { useState } from "react"; import { useDispatch, useSelector } from "react-redux"; -import Axios from "axios"; +import Axios from "api/axios"; import FileSaver from "file-saver"; import { RootState } from "store"; import Upload from "components/portal/Upload"; diff --git a/src/features/portal/index.tsx b/src/features/portal/index.tsx index 6997084..2cee2e5 100644 --- a/src/features/portal/index.tsx +++ b/src/features/portal/index.tsx @@ -4,7 +4,7 @@ import React, { useEffect, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import Alert from "react-bootstrap/Alert"; -import Axios from "axios"; +import Axios from "api/axios"; import Center from "components/Center"; import Chapters from "./Chapters"; import Delete from "./Delete"; diff --git a/src/features/recommendation/index.tsx b/src/features/recommendation/index.tsx index ef021f8..c98d403 100644 --- a/src/features/recommendation/index.tsx +++ b/src/features/recommendation/index.tsx @@ -4,7 +4,7 @@ import { Trans, useTranslation } from "react-i18next"; import CenterCard from "components/CenterCard"; import Upload from "components/portal/Upload"; -import axios from "axios"; +import axios from "api/axios"; import useAxios from "axios-hooks"; import { useParams } from "react-router-dom"; diff --git a/src/utils/showFile.ts b/src/utils/showFile.ts new file mode 100644 index 0000000..18f9929 --- /dev/null +++ b/src/utils/showFile.ts @@ -0,0 +1,37 @@ +/** + * Open a blob in a new tab + * @param blob + * @param name + * @returns void + */ +function showFile(blob: Blob, name: string): Promise { + return new Promise((res, rej) => { + try { + // It is necessary to create a new blob object with mime-type explicitly set + // otherwise only Chrome works like it should + const newBlob = new Blob([blob], { type: "application/pdf" }); + + // IE doesn't allow using a blob object directly as link href + // instead it is necessary to use msSaveOrOpenBlob + if (window.navigator && window.navigator.msSaveOrOpenBlob) { + window.navigator.msSaveOrOpenBlob(newBlob, name); + return; + } + + // For other browsers: + // Create a link pointing to the ObjectURL containing the blob. + const data = window.URL.createObjectURL(newBlob); + window.open(data); + res(); + setTimeout(function () { + // For Firefox it is necessary to delay revoking the ObjectURL + // document.removeChild(link); + window.URL.revokeObjectURL(data); + }, 100); + } catch { + rej(); + } + }); +} + +export default showFile; diff --git a/src/utils/tokenInterceptor.ts b/src/utils/tokenInterceptor.ts index 07e4268..6cfa469 100644 --- a/src/utils/tokenInterceptor.ts +++ b/src/utils/tokenInterceptor.ts @@ -1,6 +1,7 @@ import { authFail, authSuccess } from "features/auth/authSlice"; -import axios from "axios"; +import axios from "api/axios"; +// import axios from "axios"; import { clearPortal } from "features/portal/portalSlice"; import i18n from "i18n"; import store from "store"; From bc51e8af91dff4f68574d959fb3052f44d12ca34 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Fri, 2 Apr 2021 22:43:21 +0200 Subject: [PATCH 03/98] Added delete request to API instance + getAdmins + delete request --- src/api/admin.ts | 9 +++++++++ src/api/axios.ts | 6 ++++++ src/api/user.ts | 8 ++++++++ src/types/grade.ts | 17 +++++++++++++++++ src/types/user.ts | 14 ++++++++++++-- 5 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 src/api/admin.ts create mode 100644 src/api/user.ts create mode 100644 src/types/grade.ts diff --git a/src/api/admin.ts b/src/api/admin.ts new file mode 100644 index 0000000..ce55a8a --- /dev/null +++ b/src/api/admin.ts @@ -0,0 +1,9 @@ +import { Admin } from "types/user"; +import { Grading } from "types/grade"; +import api from "./axios"; + +export const getAdmins = (): Promise => + api.format.get("/admin", { params: { skip: 0, limit: 10 } }); + +export const getGradesByApplicant = (applicantID: string): Promise => + api.format.get(`/application/${applicantID}/grade`); diff --git a/src/api/axios.ts b/src/api/axios.ts index 7263205..353e101 100644 --- a/src/api/axios.ts +++ b/src/api/axios.ts @@ -10,6 +10,7 @@ export const api = axios.create({ interface FormattedRequests { post(url: string, data?: unknown, config?: AxiosRequestConfig): Promise; get(url: string, config?: AxiosRequestConfig): Promise; + delete(url: string, config?: AxiosRequestConfig): Promise; patch( url: string, data?: unknown, @@ -33,6 +34,11 @@ const format: FormattedRequests = { .patch(url, data, config) .then((res) => res.data) .catch(formatErrors), + delete: (url, config) => + api + .delete(url, config) + .then((res) => res.data) + .catch(formatErrors), }; export default { diff --git a/src/api/user.ts b/src/api/user.ts new file mode 100644 index 0000000..e244947 --- /dev/null +++ b/src/api/user.ts @@ -0,0 +1,8 @@ +import api from "./axios"; + +/** + * Delete account forever + * @returns {Promise} + */ +export const deleteAccount = (): Promise => + api.format.delete("/user/@me"); diff --git a/src/types/grade.ts b/src/types/grade.ts new file mode 100644 index 0000000..78d7c77 --- /dev/null +++ b/src/types/grade.ts @@ -0,0 +1,17 @@ +export type NumericalGradeField = + | "cv" + | "coverLetter" + | "essays" + | "grades" + | "recommendations" + | "overall"; + +export type Grades = Record & { + comment: string; +}; + +export interface Grading extends Grades { + applicantId: string; + adminId: string; + id: string; +} diff --git a/src/types/user.ts b/src/types/user.ts index 5aec6cd..2610f58 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -1,8 +1,18 @@ -export interface Applicant { +export type User = { firstName: string; lastName: string; - birthdate: string; email: string; id: string; + created: string; + verified: boolean; +}; + +export interface Applicant extends User { + birthdate: string; finnish: boolean; + type: "APPLICANT"; +} + +export interface Admin extends User { + type: "ADMIN" | "SUPER_ADMIN"; } From 6fdf6635c2ed4cb37a4fdeab9bb9a9bac52f427b Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Sat, 3 Apr 2021 14:41:46 +0200 Subject: [PATCH 04/98] Added ability to see applicant email --- .../admin/Grading/ApplicantInformation.tsx | 16 ++++++++++++++++ src/features/admin/Grading/index.tsx | 10 +++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 src/features/admin/Grading/ApplicantInformation.tsx diff --git a/src/features/admin/Grading/ApplicantInformation.tsx b/src/features/admin/Grading/ApplicantInformation.tsx new file mode 100644 index 0000000..cf2ac9e --- /dev/null +++ b/src/features/admin/Grading/ApplicantInformation.tsx @@ -0,0 +1,16 @@ +import React from "react"; + +interface ApplicantInformationProps { + email: string; +} + +function ApplicantInformation({ email }: ApplicantInformationProps) { + return ( +
+ Email: + {email} +
+ ); +} + +export default ApplicantInformation; diff --git a/src/features/admin/Grading/index.tsx b/src/features/admin/Grading/index.tsx index d864b7d..5b71ce3 100644 --- a/src/features/admin/Grading/index.tsx +++ b/src/features/admin/Grading/index.tsx @@ -8,6 +8,7 @@ import { updateGradingOrder, } from "../adminSlice"; +import ApplicantInformation from "./ApplicantInformation"; import BootstrapTable from "react-bootstrap-table-next"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import Grade from "./Grade"; @@ -54,7 +55,6 @@ class Grading extends React.Component { } render() { - const loading = this.state.loading[0] || this.state.loading[1]; const dataWithIndex = this.props.applications.map((application, index) => ({ ...application, index, @@ -99,6 +99,13 @@ class Grading extends React.Component { }, ]; + const expandRow = { + renderer: (row: any) => , + showExpandColumn: true, + expandByColumnOnly: true, + className: "white", + }; + return (
@@ -109,6 +116,7 @@ class Grading extends React.Component {
Date: Sat, 3 Apr 2021 18:22:56 +0200 Subject: [PATCH 05/98] Added files slice --- src/features/files/filesSlice.ts | 98 ++++++++++++++++++++++++++++++++ src/types/files.ts | 18 ++++++ 2 files changed, 116 insertions(+) create mode 100644 src/features/files/filesSlice.ts create mode 100644 src/types/files.ts diff --git a/src/features/files/filesSlice.ts b/src/features/files/filesSlice.ts new file mode 100644 index 0000000..38d7654 --- /dev/null +++ b/src/features/files/filesSlice.ts @@ -0,0 +1,98 @@ +import { FileID, FileInfo, FileType } from "types/files"; +/* eslint-disable camelcase */ +/* eslint-disable no-param-reassign */ +import { PayloadAction, createSlice } from "@reduxjs/toolkit"; + +import { RootState } from "store"; + +export type Recommendation = { + id: string; + code?: string; + applicantId: string; + email: string; + lastSent: string; + received: null | string; + fileId: null | string; + index: number; +}; + +interface PortalState { + files: Record; + filesByType: Partial>; + recommendations: Recommendation[]; +} + +export const initialState: PortalState = { + files: {}, + filesByType: {}, + recommendations: [], +}; + +const filesSlice = createSlice({ + name: "files", + initialState, + reducers: { + setFiles(state, action: PayloadAction) { + action.payload.forEach((file) => { + state.files[file.id] = file; + if (state.filesByType[file.type]) + state.filesByType[file.type]?.unshift(file.id); + else state.filesByType[file.type] = [file.id]; + }); + }, + replaceFile(state, action: PayloadAction) { + const file = action.payload; + state.files[file.id] = file; + if (state.filesByType[file.type]) + state.filesByType[file.type] = [file.id]; + else state.filesByType[file.type] = [file.id]; + }, + uploadSuccess(state, action: PayloadAction) { + const file = action.payload; + state.files[file.id] = file; + if (state.filesByType[file.type]) + state.filesByType[file.type]?.push(file.id); + else state.filesByType[file.type] = [file.id]; + }, + deleteFileSuccess(state, action: PayloadAction) { + const file = state.files[action.payload]; + const index = state.filesByType[file.type]?.indexOf(action.payload); + if (index !== undefined && index > -1) { + (state.filesByType[file.type] as FileID[]).splice(index, 1); + } + }, + }, +}); + +export const selectAllFiles = (state: RootState): FileInfo[] => + state.portal.files; + +export const selectSingleFileByFileType = ( + state: RootState, + type: FileType +): FileInfo | undefined => { + if (state.portal.filesByType[type]) { + const files = state.portal.filesByType[type]; + if (files) return state.portal.files[files[0]]; + else return undefined; + } + return undefined; +}; +export const selectFilesByFileType = ( + state: RootState, + type: FileType +): FileInfo[] | undefined => { + const array = state.portal.filesByType[type]?.map( + (fileID) => state.portal.files[fileID] + ); + if (array === undefined || type === "APPENDIX") return array; +}; + +export const { + setFiles, + uploadSuccess, + deleteFileSuccess, + replaceFile, +} = filesSlice.actions; + +export default filesSlice.reducer; diff --git a/src/types/files.ts b/src/types/files.ts new file mode 100644 index 0000000..540f860 --- /dev/null +++ b/src/types/files.ts @@ -0,0 +1,18 @@ +export type FileType = + | "CV" + | "COVER_LETTER" + | "GRADES" + | "RECOMMENDATION_LETTER" + | "APPENDIX" + | "ESSAY"; + +export type FileID = string; + +export type FileInfo = { + id: FileID; + userId: string; + type: FileType; + created: string; + name: string; + mime: string; +}; From 4529fd5cf0b48dfd4fa5d91711a92062d6ccc7bf Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Sat, 3 Apr 2021 23:16:09 +0200 Subject: [PATCH 06/98] Refactored Upload to allow for greater customisation and reusability --- src/api/files.ts | 43 +++++ src/components/portal/Upload/index.tsx | 2 +- src/config/portal.json | 6 +- .../admin/Grading/ApplicantInformation.tsx | 25 ++- src/features/admin/Grading/index.tsx | 4 +- src/features/files/Upload.tsx | 130 ++++++++++++++ src/features/files/filesHooks.ts | 25 +++ src/features/files/filesSlice.ts | 87 ++++------ src/features/portal/Chapters/index.tsx | 15 +- src/features/portal/Upload/UploadMultiple.tsx | 164 ------------------ src/features/portal/Upload/index.tsx | 107 ------------ src/features/portal/index.tsx | 3 +- src/store.ts | 2 + 13 files changed, 275 insertions(+), 338 deletions(-) create mode 100644 src/api/files.ts create mode 100644 src/features/files/Upload.tsx create mode 100644 src/features/files/filesHooks.ts delete mode 100644 src/features/portal/Upload/UploadMultiple.tsx delete mode 100644 src/features/portal/Upload/index.tsx diff --git a/src/api/files.ts b/src/api/files.ts new file mode 100644 index 0000000..4d4c398 --- /dev/null +++ b/src/api/files.ts @@ -0,0 +1,43 @@ +import { FileInfo, FileType } from "types/files"; + +import FileSaver from "file-saver"; +import api from "axios"; + +export const downloadFile = (fileID: string, applicantID = "@me") => + api + .get(`application/${applicantID}/file/${fileID}`, { + responseType: "blob", + }) + .then((res) => { + const utf8FileName = res.headers["content-disposition"].split( + "filename*=UTF-8''" + )[1]; + const decodedName = decodeURIComponent(utf8FileName); + const normalName = res.headers["content-disposition"].split( + "filename=" + )[1]; + FileSaver.saveAs( + res.data, + utf8FileName === undefined + ? normalName.substring(1, normalName.length - 1) + : decodedName.substring(1, decodedName.length - 1) + ); + }); + +export const deleteFile = (fileID: string, applicantID = "@me") => + api.delete(`/application/${applicantID}/file/${fileID}`); + +export const uploadFile = ( + fileType: FileType, + file: File, + fileName: string, + applicantID = "@me" +) => { + const form = new FormData(); + form.append("file", file, fileName); + return api + .post(`application/${applicantID}/file/${fileType}`, form, { + headers: { "Content-Type": "multipart/form-data" }, + }) + .then((res) => res.data); +}; diff --git a/src/components/portal/Upload/index.tsx b/src/components/portal/Upload/index.tsx index 0f973a2..da1314d 100644 --- a/src/components/portal/Upload/index.tsx +++ b/src/components/portal/Upload/index.tsx @@ -94,7 +94,7 @@ export type UploadProps = } & MostUploadProps) | ({ multiple?: false; - onChange?: (file: any, name: string) => any; + onChange?: (file: File, name: string) => any; } & MostUploadProps); const Upload: React.FC = ({ diff --git a/src/config/portal.json b/src/config/portal.json index e1f2752..b9630aa 100644 --- a/src/config/portal.json +++ b/src/config/portal.json @@ -5,6 +5,7 @@ { "fileType": "CV", "upload": { + "multiple": 1, "label": "Ladda upp CV", "accept": ".pdf" } @@ -12,6 +13,7 @@ { "fileType": "COVER_LETTER", "upload": { + "multiple": 1, "label": "Ladda upp personligt brev", "accept": ".pdf" } @@ -19,6 +21,7 @@ { "fileType": "ESSAY", "upload": { + "multiple": 1, "label": "Ladda upp essäsvar", "accept": ".pdf" } @@ -26,6 +29,7 @@ { "fileType": "GRADES", "upload": { + "multiple": 1, "label": "Ladda upp betyg", "accept": ".pdf" } @@ -37,7 +41,7 @@ { "fileType": "APPENDIX", "upload": { - "multiple": true, + "multiple": 5, "accept": ".pdf" } }, diff --git a/src/features/admin/Grading/ApplicantInformation.tsx b/src/features/admin/Grading/ApplicantInformation.tsx index cf2ac9e..b9b8ccf 100644 --- a/src/features/admin/Grading/ApplicantInformation.tsx +++ b/src/features/admin/Grading/ApplicantInformation.tsx @@ -1,14 +1,37 @@ +import { FileType } from "types/files"; import React from "react"; +import Upload from "features/files/Upload"; +import portal from "config/portal.json"; +import { useFiles } from "features/files/filesHooks"; interface ApplicantInformationProps { email: string; + applicantID: string; } -function ApplicantInformation({ email }: ApplicantInformationProps) { +function ApplicantInformation({ + email, + applicantID, +}: ApplicantInformationProps) { + const { loading, error } = useFiles(applicantID); return (
Email: {email} + {loading ? ( +
Loading
+ ) : ( + portal.chapters.map((chapter) => + chapter.upload ? ( + + ) : null + ) + )}
); } diff --git a/src/features/admin/Grading/index.tsx b/src/features/admin/Grading/index.tsx index 5b71ce3..14e37aa 100644 --- a/src/features/admin/Grading/index.tsx +++ b/src/features/admin/Grading/index.tsx @@ -100,7 +100,9 @@ class Grading extends React.Component { ]; const expandRow = { - renderer: (row: any) => , + renderer: (row: any) => ( + + ), showExpandColumn: true, expandByColumnOnly: true, className: "white", diff --git a/src/features/files/Upload.tsx b/src/features/files/Upload.tsx new file mode 100644 index 0000000..48d20c6 --- /dev/null +++ b/src/features/files/Upload.tsx @@ -0,0 +1,130 @@ +import React, { useState } from "react"; +import { deleteFile, downloadFile, uploadFile } from "api/files"; +import { + deleteFileSuccess, + replaceFile, + selectFilesByFileTypeAndApplicant, + setFiles, +} from "./filesSlice"; +import { useDispatch, useSelector } from "react-redux"; + +import { FileType } from "types/files"; +import Upload from "components/portal/Upload"; +import moment from "moment"; +import { toast } from "react-toastify"; +import { useTranslation } from "react-i18next"; + +interface UploadHookProps { + accept?: string; + fileType: FileType; + disabled?: boolean; + applicantID?: string; + multiple?: number; + maxFileSize?: number; + alwaysAbleToUpload?: boolean; +} + +interface UploadingFileInfo { + uploading: boolean; + error?: string; + name: string; +} + +const UploadHook: React.FC = ({ + disabled, + accept, + fileType, + applicantID, + maxFileSize = 5 * 10 ** 6, + multiple = 1, + alwaysAbleToUpload, +}) => { + const [uploadingFiles, setUploadingFiles] = useState([]); + const dispatch = useDispatch(); + const files = useSelector( + selectFilesByFileTypeAndApplicant(fileType, applicantID) + ); + const { t } = useTranslation(); + + const handleDelete = (fileID: string, applicantID: string) => + deleteFile(fileID, applicantID) + .then(() => { + dispatch(deleteFileSuccess(fileID)); + }) + .catch((err) => { + toast.error(err.message); + }); + + const handleUpload = (file: File, fileName: string) => { + if (file.size > maxFileSize) { + setUploadingFiles([ + { + name: file.name, + error: "too large", + uploading: false, + }, + ]); + } else { + setUploadingFiles([{ name: file.name, uploading: true }]); + uploadFile(fileType, file, fileName) + .then((res) => { + if (multiple > 1) dispatch(replaceFile(res)); + else dispatch(setFiles([res])); + setUploadingFiles([]); + }) + .catch((err) => { + setUploadingFiles([ + { name: file.name, uploading: false, error: err.message }, + ]); + }); + } + }; + + const handleCancel = () => setUploadingFiles([]); + + const applicationHasClosed = + moment.utc().month(2).endOf("month").diff(Date.now()) < 0; + const disabledUploading = + (applicationHasClosed && !alwaysAbleToUpload) || disabled; + const label = t(`${fileType}.upload.label`); + + return ( + <> + {files?.map((file) => ( + 1 || disabledUploading} + uploadLabel={t("Choose file")} + onDownload={() => downloadFile(file.id, file.userId)} + onDelete={() => handleDelete(file.id, file.userId)} + onChange={handleUpload} + /> + ))} + {uploadingFiles.map((file) => ( + + ))} + {(files?.length || 0) + uploadingFiles.length < multiple && ( + <> + + + )} + + ); +}; + +export default UploadHook; diff --git a/src/features/files/filesHooks.ts b/src/features/files/filesHooks.ts new file mode 100644 index 0000000..d3f170f --- /dev/null +++ b/src/features/files/filesHooks.ts @@ -0,0 +1,25 @@ +import { selectApplicantFilesLoaded, setFiles } from "./filesSlice"; +import { useDispatch, useSelector } from "react-redux"; + +import { FileInfo } from "types/files"; +import useAxios from "axios-hooks"; + +type UseFiles = { + loading: boolean; + error: any; +}; + +export function useFiles(applicantID = "@me"): UseFiles { + const dispatch = useDispatch(); + const files = useSelector(selectApplicantFilesLoaded(applicantID)); + const [{ loading, data, error }] = useAxios({ + url: `/application/${applicantID}/file`, + }); + if (Boolean(files) === false && data) { + dispatch(setFiles(data)); + } + return { + loading, + error, + }; +} diff --git a/src/features/files/filesSlice.ts b/src/features/files/filesSlice.ts index 38d7654..f567ff3 100644 --- a/src/features/files/filesSlice.ts +++ b/src/features/files/filesSlice.ts @@ -5,27 +5,21 @@ import { PayloadAction, createSlice } from "@reduxjs/toolkit"; import { RootState } from "store"; -export type Recommendation = { - id: string; - code?: string; - applicantId: string; - email: string; - lastSent: string; - received: null | string; - fileId: null | string; - index: number; -}; +type FilesByType = Partial>; +type FileIDsByType = Partial>; interface PortalState { files: Record; + fileIDsByApplicantAndType: Partial>; filesByType: Partial>; - recommendations: Recommendation[]; + fileTypesByApplicants: Record; } export const initialState: PortalState = { files: {}, + fileIDsByApplicantAndType: {}, filesByType: {}, - recommendations: [], + fileTypesByApplicants: {}, }; const filesSlice = createSlice({ @@ -34,58 +28,51 @@ const filesSlice = createSlice({ reducers: { setFiles(state, action: PayloadAction) { action.payload.forEach((file) => { - state.files[file.id] = file; - if (state.filesByType[file.type]) - state.filesByType[file.type]?.unshift(file.id); - else state.filesByType[file.type] = [file.id]; + if (state.fileTypesByApplicants[file.userId] === undefined) { + state.fileTypesByApplicants[file.userId] = {}; + } + if (state.fileTypesByApplicants[file.userId][file.type]) + state.fileTypesByApplicants[file.userId][file.type]?.push(file); + else state.fileTypesByApplicants[file.userId][file.type] = [file]; }); }, replaceFile(state, action: PayloadAction) { const file = action.payload; - state.files[file.id] = file; - if (state.filesByType[file.type]) - state.filesByType[file.type] = [file.id]; - else state.filesByType[file.type] = [file.id]; + state.fileTypesByApplicants[file.userId][file.type] = [file]; }, uploadSuccess(state, action: PayloadAction) { const file = action.payload; - state.files[file.id] = file; - if (state.filesByType[file.type]) - state.filesByType[file.type]?.push(file.id); - else state.filesByType[file.type] = [file.id]; + const files = state.fileTypesByApplicants[file.userId][file.type]; + if (files) files.push(file); + else state.fileTypesByApplicants[file.userId][file.type] = [file]; }, deleteFileSuccess(state, action: PayloadAction) { - const file = state.files[action.payload]; - const index = state.filesByType[file.type]?.indexOf(action.payload); - if (index !== undefined && index > -1) { - (state.filesByType[file.type] as FileID[]).splice(index, 1); - } + const deletedFile = state.files[action.payload]; + const files = + state.fileTypesByApplicants[deletedFile.userId][deletedFile.type]; + files?.filter((file) => file !== deletedFile); }, }, }); -export const selectAllFiles = (state: RootState): FileInfo[] => - state.portal.files; - -export const selectSingleFileByFileType = ( - state: RootState, - type: FileType -): FileInfo | undefined => { - if (state.portal.filesByType[type]) { - const files = state.portal.filesByType[type]; - if (files) return state.portal.files[files[0]]; - else return undefined; - } - return undefined; +export const selectApplicantFilesLoaded = (applicantID?: string) => ( + state: RootState +): boolean => { + const id = applicantID || state.auth.user?.id; + if (!id) return false; + const fileTypesByApplicants = state.files.fileTypesByApplicants[id]; + return Boolean(fileTypesByApplicants); }; -export const selectFilesByFileType = ( - state: RootState, - type: FileType -): FileInfo[] | undefined => { - const array = state.portal.filesByType[type]?.map( - (fileID) => state.portal.files[fileID] - ); - if (array === undefined || type === "APPENDIX") return array; + +export const selectFilesByFileTypeAndApplicant = ( + type: FileType, + applicantID?: string +) => (state: RootState): FileInfo[] => { + const id = applicantID || state.auth.user?.id; + if (!id) return []; + const fileTypes = state.files.fileTypesByApplicants[id]; + if (fileTypes) return fileTypes[type] || []; + return []; }; export const { diff --git a/src/features/portal/Chapters/index.tsx b/src/features/portal/Chapters/index.tsx index 70dd948..14bdfcc 100644 --- a/src/features/portal/Chapters/index.tsx +++ b/src/features/portal/Chapters/index.tsx @@ -5,8 +5,7 @@ import { FileType } from "../portalSlice"; import PortalSurvey from "../Survey"; import React from "react"; import References from "../References"; -import Upload from "../Upload"; -import UploadMultiple from "../Upload/UploadMultiple"; +import Upload from "features/files/Upload"; import portal from "config/portal.json"; interface ChaptersProps extends WithTranslation { @@ -27,20 +26,12 @@ const Chapters: React.FC = ({ description={t(`${chapter.fileType}.description`)} subtitle={t(`${chapter.fileType}.subtitle`)} > - {chapter.upload && chapter.upload.multiple === undefined && ( + {chapter.upload && ( - )} - {chapter.upload?.multiple && ( - )} {chapter.contactPeople && } diff --git a/src/features/portal/Upload/UploadMultiple.tsx b/src/features/portal/Upload/UploadMultiple.tsx deleted file mode 100644 index 44a8e0e..0000000 --- a/src/features/portal/Upload/UploadMultiple.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { - FileInfo, - FileType, - deleteFileSuccess, - selectFilesByFileType, - setFiles, -} from "../portalSlice"; -import React, { useState } from "react"; -import { useDispatch, useSelector } from "react-redux"; - -import Axios from "axios"; -import FileSaver from "file-saver"; -import { RootState } from "store"; -import Upload from "components/portal/Upload"; -import { useTranslation } from "react-i18next"; - -interface UploadMultipleProps { - label?: string; - accept?: string; - fileType: FileType; - disabled?: boolean; -} - -interface UploadedFileInfo extends Partial { - uploading?: boolean; - error?: string; -} - -const handleDownload = (id: string) => - Axios.get(`application/@me/file/${id}`, { - responseType: "blob", - }).then((res) => { - const utf8FileName = res.headers["content-disposition"].split( - "filename*=UTF-8''" - )[1]; - const decodedName = decodeURIComponent(utf8FileName); - const normalName = res.headers["content-disposition"].split("filename=")[1]; - FileSaver.saveAs( - res.data, - utf8FileName === undefined - ? normalName.substring(1, normalName.length - 1) - : decodedName.substring(1, decodedName.length - 1) - ); - }); - -const UploadMultiple: React.FC = ({ - label, - accept, - fileType, - disabled, -}) => { - // const [uploadingFiles, setUploadingFiles] = useState(); - const [uploadingFile, setUploadingFile] = useState(); - const dispatch = useDispatch(); - const uploadedFiles = useSelector((state: RootState) => - selectFilesByFileType(state, fileType) - ); - const { t } = useTranslation(); - - function handleChange(file: File, fileName: string) { - const error = file.size > 5 * 10 ** 6 ? t("too large") : undefined; - setUploadingFile({ - name: file.name, - uploading: true && !error, - error, - }); - if (error) return; - const form = new FormData(); - form.append("file", file, fileName); - Axios.post(`application/@me/file/${fileType}`, form, { - headers: { "Content-Type": "multipart/form-data" }, - }) - .then((res) => { - setUploadingFile(undefined); - dispatch(setFiles([res.data])); - }) - .catch(() => { - setUploadingFile(undefined); - const error = t("Couldn't upload"); - setUploadingFile({ - name: file.name, - uploading: true && !error, - error, - }); - }); - } - - // function handleChange(files: FileList, fileName: string[]) { - // const newUploadingFiles: UploadedFileInfo[] = []; - // const form = new FormData(); - // let uploading = false; - // for (let i = 0; i < files.length; i++) { - // const error = files[i].size > 5 * 10 ** 6 ? t("too large") : undefined; - // newUploadingFiles.push({ - // name: files[i].name, - // uploading: true && !error, - // error, - // }); - // if (!error) { - // form.append("file", files[i], files[i].name); - // uploading = true; - // } - // } - // setUploadingFiles(newUploadingFiles); - // if (uploading) - // Axios.post(`application/@me/file/${fileType}`, form, { - // headers: { "Content-Type": "multipart/form-data" }, - // }).then((res) => { - // const errorFilesOnly = newUploadingFiles.filter((file) => file.error); - // setUploadingFiles(errorFilesOnly); - // }); - // } - - const handleDelete = (id: string) => - Axios.delete(`/application/@me/file/${id}`).then(() => { - dispatch(deleteFileSuccess(id)); - }); - - const handleCancel = () => setUploadingFile(undefined); - - return ( - <> - {/* {uploadingFiles?.map((file: any) => ( - - ))} */} - {uploadedFiles?.map((file: FileInfo) => ( - handleDownload(file.id)} - onDelete={() => handleDelete(file.id)} - /> - ))} - {uploadingFile && ( - - )} - {(uploadedFiles?.length || 0) + (uploadingFile ? 1 : 0) < 5 && ( - - )} - - ); -}; - -export default UploadMultiple; diff --git a/src/features/portal/Upload/index.tsx b/src/features/portal/Upload/index.tsx deleted file mode 100644 index fc91e07..0000000 --- a/src/features/portal/Upload/index.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { - FileInfo, - FileType, - deleteFileSuccess, - selectSingleFileByFileType, -} from "../portalSlice"; -import React, { useState } from "react"; -import { useDispatch, useSelector } from "react-redux"; - -import Axios from "axios"; -import FileSaver from "file-saver"; -import { RootState } from "store"; -import Upload from "components/portal/Upload"; -import moment from "moment"; -import { replaceFile } from "../portalSlice"; -import { useTranslation } from "react-i18next"; - -interface UploadHookProps { - label?: string; - accept?: string; - fileType: FileType; - disabled?: boolean; -} - -const UploadHook: React.FC = ({ - disabled, - label, - accept, - fileType, -}) => { - const [uploading, setUploading] = useState(false); - const [error, setError] = useState(); - const dispatch = useDispatch(); - const fileInfo = useSelector((state: RootState) => - selectSingleFileByFileType(state, fileType) - ); - const { t } = useTranslation(); - - function handleChange(file: any, fileName: string) { - if (file.size > 5 * 10 ** 6) { - setError({ msg: t("too large"), fileName }); - return; - } - setUploading(true); - const form = new FormData(); - form.append("file", file, fileName); - Axios.post(`application/@me/file/${fileType}`, form, { - headers: { "Content-Type": "multipart/form-data" }, - }) - .then((res) => { - setUploading(false); - setError(undefined); - dispatch(replaceFile(res.data)); - }) - .catch(() => { - setUploading(false); - setError({ msg: t("Couldn't upload"), fileName }); - }); - } - - const handleDownload = () => - Axios.get(`application/@me/file/${fileInfo?.id}`, { - responseType: "blob", - }).then((res) => { - const utf8FileName = res.headers["content-disposition"].split( - "filename*=UTF-8''" - )[1]; - const decodedName = decodeURIComponent(utf8FileName); - const normalName = res.headers["content-disposition"].split( - "filename=" - )[1]; - FileSaver.saveAs( - res.data, - utf8FileName === undefined - ? normalName.substring(1, normalName.length - 1) - : decodedName.substring(1, decodedName.length - 1) - ); - }); - - const handleDelete = () => - Axios.delete(`/application/@me/file/${fileInfo?.id}`).then(() => { - dispatch(deleteFileSuccess((fileInfo as FileInfo).id)); - }); - - const handleCancel = () => setError(null); - - const applicationHasClosed = - moment.utc().month(2).endOf("month").diff(Date.now()) < 0; - - return ( - - ); -}; - -export default UploadHook; diff --git a/src/features/portal/index.tsx b/src/features/portal/index.tsx index 6997084..0d47537 100644 --- a/src/features/portal/index.tsx +++ b/src/features/portal/index.tsx @@ -1,5 +1,5 @@ import { ButtonGroup, ProgressBar } from "react-bootstrap"; -import { FileInfo, selectProgress, setFiles } from "./portalSlice"; +import { FileInfo, selectProgress } from "./portalSlice"; import React, { useEffect, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; @@ -14,6 +14,7 @@ import Logout from "./Logout"; import ReactMarkdown from "react-markdown"; import StyledPlate from "components/Plate"; import { addPersonSuccess } from "features/portal/portalSlice"; +import { setFiles } from "features/files/filesSlice"; import { useTranslation } from "react-i18next"; const Hook = () => { diff --git a/src/store.ts b/src/store.ts index 4aed434..391696d 100644 --- a/src/store.ts +++ b/src/store.ts @@ -16,6 +16,7 @@ import { import admin from "features/admin/adminSlice"; import auth from "features/auth/authSlice"; +import files from "features/files/filesSlice"; import portal from "features/portal/portalSlice"; import storage from "redux-persist/lib/storage"; // defaults to localStorage for web @@ -29,6 +30,7 @@ const rootReducer = combineReducers({ auth, portal, admin, + files, }); const persistedReducer = persistReducer(persistConfig, rootReducer); From 9251468b24072096fa492e0095104c321df95aee Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Sat, 3 Apr 2021 23:25:36 +0200 Subject: [PATCH 07/98] Bug fix filesSlice was not operating correctly when deleting files --- src/features/files/Upload.tsx | 2 +- src/features/files/filesSlice.ts | 23 +++++++++-------------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/features/files/Upload.tsx b/src/features/files/Upload.tsx index 48d20c6..8ff1ab8 100644 --- a/src/features/files/Upload.tsx +++ b/src/features/files/Upload.tsx @@ -49,7 +49,7 @@ const UploadHook: React.FC = ({ const handleDelete = (fileID: string, applicantID: string) => deleteFile(fileID, applicantID) .then(() => { - dispatch(deleteFileSuccess(fileID)); + dispatch(deleteFileSuccess([applicantID, fileType, fileID])); }) .catch((err) => { toast.error(err.message); diff --git a/src/features/files/filesSlice.ts b/src/features/files/filesSlice.ts index f567ff3..a788a29 100644 --- a/src/features/files/filesSlice.ts +++ b/src/features/files/filesSlice.ts @@ -7,18 +7,11 @@ import { RootState } from "store"; type FilesByType = Partial>; -type FileIDsByType = Partial>; -interface PortalState { - files: Record; - fileIDsByApplicantAndType: Partial>; - filesByType: Partial>; +interface FilesState { fileTypesByApplicants: Record; } -export const initialState: PortalState = { - files: {}, - fileIDsByApplicantAndType: {}, - filesByType: {}, +export const initialState: FilesState = { fileTypesByApplicants: {}, }; @@ -46,11 +39,13 @@ const filesSlice = createSlice({ if (files) files.push(file); else state.fileTypesByApplicants[file.userId][file.type] = [file]; }, - deleteFileSuccess(state, action: PayloadAction) { - const deletedFile = state.files[action.payload]; - const files = - state.fileTypesByApplicants[deletedFile.userId][deletedFile.type]; - files?.filter((file) => file !== deletedFile); + deleteFileSuccess( + state, + action: PayloadAction<[FileID, FileType, FileID]> + ) { + const [applicantID, fileType, fileID] = action.payload; + const files = state.fileTypesByApplicants[applicantID][fileType]; + files?.filter((file) => file.id !== fileID); }, }, }); From 352e3aeb3ed537ca8a8aaee60f7f00b0ea5a300a Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Sun, 4 Apr 2021 23:43:18 +0200 Subject: [PATCH 08/98] Added custom useAxios and improved admin hooks --- src/api/admin.ts | 6 +- src/api/useAxios.ts | 8 ++ src/components/AdminContact/index.tsx | 2 +- src/features/admin/Administration/index.tsx | 61 ++++-------- src/features/admin/TopList/index.tsx | 5 +- src/features/admin/adminHooks.ts | 105 ++++++++++++++++---- src/hooks/apiWithRedux.ts | 14 +++ src/types/user.ts | 4 + 8 files changed, 139 insertions(+), 66 deletions(-) create mode 100644 src/api/useAxios.ts create mode 100644 src/hooks/apiWithRedux.ts diff --git a/src/api/admin.ts b/src/api/admin.ts index ce55a8a..4b85dc6 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -1,4 +1,5 @@ -import { Admin } from "types/user"; +import { Admin, NewAdmin } from "types/user"; + import { Grading } from "types/grade"; import api from "./axios"; @@ -7,3 +8,6 @@ export const getAdmins = (): Promise => export const getGradesByApplicant = (applicantID: string): Promise => api.format.get(`/application/${applicantID}/grade`); + +export const addAdmin = (admin: NewAdmin): Promise => + api.format.post("/admin", admin); diff --git a/src/api/useAxios.ts b/src/api/useAxios.ts new file mode 100644 index 0000000..9024f3f --- /dev/null +++ b/src/api/useAxios.ts @@ -0,0 +1,8 @@ +import { api } from "./axios"; +import { makeUseAxios } from "axios-hooks"; + +export const useAxios = makeUseAxios({ + axios: api, +}); + +export default useAxios; diff --git a/src/components/AdminContact/index.tsx b/src/components/AdminContact/index.tsx index 5f2001d..aff4dd4 100644 --- a/src/components/AdminContact/index.tsx +++ b/src/components/AdminContact/index.tsx @@ -6,8 +6,8 @@ import { InputGroup, Spinner, } from "react-bootstrap"; -import { Formik, setNestedObjectValues } from "formik"; +import { Formik } from "formik"; import React from "react"; import styled from "styled-components"; diff --git a/src/features/admin/Administration/index.tsx b/src/features/admin/Administration/index.tsx index b141c6f..ab2e5c0 100644 --- a/src/features/admin/Administration/index.tsx +++ b/src/features/admin/Administration/index.tsx @@ -1,50 +1,28 @@ import React, { useState } from "react"; -import { selectAdmins, setAdmins } from "../adminSlice"; -import { useDispatch, useSelector } from "react-redux"; import AddButton from "components/AddButton"; import AdminContact from "components/AdminContact"; import { Spinner } from "react-bootstrap"; -import axios from "api/axios"; import { selectUserType } from "features/auth/authSlice"; -import useAxios from "axios-hooks"; - -interface AdminInfo { - id: string; - email: string; - firstName: string; - lastName: string; - type: "SUPER_ADMIN" | "ADMIN"; - verified: boolean; - created: string; -} +import { useAdmins } from "../adminHooks"; +import { useSelector } from "react-redux"; const Administration: React.FC = () => { - const [{ data, loading }] = useAxios({ - url: "/admin", - params: { skip: 0, limit: 10 }, - }); - const dispatch = useDispatch(); - + const { loading, data, addAdmin } = useAdmins(); const [numberOfEmptyFields, setEmptyFields] = useState([]); const userType = useSelector(selectUserType); - const admins = useSelector(selectAdmins); - if (data && admins.length === 0) dispatch(setAdmins(data)); const emptyFields: React.ReactElement[] = []; numberOfEmptyFields.forEach((i) => { emptyFields.push( - axios - .post("/admin", { - ...values, - type: values.superAdmin ? "SUPER_ADMIN" : "ADMIN", - }) - .then((res) => { - setEmptyFields(numberOfEmptyFields.filter((x) => x !== i)); - dispatch(setAdmins([res.data])); - }) + addAdmin({ + ...values, + type: values.superAdmin ? "SUPER_ADMIN" : "ADMIN", + }).then(() => + setEmptyFields(numberOfEmptyFields.filter((x) => x !== i)) + ) } /> ); @@ -59,16 +37,17 @@ const Administration: React.FC = () => { eller ansökningar. En vanlig admin kan endast läsa och bedöma ansökningar.

- {admins.map((admin) => ( - - ))} + {data && + data.map((admin) => ( + + ))} {userType === "SUPER_ADMIN" && ( <> {emptyFields} diff --git a/src/features/admin/TopList/index.tsx b/src/features/admin/TopList/index.tsx index c97643e..5d0427d 100644 --- a/src/features/admin/TopList/index.tsx +++ b/src/features/admin/TopList/index.tsx @@ -19,10 +19,11 @@ import { useGrades } from "../adminHooks"; interface GradingDataRowProps { id: string; + [field: string]: any; } -const GradingDataRow = ({ id }: GradingDataRowProps) => { - console.log("render grading"); +const GradingDataRow = ({ id, ...props }: GradingDataRowProps) => { + console.log("render grading", props); const { data, loading } = useGrades(id); if (loading) return
Loading
; return ; diff --git a/src/features/admin/adminHooks.ts b/src/features/admin/adminHooks.ts index 2d0a9b2..36dd71a 100644 --- a/src/features/admin/adminHooks.ts +++ b/src/features/admin/adminHooks.ts @@ -1,37 +1,40 @@ +import { Admin, NewAdmin } from "types/user"; +import { addAdmin, getAdmins, getGradesByApplicant } from "api/admin"; import { selectAdmins, selectGradesByApplicant, setAdmins, setGrades, } from "./adminSlice"; +import { useCallback, useEffect, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; -import useAxios from "axios-hooks"; -import { useEffect } from "react"; +import useAxios from "api/useAxios"; interface UseGrades { loading: boolean; data: any; error: any; } - export function useGrades(applicantId: string): UseGrades { - const admins = useAdmins(); - const [{ loading, data, error }] = useAxios( - `/application/${applicantId}/grade` - ); + useAdmins(); const dispatch = useDispatch(); - useEffect(() => { - if (data) dispatch(setGrades({ grades: data, applicantId })); - }, [data]); - const gradesByApplicant = useSelector(selectGradesByApplicant(applicantId)); - const result = { - loading: admins.loading || loading, - data: admins.loading ? null : gradesByApplicant, - error, - }; - console.log(result); - return result; + const grades = useSelector(selectGradesByApplicant(applicantId)); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + if (loading === false && Boolean(grades) === false) { + getGradesByApplicant(applicantId) + .then((grades) => { + setLoading(false); + dispatch(setGrades({ grades, applicantId })); + }) + .catch((err) => { + setError(err); + setLoading(false); + }); + setLoading(true); + } + return { loading, data: grades, error }; } interface AdminInfo { @@ -44,15 +47,75 @@ interface AdminInfo { created: string; } -export function useAdmins(): UseGrades { +interface UseHook { + loading: boolean; + error: any; + data: T; +} + +interface UseAdmins extends UseHook { + addAdmin: (admin: NewAdmin) => Promise; +} + +export function useAdmins(): UseAdmins { const [{ loading, data, error }] = useAxios({ url: "/admin", - params: { skip: 0, limit: 10 }, + params: { skip: 0, limit: 20 }, }); const dispatch = useDispatch(); useEffect(() => { if (data) dispatch(setAdmins(data)); }, [data]); const admins = useSelector(selectAdmins); - return { loading, data: admins, error }; + const newAdmin = useCallback( + (admin: NewAdmin) => + addAdmin(admin).then((res) => { + dispatch(setAdmins([res])); + return res; + }), + [dispatch] + ); + return { loading, data: admins, error, addAdmin: newAdmin }; } + +// export function useAdmins(): UseHook { +// const dispatch = useDispatch(); +// // const admins = useSelector(selectAdmins); +// const [{ loading, data, error }] = useAxios({ +// url: "/admin", +// params: { +// skip: 0, +// limit: 10, +// }, +// }); +// // useEffect(() => { +// // if (admins.length === 0 && data !== undefined) { +// // dispatch(setAdmins(data)); +// // } +// // }, [data]); +// return { +// loading, +// data: data ? data : [], +// error, +// }; +// } + +// export function useAdmins(): UseHook { +// const dispatch = useDispatch(); +// const admins = useSelector(selectAdmins); +// const [loading, setLoading] = useState(false); +// const [error, setError] = useState(); +// if (loading === false && admins.length === 0) { +// setLoading(true); +// getAdmins() +// .then((res) => { +// setLoading(false); +// dispatch(setAdmins(res)); +// }) +// .catch((err) => { +// setError(err); +// setLoading(false); +// }); +// } +// return { loading, data: admins, error }; +// } diff --git a/src/hooks/apiWithRedux.ts b/src/hooks/apiWithRedux.ts new file mode 100644 index 0000000..553dbc7 --- /dev/null +++ b/src/hooks/apiWithRedux.ts @@ -0,0 +1,14 @@ +import { RootState } from "store"; +import { TypedUseSelectorHook } from "react-redux"; + +interface UseHook { + loading: boolean; + error: any; + data: T; +} + +interface UseWithRedux { + selector: TypedUseSelectorHook; +} + +// export function useWithRedux(selector) {} diff --git a/src/types/user.ts b/src/types/user.ts index 2610f58..f7e1042 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -13,6 +13,10 @@ export interface Applicant extends User { type: "APPLICANT"; } +export type ServerUserFields = "id" | "created" | "verified"; + +export type NewAdmin = Omit; + export interface Admin extends User { type: "ADMIN" | "SUPER_ADMIN"; } From 88998e4057699765961208ee3b345e8300d3c9c4 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 5 Apr 2021 00:24:47 +0200 Subject: [PATCH 09/98] Improved admin hooks --- src/api/admin.ts | 5 +- src/features/admin/TopList/index.tsx | 3 +- src/features/admin/adminHooks.ts | 84 +++++----------------------- src/hooks/apiWithRedux.ts | 13 +---- 4 files changed, 20 insertions(+), 85 deletions(-) diff --git a/src/api/admin.ts b/src/api/admin.ts index 4b85dc6..9f6ca46 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -6,8 +6,11 @@ import api from "./axios"; export const getAdmins = (): Promise => api.format.get("/admin", { params: { skip: 0, limit: 10 } }); +export const getGradesConfig = (applicantID: string): string => + `/application/${applicantID}/grade`; + export const getGradesByApplicant = (applicantID: string): Promise => - api.format.get(`/application/${applicantID}/grade`); + api.format.get(getGradesConfig(applicantID)); export const addAdmin = (admin: NewAdmin): Promise => api.format.post("/admin", admin); diff --git a/src/features/admin/TopList/index.tsx b/src/features/admin/TopList/index.tsx index 5d0427d..2f55958 100644 --- a/src/features/admin/TopList/index.tsx +++ b/src/features/admin/TopList/index.tsx @@ -22,8 +22,7 @@ interface GradingDataRowProps { [field: string]: any; } -const GradingDataRow = ({ id, ...props }: GradingDataRowProps) => { - console.log("render grading", props); +const GradingDataRow = ({ id }: GradingDataRowProps) => { const { data, loading } = useGrades(id); if (loading) return
Loading
; return ; diff --git a/src/features/admin/adminHooks.ts b/src/features/admin/adminHooks.ts index 36dd71a..26dffff 100644 --- a/src/features/admin/adminHooks.ts +++ b/src/features/admin/adminHooks.ts @@ -1,14 +1,15 @@ import { Admin, NewAdmin } from "types/user"; -import { addAdmin, getAdmins, getGradesByApplicant } from "api/admin"; +import { addAdmin, getGradesConfig } from "api/admin"; import { selectAdmins, selectGradesByApplicant, setAdmins, setGrades, } from "./adminSlice"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect } from "react"; import { useDispatch, useSelector } from "react-redux"; +import { Grading } from "types/grade"; import useAxios from "api/useAxios"; interface UseGrades { @@ -18,53 +19,36 @@ interface UseGrades { } export function useGrades(applicantId: string): UseGrades { useAdmins(); + const [{ loading, data, error }] = useAxios( + getGradesConfig(applicantId) + ); const dispatch = useDispatch(); const grades = useSelector(selectGradesByApplicant(applicantId)); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(); - if (loading === false && Boolean(grades) === false) { - getGradesByApplicant(applicantId) - .then((grades) => { - setLoading(false); - dispatch(setGrades({ grades, applicantId })); - }) - .catch((err) => { - setError(err); - setLoading(false); - }); - setLoading(true); - } + useEffect(() => { + if (data && Boolean(grades) === false) + dispatch(setGrades({ grades: data, applicantId })); + }, [data]); return { loading, data: grades, error }; } -interface AdminInfo { - id: string; - email: string; - firstName: string; - lastName: string; - type: "SUPER_ADMIN" | "ADMIN"; - verified: boolean; - created: string; -} - interface UseHook { loading: boolean; error: any; data: T; } -interface UseAdmins extends UseHook { +interface UseAdmins extends UseHook { addAdmin: (admin: NewAdmin) => Promise; } export function useAdmins(): UseAdmins { - const [{ loading, data, error }] = useAxios({ + const [{ loading, data, error }] = useAxios({ url: "/admin", params: { skip: 0, limit: 20 }, }); const dispatch = useDispatch(); useEffect(() => { - if (data) dispatch(setAdmins(data)); + if (data && admins.length === 0) dispatch(setAdmins(data)); }, [data]); const admins = useSelector(selectAdmins); const newAdmin = useCallback( @@ -77,45 +61,3 @@ export function useAdmins(): UseAdmins { ); return { loading, data: admins, error, addAdmin: newAdmin }; } - -// export function useAdmins(): UseHook { -// const dispatch = useDispatch(); -// // const admins = useSelector(selectAdmins); -// const [{ loading, data, error }] = useAxios({ -// url: "/admin", -// params: { -// skip: 0, -// limit: 10, -// }, -// }); -// // useEffect(() => { -// // if (admins.length === 0 && data !== undefined) { -// // dispatch(setAdmins(data)); -// // } -// // }, [data]); -// return { -// loading, -// data: data ? data : [], -// error, -// }; -// } - -// export function useAdmins(): UseHook { -// const dispatch = useDispatch(); -// const admins = useSelector(selectAdmins); -// const [loading, setLoading] = useState(false); -// const [error, setError] = useState(); -// if (loading === false && admins.length === 0) { -// setLoading(true); -// getAdmins() -// .then((res) => { -// setLoading(false); -// dispatch(setAdmins(res)); -// }) -// .catch((err) => { -// setError(err); -// setLoading(false); -// }); -// } -// return { loading, data: admins, error }; -// } diff --git a/src/hooks/apiWithRedux.ts b/src/hooks/apiWithRedux.ts index 553dbc7..f5c8d13 100644 --- a/src/hooks/apiWithRedux.ts +++ b/src/hooks/apiWithRedux.ts @@ -1,14 +1,5 @@ -import { RootState } from "store"; -import { TypedUseSelectorHook } from "react-redux"; - -interface UseHook { +export interface UseHook { loading: boolean; - error: any; + error: E; data: T; } - -interface UseWithRedux { - selector: TypedUseSelectorHook; -} - -// export function useWithRedux(selector) {} From 2f075b63aad2d18627ff69e5a12faeb38dd471dc Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 5 Apr 2021 00:38:56 +0200 Subject: [PATCH 10/98] Flattened admin file structure --- src/features/admin/{index.tsx => AdminView.tsx} | 11 +++++------ .../{Administration/index.tsx => Administration.tsx} | 2 +- .../admin/{Grading/index.tsx => GradingView.tsx} | 12 ++++++------ .../Grade.tsx => OpenGradingModalButton.tsx} | 2 +- .../RandomiseOrder.tsx => RandomiseGradingOrder.tsx} | 2 +- .../admin/{TopList/index.tsx => TopList.tsx} | 4 ++-- src/features/admin/adminSlice.ts | 1 - .../admin/{Grading/grading.css => table.css} | 0 src/features/router/index.tsx | 2 +- src/types/grade.ts | 4 ++-- 10 files changed, 19 insertions(+), 21 deletions(-) rename src/features/admin/{index.tsx => AdminView.tsx} (82%) rename src/features/admin/{Administration/index.tsx => Administration.tsx} (98%) rename src/features/admin/{Grading/index.tsx => GradingView.tsx} (93%) rename src/features/admin/{Grading/Grade.tsx => OpenGradingModalButton.tsx} (99%) rename src/features/admin/{Grading/RandomiseOrder.tsx => RandomiseGradingOrder.tsx} (95%) rename src/features/admin/{TopList/index.tsx => TopList.tsx} (98%) rename src/features/admin/{Grading/grading.css => table.css} (100%) diff --git a/src/features/admin/index.tsx b/src/features/admin/AdminView.tsx similarity index 82% rename from src/features/admin/index.tsx rename to src/features/admin/AdminView.tsx index afef554..882af1c 100644 --- a/src/features/admin/index.tsx +++ b/src/features/admin/AdminView.tsx @@ -1,7 +1,6 @@ import React, { Suspense, lazy } from "react"; import { Route, Switch } from "react-router-dom"; -import Center from "components/Center"; import Delete from "features/portal/Delete"; import Logo from "components/Logo"; import Logout from "features/portal/Logout"; @@ -9,11 +8,11 @@ import Nav from "./Nav"; import Plate from "components/Plate"; import Spinner from "react-bootstrap/Spinner"; -const TopList = lazy(() => import("features/admin/TopList")); +const TopList = lazy(() => import("./TopList")); const NoMatch = lazy(() => import("features/nomatch")); -const Administration = lazy(() => import("features/admin/Administration")); -const Statistics = lazy(() => import("features/admin/Statistics")); -const Grading = lazy(() => import("features/admin/Grading")); +const Administration = lazy(() => import("./Administration")); +const Statistics = lazy(() => import("./Statistics")); +const GradingView = lazy(() => import("./GradingView")); const Admin: React.FC = () => (
@@ -38,7 +37,7 @@ const Admin: React.FC = () => ( > - + diff --git a/src/features/admin/Administration/index.tsx b/src/features/admin/Administration.tsx similarity index 98% rename from src/features/admin/Administration/index.tsx rename to src/features/admin/Administration.tsx index ab2e5c0..3cbc0f0 100644 --- a/src/features/admin/Administration/index.tsx +++ b/src/features/admin/Administration.tsx @@ -4,7 +4,7 @@ import AddButton from "components/AddButton"; import AdminContact from "components/AdminContact"; import { Spinner } from "react-bootstrap"; import { selectUserType } from "features/auth/authSlice"; -import { useAdmins } from "../adminHooks"; +import { useAdmins } from "./adminHooks"; import { useSelector } from "react-redux"; const Administration: React.FC = () => { diff --git a/src/features/admin/Grading/index.tsx b/src/features/admin/GradingView.tsx similarity index 93% rename from src/features/admin/Grading/index.tsx rename to src/features/admin/GradingView.tsx index d3a46ef..5838021 100644 --- a/src/features/admin/Grading/index.tsx +++ b/src/features/admin/GradingView.tsx @@ -1,4 +1,4 @@ -import "./grading.css"; +import "./table.css"; import { ConnectedProps, connect } from "react-redux"; import { @@ -6,13 +6,13 @@ import { selectApplicationsByGradingOrder, setApplications, updateGradingOrder, -} from "../adminSlice"; +} from "./adminSlice"; import BootstrapTable from "react-bootstrap-table-next"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import Grade from "./Grade"; +import OpenGradingModalButton from "./OpenGradingModalButton"; import OpenPDF from "components/portal/OpenPDF"; -import RandomiseOrder from "./RandomiseOrder"; +import RandomiseGradingOrder from "./RandomiseGradingOrder"; import React from "react"; import { RootState } from "store"; import Spinner from "react-bootstrap/Spinner"; @@ -91,7 +91,7 @@ class Grading extends React.Component { text: "Bedöm", isDummyField: true, formatter: (id: string, row: any) => ( - { För att börja bedöma eller se nya ansökningar behöver du slumpa ordningen.

- +
import("features/auth/login/LoginWithCodeRoute") ); const GDPR = lazy(() => import("features/GDPR")); -const AdminPortal = lazy(() => import("features/admin")); +const AdminPortal = lazy(() => import("features/admin/AdminView")); const AppRouter: React.FC = () => ( diff --git a/src/types/grade.ts b/src/types/grade.ts index 78d7c77..b567056 100644 --- a/src/types/grade.ts +++ b/src/types/grade.ts @@ -6,11 +6,11 @@ export type NumericalGradeField = | "recommendations" | "overall"; -export type Grades = Record & { +export type ApplicationGrade = Record & { comment: string; }; -export interface Grading extends Grades { +export interface Grading extends ApplicationGrade { applicantId: string; adminId: string; id: string; From 3eba4b876ea06d0b817e57fa92472f9b1bbf24be Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 5 Apr 2021 01:16:28 +0200 Subject: [PATCH 11/98] Improved admin grading types and added useApi --- src/api/admin.ts | 14 +++- src/api/useAxios.ts | 8 --- src/features/admin/GradingView.tsx | 12 ++-- src/features/admin/adminHooks.ts | 13 ++-- src/features/admin/adminSlice.ts | 109 ++++++++--------------------- src/hooks/apiWithRedux.ts | 5 -- src/hooks/useApi.ts | 11 +++ src/types/grade.ts | 36 +++++++++- 8 files changed, 94 insertions(+), 114 deletions(-) delete mode 100644 src/api/useAxios.ts delete mode 100644 src/hooks/apiWithRedux.ts create mode 100644 src/hooks/useApi.ts diff --git a/src/api/admin.ts b/src/api/admin.ts index 9f6ca46..c03951b 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -1,6 +1,6 @@ import { Admin, NewAdmin } from "types/user"; +import { Application, IndividualGrading, OrderItem } from "types/grade"; -import { Grading } from "types/grade"; import api from "./axios"; export const getAdmins = (): Promise => @@ -9,8 +9,16 @@ export const getAdmins = (): Promise => export const getGradesConfig = (applicantID: string): string => `/application/${applicantID}/grade`; -export const getGradesByApplicant = (applicantID: string): Promise => - api.format.get(getGradesConfig(applicantID)); +export const getGradesByApplicant = ( + applicantID: string +): Promise => + api.format.get(getGradesConfig(applicantID)); + +export const getApplications = (): Promise => + api.format.get("/application"); + +export const getGradingOrder = (): Promise => + api.format.get("/admin/grading"); export const addAdmin = (admin: NewAdmin): Promise => api.format.post("/admin", admin); diff --git a/src/api/useAxios.ts b/src/api/useAxios.ts deleted file mode 100644 index 9024f3f..0000000 --- a/src/api/useAxios.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { api } from "./axios"; -import { makeUseAxios } from "axios-hooks"; - -export const useAxios = makeUseAxios({ - axios: api, -}); - -export default useAxios; diff --git a/src/features/admin/GradingView.tsx b/src/features/admin/GradingView.tsx index 5838021..5b73faa 100644 --- a/src/features/admin/GradingView.tsx +++ b/src/features/admin/GradingView.tsx @@ -1,8 +1,8 @@ import "./table.css"; import { ConnectedProps, connect } from "react-redux"; +import { getApplications, getGradingOrder } from "api/admin"; import { - OrderItem, selectApplicationsByGradingOrder, setApplications, updateGradingOrder, @@ -16,7 +16,6 @@ import RandomiseGradingOrder from "./RandomiseGradingOrder"; import React from "react"; import { RootState } from "store"; import Spinner from "react-bootstrap/Spinner"; -import axios from "api/axios"; import { downloadAndOpen } from "api/downloadPDF"; import { faFileDownload } from "@fortawesome/free-solid-svg-icons"; @@ -44,18 +43,17 @@ class Grading extends React.Component { componentDidMount() { if (Boolean(this.props.applications.length) === false) { - axios.get("/application").then((res) => { - this.props.setApplications(res.data); + getApplications().then((res) => { + this.props.setApplications(res); }); - axios.get("/admin/grading").then((res) => { - this.props.updateGradingOrder(res.data); + getGradingOrder().then((res) => { + this.props.updateGradingOrder(res); this.setState({ loading: [this.state.loading[0], false] }); }); } } render() { - const loading = this.state.loading[0] || this.state.loading[1]; const dataWithIndex = this.props.applications.map((application, index) => ({ ...application, index, diff --git a/src/features/admin/adminHooks.ts b/src/features/admin/adminHooks.ts index 26dffff..8bc1ea6 100644 --- a/src/features/admin/adminHooks.ts +++ b/src/features/admin/adminHooks.ts @@ -9,17 +9,12 @@ import { import { useCallback, useEffect } from "react"; import { useDispatch, useSelector } from "react-redux"; -import { Grading } from "types/grade"; -import useAxios from "api/useAxios"; +import { IndividualGrading } from "types/grade"; +import useApi from "hooks/useApi"; -interface UseGrades { - loading: boolean; - data: any; - error: any; -} export function useGrades(applicantId: string): UseGrades { useAdmins(); - const [{ loading, data, error }] = useAxios( + const [{ loading, data, error }] = useApi( getGradesConfig(applicantId) ); const dispatch = useDispatch(); @@ -42,7 +37,7 @@ interface UseAdmins extends UseHook { } export function useAdmins(): UseAdmins { - const [{ loading, data, error }] = useAxios({ + const [{ loading, data, error }] = useApi({ url: "/admin", params: { skip: 0, limit: 20 }, }); diff --git a/src/features/admin/adminSlice.ts b/src/features/admin/adminSlice.ts index c28c6d9..3331c0a 100644 --- a/src/features/admin/adminSlice.ts +++ b/src/features/admin/adminSlice.ts @@ -1,76 +1,24 @@ +import { + Application, + GradedApplication, + IndividualGrading, + IndividualGradingWithName, + OrderItem, + TopOrderItem, +} from "types/grade"; /* eslint-disable camelcase */ /* eslint-disable no-param-reassign */ import { PayloadAction, createSlice } from "@reduxjs/toolkit"; +import { Admin } from "types/user"; import { RootState } from "store"; -export interface TopOrderItem { - applicantId: string; - score: number; -} - -export interface OrderItem { - id: string; - adminId: string; - applicantId: string; - gradingOrder: number; - done: boolean; -} - -type NumericalGradeField = - | "cv" - | "coverLetter" - | "essays" - | "grades" - | "recommendations" - | "overall"; - -export type GradeFormValues = Record & { - comment: string; -}; - -type GradingField = - | "cv" - | "coverLetter" - | "grades" - | "recommendations" - | "overall"; - -interface ApplicationBaseInfo { - id: string; - email: string; - firstName: string; - lastName: string; - finnish: boolean; - birthdate: string; - city: string; - school: string; -} - -export type ApplicationInfo = Partial & ApplicationBaseInfo; - -interface AdminInfo { - id: string; - email: string; - firstName: string; - lastName: string; - type: "SUPER_ADMIN" | "ADMIN"; - verified: boolean; - created: string; -} - -export interface Grading extends GradeFormValues { - applicantId: string; - adminId: string; - id: string; -} - interface AdminState { gradingOrder: OrderItem[]; topOrder: TopOrderItem[]; - applications: Record; - admins: Record; - grades: Record; + applications: Record; + admins: Record; + grades: Record; } export const initialState: AdminState = { @@ -85,7 +33,10 @@ const adminSlice = createSlice({ name: "auth", initialState, reducers: { - setApplications(state, action: PayloadAction) { + setApplications( + state, + action: PayloadAction<(GradedApplication | Application)[]> + ) { action.payload.forEach( (applicant) => (state.applications[applicant.id] = applicant) ); @@ -93,9 +44,9 @@ const adminSlice = createSlice({ .map((applicantId) => { const application = state.applications[ applicantId - ] as ApplicationInfo; + ] as GradedApplication; let score = 0; - if (application?.cv) { + if (application.cv !== undefined) { score = (application?.cv as number) + (application?.coverLetter as number) + @@ -108,7 +59,7 @@ const adminSlice = createSlice({ .sort((a, b) => b.score - a.score); state.topOrder = topOrder; }, - setAdmins(state, action: PayloadAction) { + setAdmins(state, action: PayloadAction) { action.payload.forEach((admin) => { state.admins[admin.id] = admin; }); @@ -118,11 +69,14 @@ const adminSlice = createSlice({ }, setGrades( state, - action: PayloadAction<{ grades: Grading[]; applicantId: string }> + action: PayloadAction<{ + grades: IndividualGrading[]; + applicantId: string; + }> ) { state.grades[action.payload.applicantId] = action.payload.grades; }, - setMyGrade(state, action: PayloadAction) { + setMyGrade(state, action: PayloadAction) { const gradeIndex = state.grades[action.payload.applicantId].findIndex( (grade) => grade.adminId === action.payload.adminId ); @@ -138,22 +92,17 @@ const adminSlice = createSlice({ export const selectGradingOrder = (state: RootState): OrderItem[] => state.admin.gradingOrder; -export const selectAdmins = (state: RootState): AdminInfo[] => +export const selectAdmins = (state: RootState): Admin[] => Object.keys(state.admin.admins).map((adminID) => state.admin.admins[adminID]); -export const selectApplicationsByTop = (state: RootState): ApplicationInfo[] => +export const selectApplicationsByTop = (state: RootState): Application[] => state.admin.topOrder.map( (orderItem) => state.admin.applications[orderItem.applicantId] ); -interface GradingData extends GradeFormValues { - firstName: string; - lastName: string; -} - export const selectGradesByApplicant = (userID: string) => ( state: RootState -): GradingData[] | undefined => +): IndividualGradingWithName[] => state.admin.grades[userID]?.map((grade) => ({ ...grade, firstName: state.admin.admins[grade.adminId]?.firstName, @@ -163,7 +112,7 @@ export const selectGradesByApplicant = (userID: string) => ( export const selectMyGrading = ( state: RootState, id: string -): GradingData | undefined => { +): IndividualGradingWithName | undefined => { const relevantGrades = state.admin.grades[id]; if (relevantGrades) { const myGrading = relevantGrades.find( @@ -188,7 +137,7 @@ export const selectMyGrading = ( export const selectApplicationsByGradingOrder = ( state: RootState -): ApplicationInfo[] => +): Application[] => state.admin.gradingOrder .map((orderItem) => ({ ...state.admin.applications[orderItem.applicantId], diff --git a/src/hooks/apiWithRedux.ts b/src/hooks/apiWithRedux.ts deleted file mode 100644 index f5c8d13..0000000 --- a/src/hooks/apiWithRedux.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface UseHook { - loading: boolean; - error: E; - data: T; -} diff --git a/src/hooks/useApi.ts b/src/hooks/useApi.ts new file mode 100644 index 0000000..d596162 --- /dev/null +++ b/src/hooks/useApi.ts @@ -0,0 +1,11 @@ +import { UseAxios, makeUseAxios } from "axios-hooks"; + +import { api as axios } from "api/axios"; + +export const useApi = makeUseAxios({ + axios, +}); + +export type UseApi = UseAxios; + +export default useApi; diff --git a/src/types/grade.ts b/src/types/grade.ts index b567056..d884f87 100644 --- a/src/types/grade.ts +++ b/src/types/grade.ts @@ -1,3 +1,11 @@ +import { Admin, Applicant } from "./user"; + +export interface Application extends Applicant { + city: string; + school: string; + done?: boolean; +} + export type NumericalGradeField = | "cv" | "coverLetter" @@ -6,12 +14,36 @@ export type NumericalGradeField = | "recommendations" | "overall"; -export type ApplicationGrade = Record & { +export type ApplicationGrade = { comment: string; + cv: number; + coverLetter: number; + essays: number; + grades: number; + recommendations: number; + overall: number; }; -export interface Grading extends ApplicationGrade { +export interface IndividualGrading extends ApplicationGrade { applicantId: string; adminId: string; id: string; } + +export type IndividualGradingWithName = ApplicationGrade & + Pick; + +export type GradedApplication = ApplicationGrade & Application; + +export interface TopOrderItem { + applicantId: string; + score: number; +} + +export interface OrderItem { + id: string; + adminId: string; + applicantId: string; + gradingOrder: number; + done: boolean; +} From bd1d8b111a950088a1a52b7543b59dddb36824b6 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 5 Apr 2021 01:47:16 +0200 Subject: [PATCH 12/98] Restructured admi requests to api structure --- src/api/admin.ts | 12 +++++++++--- src/features/admin/OpenGradingModalButton.tsx | 7 ++++--- src/features/admin/TopList.tsx | 12 ++++-------- src/features/admin/adminHooks.ts | 17 ++++++----------- src/features/admin/adminSlice.ts | 4 +++- src/hooks/useApi.ts | 10 +++++++--- src/types/grade.ts | 11 ++++++----- 7 files changed, 39 insertions(+), 34 deletions(-) diff --git a/src/api/admin.ts b/src/api/admin.ts index c03951b..3d79fbe 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -1,5 +1,10 @@ import { Admin, NewAdmin } from "types/user"; -import { Application, IndividualGrading, OrderItem } from "types/grade"; +import { + Application, + GradedApplication, + IndividualGrading, + OrderItem, +} from "types/grade"; import api from "./axios"; @@ -14,8 +19,9 @@ export const getGradesByApplicant = ( ): Promise => api.format.get(getGradesConfig(applicantID)); -export const getApplications = (): Promise => - api.format.get("/application"); +export const getApplications = (): Promise< + (Application | GradedApplication)[] +> => api.format.get("/application"); export const getGradingOrder = (): Promise => api.format.get("/admin/grading"); diff --git a/src/features/admin/OpenGradingModalButton.tsx b/src/features/admin/OpenGradingModalButton.tsx index 379778c..10d72bc 100644 --- a/src/features/admin/OpenGradingModalButton.tsx +++ b/src/features/admin/OpenGradingModalButton.tsx @@ -1,7 +1,7 @@ import { Button, Modal, Spinner } from "react-bootstrap"; -import { Grading, selectMyGrading, setGrades, setMyGrade } from "./adminSlice"; import GradingModal, { GradeFormValues } from "components/GradingModal"; import React, { useState } from "react"; +import { selectMyGrading, setGrades, setMyGrade } from "./adminSlice"; import { useDispatch, useSelector } from "react-redux"; import { ButtonProps } from "react-bootstrap/Button"; @@ -9,6 +9,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { RootState } from "store"; import axios from "api/axios"; import { faEdit } from "@fortawesome/free-solid-svg-icons"; +import { getGradesByApplicant } from "api/admin"; interface GradeProps { id: string; @@ -29,8 +30,8 @@ const Grade: React.FC = ({ id, variant = "primary" }) => { setOpen(!open); if (!open && gradingData === undefined) { setLoading(true); - axios.get(`/application/${id}/grade`).then((res) => { - dispatch(setGrades({ grades: res.data, applicantId: id })); + getGradesByApplicant(id).then((grades) => { + dispatch(setGrades({ grades, applicantId: id })); setLoading(false); }); } diff --git a/src/features/admin/TopList.tsx b/src/features/admin/TopList.tsx index 9f043c9..6dd32ac 100644 --- a/src/features/admin/TopList.tsx +++ b/src/features/admin/TopList.tsx @@ -1,9 +1,5 @@ -import { - ApplicationInfo, - selectApplicationsByTop, - setApplications, -} from "./adminSlice"; import { ConnectedProps, connect } from "react-redux"; +import { selectApplicationsByTop, setApplications } from "./adminSlice"; import BootstrapTable from "react-bootstrap-table-next"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -12,9 +8,9 @@ import OpenPDF from "components/portal/OpenPDF"; import React from "react"; import { RootState } from "store"; import Spinner from "react-bootstrap/Spinner"; -import axios from "api/axios"; import { downloadAndOpen } from "api/downloadPDF"; import { faFileDownload } from "@fortawesome/free-solid-svg-icons"; +import { getApplications } from "api/admin"; import { useGrades } from "./adminHooks"; interface GradingDataRowProps { @@ -39,8 +35,8 @@ class TopList extends React.Component { componentDidMount() { if (Boolean(this.props.applications.length) === false) - axios.get("/application").then((res) => { - this.props.setApplications(res.data); + getApplications().then((applications) => { + this.props.setApplications(applications); this.setState({ loading: false }); }); } diff --git a/src/features/admin/adminHooks.ts b/src/features/admin/adminHooks.ts index 8bc1ea6..db691fe 100644 --- a/src/features/admin/adminHooks.ts +++ b/src/features/admin/adminHooks.ts @@ -1,4 +1,5 @@ import { Admin, NewAdmin } from "types/user"; +import { IndividualGrading, IndividualGradingWithName } from "types/grade"; import { addAdmin, getGradesConfig } from "api/admin"; import { selectAdmins, @@ -6,13 +7,13 @@ import { setAdmins, setGrades, } from "./adminSlice"; +import useApi, { UseApi } from "hooks/useApi"; import { useCallback, useEffect } from "react"; import { useDispatch, useSelector } from "react-redux"; -import { IndividualGrading } from "types/grade"; -import useApi from "hooks/useApi"; - -export function useGrades(applicantId: string): UseGrades { +export function useGrades( + applicantId: string +): UseApi { useAdmins(); const [{ loading, data, error }] = useApi( getGradesConfig(applicantId) @@ -26,13 +27,7 @@ export function useGrades(applicantId: string): UseGrades { return { loading, data: grades, error }; } -interface UseHook { - loading: boolean; - error: any; - data: T; -} - -interface UseAdmins extends UseHook { +interface UseAdmins extends UseApi { addAdmin: (admin: NewAdmin) => Promise; } diff --git a/src/features/admin/adminSlice.ts b/src/features/admin/adminSlice.ts index 3331c0a..bf856d3 100644 --- a/src/features/admin/adminSlice.ts +++ b/src/features/admin/adminSlice.ts @@ -95,7 +95,9 @@ export const selectGradingOrder = (state: RootState): OrderItem[] => export const selectAdmins = (state: RootState): Admin[] => Object.keys(state.admin.admins).map((adminID) => state.admin.admins[adminID]); -export const selectApplicationsByTop = (state: RootState): Application[] => +export const selectApplicationsByTop = ( + state: RootState +): (Application | GradedApplication)[] => state.admin.topOrder.map( (orderItem) => state.admin.applications[orderItem.applicantId] ); diff --git a/src/hooks/useApi.ts b/src/hooks/useApi.ts index d596162..561fbea 100644 --- a/src/hooks/useApi.ts +++ b/src/hooks/useApi.ts @@ -1,11 +1,15 @@ -import { UseAxios, makeUseAxios } from "axios-hooks"; - import { api as axios } from "api/axios"; +import { makeUseAxios } from "axios-hooks"; export const useApi = makeUseAxios({ axios, }); -export type UseApi = UseAxios; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface UseApi { + loading: boolean; + data: T; + error: E; +} export default useApi; diff --git a/src/types/grade.ts b/src/types/grade.ts index d884f87..66f3fc4 100644 --- a/src/types/grade.ts +++ b/src/types/grade.ts @@ -1,10 +1,11 @@ import { Admin, Applicant } from "./user"; -export interface Application extends Applicant { - city: string; - school: string; - done?: boolean; -} +export type Application = Applicant & + Partial & { + city: string; + school: string; + done?: boolean; + }; export type NumericalGradeField = | "cv" From 8885416b27aacf7233017b887d14bd53eef194e9 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 5 Apr 2021 02:37:52 +0200 Subject: [PATCH 13/98] Moved files and fixed errors --- src/App.tsx | 22 +-- src/api/admin.ts | 7 + src/components/AdminContact/index.tsx | 4 +- src/components/Background.tsx | 17 ++ .../{portal => }/Chapter/index.stories.jsx | 0 src/components/Chapter/index.tsx | 35 ++++ .../ContactPerson/index.stories.jsx | 0 src/components/ContactPerson/index.tsx | 115 +++++++++++ .../GradingData/{index.jsx => index.tsx} | 13 +- src/components/GradingModal/index.tsx | 2 +- src/components/Logo.tsx | 1 - src/components/Rating/index.tsx | 2 +- src/components/Survey/index.tsx | 6 +- src/components/portal/Chapter/index.css | 178 ------------------ src/components/portal/Chapter/index.jsx | 47 ----- src/components/portal/ContactPerson/index.jsx | 110 ----------- src/components/portal/Upload/index.tsx | 26 +-- src/features/ChangeLanguage.tsx | 16 +- src/features/admin/GradingView.tsx | 5 +- src/features/admin/OpenGradingModalButton.tsx | 18 +- src/features/admin/Statistics.tsx | 114 ++--------- src/features/admin/TopList.tsx | 10 +- src/features/admin/adminHooks.ts | 46 +++++ src/features/admin/adminSlice.ts | 5 +- src/features/auth/AuthenticatedLayer.tsx | 1 - src/features/auth/login/LoginWithCode.tsx | 2 +- src/features/nomatch/index.tsx | 4 +- src/features/portal/Chapters/index.tsx | 2 +- src/features/portal/Delete.tsx | 2 +- src/features/portal/Download.tsx | 2 +- src/features/portal/References/index.tsx | 7 +- src/features/portal/Survey/index.tsx | 2 +- src/features/portal/index.tsx | 2 +- src/types/survey.ts | 29 +++ src/utils/average.ts | 9 + src/utils/tokenInterceptor.ts | 38 ++-- 36 files changed, 347 insertions(+), 552 deletions(-) create mode 100644 src/components/Background.tsx rename src/components/{portal => }/Chapter/index.stories.jsx (100%) create mode 100644 src/components/Chapter/index.tsx rename src/components/{portal => }/ContactPerson/index.stories.jsx (100%) create mode 100644 src/components/ContactPerson/index.tsx rename src/components/GradingData/{index.jsx => index.tsx} (78%) delete mode 100644 src/components/portal/Chapter/index.css delete mode 100644 src/components/portal/Chapter/index.jsx delete mode 100644 src/components/portal/ContactPerson/index.jsx create mode 100644 src/types/survey.ts create mode 100644 src/utils/average.ts diff --git a/src/App.tsx b/src/App.tsx index 6d7b45a..762f709 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import "react-toastify/dist/ReactToastify.min.css"; import store, { persistor } from "./store"; import AuthenticatedLayer from "features/auth/AuthenticatedLayer"; +import Background from "components/Background"; import ChangeLanguage from "features/ChangeLanguage"; import CustomThemeProvider from "components/CustomThemeProvider"; import DevelopedBy from "components/DevelopedBy"; @@ -13,36 +14,21 @@ import { Provider } from "react-redux"; import React from "react"; import Router from "features/router"; import { ToastContainer } from "react-toastify"; -import styled from "styled-components"; -const StyledApp = styled.div` - background: ${(props) => props.theme.bg}; - - height: 100%; - width: 100%; - overflow: auto; - - #centered { - width: 100%; - min-height: calc(100% - 2.5rem); - display: table; - } -`; - -function App() { +function App(): React.ReactElement { return ( - +
-
+
diff --git a/src/api/admin.ts b/src/api/admin.ts index 3d79fbe..1a7a626 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -1,6 +1,7 @@ import { Admin, NewAdmin } from "types/user"; import { Application, + ApplicationGrade, GradedApplication, IndividualGrading, OrderItem, @@ -26,5 +27,11 @@ export const getApplications = (): Promise< export const getGradingOrder = (): Promise => api.format.get("/admin/grading"); +export const postApplicationGrade = ( + applicantID: string, + form: ApplicationGrade +): Promise => + api.format.post(`/application/${applicantID}/grade`, form); + export const addAdmin = (admin: NewAdmin): Promise => api.format.post("/admin", admin); diff --git a/src/components/AdminContact/index.tsx b/src/components/AdminContact/index.tsx index aff4dd4..4ffdd7f 100644 --- a/src/components/AdminContact/index.tsx +++ b/src/components/AdminContact/index.tsx @@ -6,8 +6,8 @@ import { InputGroup, Spinner, } from "react-bootstrap"; +import { Formik, FormikErrors } from "formik"; -import { Formik } from "formik"; import React from "react"; import styled from "styled-components"; @@ -43,7 +43,7 @@ interface AdminContactFields { interface AdminContactProps extends Partial { status?: "VERIFIED" | "REQUESTED" | "LOADING"; - initialErrors?: any; + initialErrors?: FormikErrors; onSubmit?: (values: AdminContactFields) => Promise; } diff --git a/src/components/Background.tsx b/src/components/Background.tsx new file mode 100644 index 0000000..c9ce732 --- /dev/null +++ b/src/components/Background.tsx @@ -0,0 +1,17 @@ +import styled from "styled-components"; + +const Background = styled.div` + background: ${(props) => props.theme.bg}; + + height: 100%; + width: 100%; + overflow: auto; + + #centered { + width: 100%; + min-height: calc(100% - 2.5rem); + display: table; + } +`; + +export default Background; diff --git a/src/components/portal/Chapter/index.stories.jsx b/src/components/Chapter/index.stories.jsx similarity index 100% rename from src/components/portal/Chapter/index.stories.jsx rename to src/components/Chapter/index.stories.jsx diff --git a/src/components/Chapter/index.tsx b/src/components/Chapter/index.tsx new file mode 100644 index 0000000..ccb3732 --- /dev/null +++ b/src/components/Chapter/index.tsx @@ -0,0 +1,35 @@ +import "bootstrap/dist/css/bootstrap.min.css"; + +import React from "react"; +import ReactMarkdown from "react-markdown"; + +interface ChapterProps { + title: string; + subtitle: string; + description: string; +} + +const Chapter: React.FC = ({ + title, + subtitle, + description, + children, +}) => { + return ( +
+

{title}

+

{subtitle}

+
+ +
+
{children}
+
+
+ ); +}; + +export default Chapter; diff --git a/src/components/portal/ContactPerson/index.stories.jsx b/src/components/ContactPerson/index.stories.jsx similarity index 100% rename from src/components/portal/ContactPerson/index.stories.jsx rename to src/components/ContactPerson/index.stories.jsx diff --git a/src/components/ContactPerson/index.tsx b/src/components/ContactPerson/index.tsx new file mode 100644 index 0000000..1404847 --- /dev/null +++ b/src/components/ContactPerson/index.tsx @@ -0,0 +1,115 @@ +import { Form, Spinner } from "react-bootstrap"; + +import Button from "react-bootstrap/Button"; +import FormControl from "react-bootstrap/FormControl"; +import { Formik } from "formik"; +import InputGroup from "react-bootstrap/InputGroup"; +import React from "react"; +import moment from "moment"; +import styled from "styled-components"; +import { useTranslation } from "react-i18next"; + +const StyledInputGroup = styled(InputGroup)` + &.received input, + &.received span { + /* green */ + color: #155724; + background-color: #d4edda; + border-color: rgb(40, 167, 69); + } + + &.requested .input-group-append span { + color: #1a237e; + background-color: #c5cae9; + } +`; + +interface ContactPersonProps { + email?: string; + loading?: boolean; + sendDate?: string; + onSubmit: (email: string) => void; + received?: boolean; + disabled?: boolean; +} + +function ContactPerson({ + email, + loading, + sendDate = "1970-01-01", + onSubmit, + received = false, + disabled, +}: ContactPersonProps): React.ReactElement { + // https://stackoverflow.com/questions/13262621/how-do-i-use-format-on-a-moment-js-duration + const diff = moment(sendDate).add("day", 1).diff(moment()); + const formattedDiff = + diff > 3600 * 1000 + ? Math.round(diff / (3600 * 1000)) + : Math.round(diff / (1000 * 60)); + + const { t } = useTranslation(); + const status = email ? (received ? "received" : "requested") : "nothing"; + const text = { + nothing: t("Not requested"), + requested: t("Requested"), + received: t("Letter received"), + }; + + const button = { + nothing: t("Send request"), + requested: t("Send again"), + }; + + return ( + { + if (values.email) onSubmit(values.email); + setSubmitting(false); + }} + > + {({ values, errors, handleChange, handleSubmit }) => ( +
+ + {loading && ( + + + + + + )} + 0 || loading || disabled)} + placeholder="E-mail" + required + /> + + {text[status]} + {status !== "received" && ( + + )} + + +
+ )} +
+ ); +} + +export default ContactPerson; diff --git a/src/components/GradingData/index.jsx b/src/components/GradingData/index.tsx similarity index 78% rename from src/components/GradingData/index.jsx rename to src/components/GradingData/index.tsx index cecda41..4cc839e 100644 --- a/src/components/GradingData/index.jsx +++ b/src/components/GradingData/index.tsx @@ -1,7 +1,14 @@ +import { IndividualGradingWithName } from "types/grade"; import React from "react"; import { Table } from "react-bootstrap"; -const GradingData = ({ applicationGrades = [] }) => ( +interface GradingDataProps { + applicationGrades?: IndividualGradingWithName[]; +} + +const GradingData = ({ + applicationGrades = [], +}: GradingDataProps): React.ReactElement => ( <> @@ -17,7 +24,7 @@ const GradingData = ({ applicationGrades = [] }) => ( {applicationGrades.map((grade) => ( - + @@ -40,7 +47,7 @@ const GradingData = ({ applicationGrades = [] }) => ( {applicationGrades.map( (grade) => Boolean(grade.comment) && ( - + diff --git a/src/components/GradingModal/index.tsx b/src/components/GradingModal/index.tsx index dcab5bd..d8a6fbc 100644 --- a/src/components/GradingModal/index.tsx +++ b/src/components/GradingModal/index.tsx @@ -50,7 +50,7 @@ export type GradeFormValues = Record & { interface GradingModalProps extends WithTranslation { name?: string; initialValues?: GradeFormValues; - onSubmit?: (values: GradeFormValues) => Promise; + onSubmit?: (values: GradeFormValues) => Promise; } const GradingModal: React.FC = ({ diff --git a/src/components/Logo.tsx b/src/components/Logo.tsx index 3fa1b35..c532d1f 100644 --- a/src/components/Logo.tsx +++ b/src/components/Logo.tsx @@ -1,4 +1,3 @@ -import { Properties as CSSProperties } from "csstype"; import React from "react"; import logo from "resources/rays.png"; import styled from "styled-components"; diff --git a/src/components/Rating/index.tsx b/src/components/Rating/index.tsx index 098e7cd..47b50f5 100644 --- a/src/components/Rating/index.tsx +++ b/src/components/Rating/index.tsx @@ -19,7 +19,7 @@ const Star = styled(Icon)` margin-top: -3px; `; -const Rating = (props: RatingComponentProps) => ( +const Rating = (props: RatingComponentProps): React.ReactElement => ( } diff --git a/src/components/Survey/index.tsx b/src/components/Survey/index.tsx index fc7a84c..e56594c 100644 --- a/src/components/Survey/index.tsx +++ b/src/components/Survey/index.tsx @@ -64,7 +64,11 @@ interface SurveyProps { disabled?: boolean; } -const Survey = ({ survey, onSubmit, disabled }: SurveyProps) => { +const Survey = ({ + survey, + onSubmit, + disabled, +}: SurveyProps): React.ReactElement => { const { t } = useTranslation(); const initialValues = { diff --git a/src/components/portal/Chapter/index.css b/src/components/portal/Chapter/index.css deleted file mode 100644 index c4839c4..0000000 --- a/src/components/portal/Chapter/index.css +++ /dev/null @@ -1,178 +0,0 @@ -.section { - padding: 20px 0; - border-bottom: 1px solid #ddd; -} - -.upload { - margin-top: 20px; - margin: 20px auto 0 auto; - width: 95%; -} - -.buttons { - margin-top: 30px; - width: 100%; - display: flex; - justify-content: space-between; - flex-wrap: wrap; -} - -.btn-group { - margin: 0 auto 30px auto; - min-width: 250px; - vertical-align: top; -} - -.recommendation-senders { - margin-top: 20px; -} - -.input-group.row { - width: 100%; - margin: 20px auto; -} - -.input-group.row div { - padding: 0; -} - -.input-group.row input, .input-group.row span, .input-group.row button { - width: 100%; -} - -.input-group.row input { - border-top-right-radius: 0px; - border-bottom-right-radius: 0px; - border-right: 0px; -} - -.input-group.row span { - border-radius: 0px; -} - -.input-group.row button { - border-top-left-radius: 0px; - border-bottom-left-radius: 0px; -} - -@media screen and (max-width: 600px) { - .content { - margin: 0; - width: 100%; - } -} - -@media screen and (max-width: 768px) { - .input-group.row input { - border-right: 1px solid #ced4da; - border-bottom: 0px; - border-radius: 4px; - border-bottom-right-radius: 0px; - border-bottom-left-radius: 0px; - } - - .input-group.row span { - border-radius: 0px; - } - - .input-group.row button { - border-radius: 4px; - border-top-right-radius: 0px; - border-top-left-radius: 0px; - } -} - -.custom-file-label.success { - color: #155724; - background-color: #d4edda; - border-color: #c3e6cb; -} - -.custom-label { - pointer-events: none; -} - -.error { - margin-top: 7px; - text-align: right; - position: relative; - font-size: 12px; - color: red; -} - -.file-input { - cursor: pointer; -} - -.ratingContainer { - margin-top: 20px; -} - -.startContainer { - height: 25px; -} - -.rating { - position: relative; - display: inline-block; - line-height: 25px; - font-size: 25px; - bottom: 12px; -} - -.rating label { - position: absolute; - top: 0; - left: 0; - cursor: pointer; -} - -.rating label:last-child { - position: static; -} - -.rating label:nth-child(1) { - z-index: 5; -} - -.rating label:nth-child(2) { - z-index: 4; -} - -.rating label:nth-child(3) { - z-index: 3; -} - -.rating label:nth-child(4) { - z-index: 2; -} - -.rating label:nth-child(5) { - z-index: 1; -} - -.rating label input { - position: absolute; - top: 0; - left: 0; - opacity: 0; -} - -.rating label .icon { - float: left; - color: transparent; -} - -.rating label:last-child .icon { - color: #000; -} - -.rating:not(:hover) label input:checked~.icon, -.rating:hover label:hover input~.icon { - color: #DC0C05; -} - -.rating label input:focus:not(:checked)~.icon:last-child { - color: #000; - text-shadow: 0 0 5px #DC0C05; -} \ No newline at end of file diff --git a/src/components/portal/Chapter/index.jsx b/src/components/portal/Chapter/index.jsx deleted file mode 100644 index c5864ed..0000000 --- a/src/components/portal/Chapter/index.jsx +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react'; -import 'bootstrap/dist/css/bootstrap.min.css'; -import PropTypes from 'prop-types'; -import ReactMarkdown from 'react-markdown'; - -function Chapter({ - title, - subtitle, - description, - children, -}) { - return ( -
-

- {title} -

-

{subtitle}

-
- -
-
- {children} -
-
-
- ); -} - -Chapter.propTypes = { - title: PropTypes.string, - subtitle: PropTypes.string, - description: PropTypes.string, -}; - -Chapter.defaultProps = { - title: null, - subtitle: null, - description: null, -}; - -export default Chapter; diff --git a/src/components/portal/ContactPerson/index.jsx b/src/components/portal/ContactPerson/index.jsx deleted file mode 100644 index 3b16cad..0000000 --- a/src/components/portal/ContactPerson/index.jsx +++ /dev/null @@ -1,110 +0,0 @@ -import { Form, Spinner } from "react-bootstrap"; - -import Button from "react-bootstrap/Button"; -import FormControl from "react-bootstrap/FormControl"; -import InputGroup from "react-bootstrap/InputGroup"; -import React from "react"; -import moment from "moment"; -import styled from "styled-components"; -import { useTranslation } from "react-i18next"; - -const StyledInputGroup = styled(InputGroup)` - &.received input, - &.received span { - /* green */ - color: #155724; - background-color: #d4edda; - border-color: rgb(40, 167, 69); - } - - &.requested .input-group-append span { - color: #1a237e; - background-color: #c5cae9; - } - - /* &.requested .form-control { - background: #e6efff; - } - - &.requested .form-control, - &.requested .input-group-append span { - border-color: #6770cb; - } - - &.requested .input-group-append .btn { - border-color: #1a237e; - } */ -`; - -function ContactPerson({ - email, - loading, - sendDate = "1970-01-01", - cooldown = ["day", 1], - handleSubmit, - received = false, - disabled, -}) { - // https://stackoverflow.com/questions/13262621/how-do-i-use-format-on-a-moment-js-duration - const diff = moment(sendDate).add(cooldown[0], cooldown[1]).diff(moment()); - const formattedDiff = - diff > 3600 * 1000 - ? Math.round(diff / (3600 * 1000)) - : Math.round(diff / (1000 * 60)); - - const { t } = useTranslation(); - const status = email ? (received ? "received" : "requested") : "nothing"; - const text = { - nothing: t("Not requested"), - requested: t("Requested"), - received: t("Letter received"), - }; - - const button = { - nothing: t("Send request"), - requested: t("Send again"), - }; - - return ( -
{ - e.preventDefault(); - const newEmail = e.target.email.value; - handleSubmit(newEmail); - }} - > - - {loading && ( - - - - - - )} - 0 || loading || disabled} - placeholder="E-mail" - required - /> - - {text[status]} - {!received && ( - - )} - - - - ); -} - -export default ContactPerson; diff --git a/src/components/portal/Upload/index.tsx b/src/components/portal/Upload/index.tsx index 0f973a2..56b491c 100644 --- a/src/components/portal/Upload/index.tsx +++ b/src/components/portal/Upload/index.tsx @@ -73,7 +73,7 @@ const StyledFormControl = styled(FormControl)` }`} `; -export interface MostUploadProps { +export interface UploadProps { label?: string; onDownload?: () => Promise; onDelete?: () => Promise; @@ -85,18 +85,9 @@ export interface MostUploadProps { error?: string; uploadLabel?: string; disabled?: boolean; + onChange?: (file: File, name: string) => void; } -export type UploadProps = - | ({ - multiple: true; - onChange?: (files: FileList, list: string[]) => any; - } & MostUploadProps) - | ({ - multiple?: false; - onChange?: (file: any, name: string) => any; - } & MostUploadProps); - const Upload: React.FC = ({ label, onChange = () => null, @@ -110,7 +101,6 @@ const Upload: React.FC = ({ error, uploadLabel, disabled, - multiple, }) => { const [fileName, updateFileName] = useState(""); const [downloading, setDownloading] = useState(false); @@ -128,16 +118,11 @@ const Upload: React.FC = ({ setOpen(isOpen); }; - function handleFileChange(e: any) { + function handleFileChange(e: React.ChangeEvent) { const list = e.target.value.split("\\"); - const name = list[list.length - 1]; - if (multiple) { - updateFileName(list.join(", ")); - return onChange(e.target.files, list); - } else { - updateFileName(list[list.length - 1]); + updateFileName(list[list.length - 1]); + if (e.target.files) return onChange(e.target.files[0], list[list.length - 1]); - } } const { t } = useTranslation(); @@ -193,7 +178,6 @@ const Upload: React.FC = ({ accept={accept} isInvalid={Boolean(error)} label={uploadLabel} - multiple={multiple} /> {newLabel} diff --git a/src/features/ChangeLanguage.tsx b/src/features/ChangeLanguage.tsx index 4b1b952..afd2c12 100644 --- a/src/features/ChangeLanguage.tsx +++ b/src/features/ChangeLanguage.tsx @@ -1,6 +1,6 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import styled from 'styled-components'; +import React from "react"; +import styled from "styled-components"; +import { useTranslation } from "react-i18next"; const Button = styled.button` border: none; @@ -15,20 +15,20 @@ const Button = styled.button` } `; -const ChangeLanguage = () => { +const ChangeLanguage = (): React.ReactElement => { const { i18n } = useTranslation(); return (
- -
diff --git a/src/features/admin/GradingView.tsx b/src/features/admin/GradingView.tsx index 5b73faa..38dae8a 100644 --- a/src/features/admin/GradingView.tsx +++ b/src/features/admin/GradingView.tsx @@ -8,6 +8,7 @@ import { updateGradingOrder, } from "./adminSlice"; +import { Application } from "types/grade"; import BootstrapTable from "react-bootstrap-table-next"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import OpenGradingModalButton from "./OpenGradingModalButton"; @@ -75,7 +76,7 @@ class Grading extends React.Component { { dataField: "id", text: "PDF", - formatter: (id: string, row: any) => ( + formatter: (id: string, row: Application) => ( downloadAndOpen(id)} @@ -88,7 +89,7 @@ class Grading extends React.Component { dataField: "dummy_field", text: "Bedöm", isDummyField: true, - formatter: (id: string, row: any) => ( + formatter: (id: string, row: Application) => ( - axios.post(`/application/${id}/grade`, values); - const Grade: React.FC = ({ id, variant = "primary" }) => { const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const dispatch = useDispatch(); - const gradingData = useSelector((state: RootState) => - selectMyGrading(state, id) - ); + const gradingData = useSelector(selectMyGrading(id)); const handleClick = () => { setOpen(!open); if (!open && gradingData === undefined) { @@ -59,10 +52,9 @@ const Grade: React.FC = ({ id, variant = "primary" }) => { - handleSubmit(id, values).then((res) => { + postApplicationGrade(id, values).then((res) => { setOpen(false); - dispatch(setMyGrade(res.data)); - return res; + dispatch(setMyGrade(res)); }) } name={name} diff --git a/src/features/admin/Statistics.tsx b/src/features/admin/Statistics.tsx index e2ac18d..51a0156 100644 --- a/src/features/admin/Statistics.tsx +++ b/src/features/admin/Statistics.tsx @@ -1,98 +1,10 @@ +import { NumericalStatistic } from "types/survey"; import React from "react"; import Spinner from "react-bootstrap/Spinner"; import Table from "react-bootstrap/Table"; -import useAxios from "axios-hooks"; +import { useStatistics } from "./adminHooks"; import { useTranslation } from "react-i18next"; -interface UseStatistics { - loading: boolean; - data: any; - error: any; -} - -type StatisticalValue = "average"; - -interface NumericalStatistic { - average: number; - count: Record; -} - -interface Statistics { - applicationProcess: NumericalStatistic; - applicationPortal: NumericalStatistic; - improvement: string[]; - informant: string[]; - city: string[]; - school: string[]; - gender: { count: Record }; -} - -type Gender = "MALE" | "FEMALE" | "OTHER" | "UNDISCLOSED"; -type Grade = 1 | 2 | 3 | 4 | 5; - -export interface SurveyAnswers { - city: string; - school: string; - gender: Gender; - applicationPortal: Grade; - applicationProcess: Grade; - improvement: string; - informant: string; -} - -function average(answers: Record) { - let sum = 0; - let n = 0; - Object.keys(answers).forEach((answer) => { - sum += parseInt(answer) * answers[parseInt(answer)]; - n += answers[parseInt(answer)]; - }); - return sum / n; -} - -function useStatistics(): UseStatistics { - const [{ loading, data, error }] = useAxios("/admin/survey"); - const statistics: Statistics = { - applicationPortal: { count: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }, average: 0 }, - applicationProcess: { count: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }, average: 0 }, - gender: { - count: { - MALE: 0, - FEMALE: 0, - OTHER: 0, - UNDISCLOSED: 0, - }, - }, - city: [], - school: [], - improvement: [], - informant: [], - }; - if (data) { - data.forEach((answer) => { - statistics.applicationPortal.count[answer.applicationPortal]++; - statistics.applicationProcess.count[answer.applicationPortal]++; - statistics.gender.count[answer.gender]++; - statistics.city.push(answer.city); - statistics.school.push(answer.school); - statistics.improvement.push(answer.improvement); - statistics.informant.push(answer.informant); - }); - statistics.applicationPortal.average = average( - statistics.applicationPortal.count - ); - statistics.applicationProcess.average = average( - statistics.applicationProcess.count - ); - } - - return { - loading, - data: statistics, - error, - }; -} - interface StringTableProps { answers: string[]; title: string; @@ -120,10 +32,9 @@ function StringTable({ answers, title }: StringTableProps) { interface NumericalTableProps { title: string; answers: NumericalStatistic; - isNumeric?: boolean; } -function NumericalTable({ answers, title, isNumeric }: NumericalTableProps) { +function NumericalTable({ answers, title }: NumericalTableProps) { const { t } = useTranslation(); return ( <> @@ -131,7 +42,7 @@ function NumericalTable({ answers, title, isNumeric }: NumericalTableProps) {
{grade.firstName + " " + grade.lastName} {grade.cv} {grade.coverLetter}
{grade.firstName + " " + grade.lastName} {grade.comment}
- + @@ -143,13 +54,12 @@ function NumericalTable({ answers, title, isNumeric }: NumericalTableProps) { ))} - {isNumeric && - (["average"] as StatisticalValue[]).map((key) => ( - - - - - ))} + {answers.average && ( + + + + + )}
{isNumeric ? "Betyg" : "Svar"}{answers.average ? "Betyg" : "Svar"} Antal
{answers.count[n]}
{t(key)}{Math.round(answers[key] * 100) / 100}
{t("average")}{answers.average}
@@ -157,7 +67,7 @@ function NumericalTable({ answers, title, isNumeric }: NumericalTableProps) { } function StatisticsPage(): React.ReactElement { - const { loading, data, error } = useStatistics(); + const { loading, data } = useStatistics(); const { t } = useTranslation(); if (loading) return ( @@ -175,12 +85,10 @@ function StatisticsPage(): React.ReactElement { return (
diff --git a/src/features/admin/TopList.tsx b/src/features/admin/TopList.tsx index 6dd32ac..772256f 100644 --- a/src/features/admin/TopList.tsx +++ b/src/features/admin/TopList.tsx @@ -3,6 +3,7 @@ import { selectApplicationsByTop, setApplications } from "./adminSlice"; import BootstrapTable from "react-bootstrap-table-next"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { GradedApplication } from "types/grade"; import GradingData from "components/GradingData"; import OpenPDF from "components/portal/OpenPDF"; import React from "react"; @@ -13,12 +14,7 @@ import { faFileDownload } from "@fortawesome/free-solid-svg-icons"; import { getApplications } from "api/admin"; import { useGrades } from "./adminHooks"; -interface GradingDataRowProps { - id: string; - [field: string]: any; -} - -const GradingDataRow = ({ id }: GradingDataRowProps) => { +const GradingDataRow = ({ id }: Pick) => { const { data, loading } = useGrades(id); if (loading) return
Loading
; return ; @@ -100,7 +96,7 @@ class TopList extends React.Component { .map(({ i }) => i); const expandRow = { - renderer: (row: any) => , + renderer: (row: GradedApplication) => , showExpandColumn: true, expandByColumnOnly: true, nonExpandable, diff --git a/src/features/admin/adminHooks.ts b/src/features/admin/adminHooks.ts index db691fe..6d07bb8 100644 --- a/src/features/admin/adminHooks.ts +++ b/src/features/admin/adminHooks.ts @@ -1,5 +1,6 @@ import { Admin, NewAdmin } from "types/user"; import { IndividualGrading, IndividualGradingWithName } from "types/grade"; +import { Statistics, SurveyAnswers } from "types/survey"; import { addAdmin, getGradesConfig } from "api/admin"; import { selectAdmins, @@ -11,6 +12,8 @@ import useApi, { UseApi } from "hooks/useApi"; import { useCallback, useEffect } from "react"; import { useDispatch, useSelector } from "react-redux"; +import average from "utils/average"; + export function useGrades( applicantId: string ): UseApi { @@ -51,3 +54,46 @@ export function useAdmins(): UseAdmins { ); return { loading, data: admins, error, addAdmin: newAdmin }; } + +export function useStatistics(): UseApi { + const [{ loading, data, error }] = useApi("/admin/survey"); + const statistics: Statistics = { + applicationPortal: { count: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }, average: 0 }, + applicationProcess: { count: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }, average: 0 }, + gender: { + count: { + MALE: 0, + FEMALE: 0, + OTHER: 0, + UNDISCLOSED: 0, + }, + }, + city: [], + school: [], + improvement: [], + informant: [], + }; + if (data) { + data.forEach((answer) => { + statistics.applicationPortal.count[answer.applicationPortal]++; + statistics.applicationProcess.count[answer.applicationPortal]++; + statistics.gender.count[answer.gender]++; + statistics.city.push(answer.city); + statistics.school.push(answer.school); + statistics.improvement.push(answer.improvement); + statistics.informant.push(answer.informant); + }); + statistics.applicationPortal.average = average( + statistics.applicationPortal.count + ); + statistics.applicationProcess.average = average( + statistics.applicationProcess.count + ); + } + + return { + loading, + data: statistics, + error, + }; +} diff --git a/src/features/admin/adminSlice.ts b/src/features/admin/adminSlice.ts index bf856d3..ce7e039 100644 --- a/src/features/admin/adminSlice.ts +++ b/src/features/admin/adminSlice.ts @@ -111,9 +111,8 @@ export const selectGradesByApplicant = (userID: string) => ( lastName: state.admin.admins[grade.adminId]?.lastName, })); -export const selectMyGrading = ( - state: RootState, - id: string +export const selectMyGrading = (id: string) => ( + state: RootState ): IndividualGradingWithName | undefined => { const relevantGrades = state.admin.grades[id]; if (relevantGrades) { diff --git a/src/features/auth/AuthenticatedLayer.tsx b/src/features/auth/AuthenticatedLayer.tsx index 20f83d6..a996df1 100644 --- a/src/features/auth/AuthenticatedLayer.tsx +++ b/src/features/auth/AuthenticatedLayer.tsx @@ -6,7 +6,6 @@ import { } from "features/auth/authSlice"; import Axios from "api/axios"; -import { TokenStorage } from "utils/tokenInterceptor"; import { useDispatch } from "react-redux"; import { useSelector } from "react-redux"; diff --git a/src/features/auth/login/LoginWithCode.tsx b/src/features/auth/login/LoginWithCode.tsx index c25ce73..1d7f099 100644 --- a/src/features/auth/login/LoginWithCode.tsx +++ b/src/features/auth/login/LoginWithCode.tsx @@ -18,7 +18,7 @@ interface LoginWithCodeProps extends WithTranslation { onSubmit?: ( values: Values, formikHelpers: FormikHelpers - ) => void | Promise; + ) => void | Promise; email?: string; } diff --git a/src/features/nomatch/index.tsx b/src/features/nomatch/index.tsx index 2c25667..424e709 100644 --- a/src/features/nomatch/index.tsx +++ b/src/features/nomatch/index.tsx @@ -1,11 +1,11 @@ -import React from "react"; import Center from "components/Center"; import Plate from "components/Plate"; +import React from "react"; import Star from "components/Star"; import StyledTitle from "components/StyledTitle"; import { Trans } from "react-i18next"; -const NoMatch = () => ( +const NoMatch = (): React.ReactElement => (
diff --git a/src/features/portal/Chapters/index.tsx b/src/features/portal/Chapters/index.tsx index 70dd948..eeba735 100644 --- a/src/features/portal/Chapters/index.tsx +++ b/src/features/portal/Chapters/index.tsx @@ -1,6 +1,6 @@ import { WithTranslation, withTranslation } from "react-i18next"; -import Chapter from "components/portal/Chapter"; +import Chapter from "components/Chapter"; import { FileType } from "../portalSlice"; import PortalSurvey from "../Survey"; import React from "react"; diff --git a/src/features/portal/Delete.tsx b/src/features/portal/Delete.tsx index 7055745..cffc7c0 100644 --- a/src/features/portal/Delete.tsx +++ b/src/features/portal/Delete.tsx @@ -54,7 +54,7 @@ const ConfirmModal = ({ show, onHide }: ConfirmModalProps) => { ); }; -const Delete = () => { +const Delete = (): React.ReactElement => { const [modalVisible, showModal] = useState(false); const { t } = useTranslation(); return ( diff --git a/src/features/portal/Download.tsx b/src/features/portal/Download.tsx index 9f72fa9..0ee2f04 100644 --- a/src/features/portal/Download.tsx +++ b/src/features/portal/Download.tsx @@ -10,7 +10,7 @@ interface DownloadProps { style: CSS.Properties; } -const Download = ({ style }: DownloadProps) => { +const Download = ({ style }: DownloadProps): React.ReactElement => { const [downloading, setDownload] = useState(false); const { t } = useTranslation(); return ( diff --git a/src/features/portal/References/index.tsx b/src/features/portal/References/index.tsx index 58d2714..25f9482 100644 --- a/src/features/portal/References/index.tsx +++ b/src/features/portal/References/index.tsx @@ -4,7 +4,7 @@ import { Trans, withTranslation } from "react-i18next"; import { useDispatch, useSelector } from "react-redux"; import Axios from "api/axios"; -import ContactPerson from "components/portal/ContactPerson"; +import ContactPerson from "components/ContactPerson"; import { RootState } from "store"; import moment from "moment"; import { selectRecommendation } from "features/portal/portalSlice"; @@ -65,12 +65,11 @@ const Person = ({ } return ( ); @@ -80,7 +79,7 @@ interface ReferencesProps { loading?: boolean; } -const References = ({ loading }: ReferencesProps) => { +const References = ({ loading }: ReferencesProps): React.ReactElement => { const map = []; for (let i = 0; i < 3; i += 1) { map[i] = ( diff --git a/src/features/portal/Survey/index.tsx b/src/features/portal/Survey/index.tsx index 427748d..581f10c 100644 --- a/src/features/portal/Survey/index.tsx +++ b/src/features/portal/Survey/index.tsx @@ -15,7 +15,7 @@ function useSurvey(): [SurveyAnswers | undefined, boolean] { return [survey, loading]; } -const PortalSurvey = () => { +const PortalSurvey = (): React.ReactElement => { const [survey, loading] = useSurvey(); const dispatch = useDispatch(); if (loading) return
; diff --git a/src/features/portal/index.tsx b/src/features/portal/index.tsx index 2cee2e5..7370859 100644 --- a/src/features/portal/index.tsx +++ b/src/features/portal/index.tsx @@ -16,7 +16,7 @@ import StyledPlate from "components/Plate"; import { addPersonSuccess } from "features/portal/portalSlice"; import { useTranslation } from "react-i18next"; -const Hook = () => { +const Hook = (): React.ReactElement => { const dispatch = useDispatch(); const [filesLoading, setFilesLoading] = useState(true); const [referencesLoading, setReferencesLoading] = useState(true); diff --git a/src/types/survey.ts b/src/types/survey.ts new file mode 100644 index 0000000..8ec7914 --- /dev/null +++ b/src/types/survey.ts @@ -0,0 +1,29 @@ +export type StatisticalValue = "average"; + +export interface NumericalStatistic { + average?: number; + count: Record; +} + +export type Gender = "MALE" | "FEMALE" | "OTHER" | "UNDISCLOSED"; +export type Grade = 1 | 2 | 3 | 4 | 5; + +export interface SurveyAnswers { + city: string; + school: string; + gender: Gender; + applicationPortal: Grade; + applicationProcess: Grade; + improvement: string; + informant: string; +} + +export interface Statistics { + applicationProcess: NumericalStatistic; + applicationPortal: NumericalStatistic; + improvement: string[]; + informant: string[]; + city: string[]; + school: string[]; + gender: { count: Record }; +} diff --git a/src/utils/average.ts b/src/utils/average.ts new file mode 100644 index 0000000..1d106a9 --- /dev/null +++ b/src/utils/average.ts @@ -0,0 +1,9 @@ +export default function average(answers: Record): number { + let sum = 0; + let n = 0; + Object.keys(answers).forEach((answer) => { + sum += parseInt(answer) * answers[parseInt(answer)]; + n += answers[parseInt(answer)]; + }); + return sum / n; +} diff --git a/src/utils/tokenInterceptor.ts b/src/utils/tokenInterceptor.ts index 6cfa469..946bdba 100644 --- a/src/utils/tokenInterceptor.ts +++ b/src/utils/tokenInterceptor.ts @@ -30,26 +30,24 @@ export class TokenStorage { } public static getNewToken(): Promise { - const getNewTokenPromise: Promise = new Promise( - (resolve, reject): void => { - this.updatingToken = true; - axios - .post("/user/oauth/token", { - refresh_token: this.getRefreshToken(), - grant_type: "refresh_token", - }) - .then((response) => { - this.updatingToken = false; - this.storeTokens(response.data); - resolve(response.data.access_token); - }) - .catch((error) => { - this.updatingToken = false; - this.removeTokensAndNotify(); - console.error(error); - }); - } - ); + const getNewTokenPromise: Promise = new Promise((resolve): void => { + this.updatingToken = true; + axios + .post("/user/oauth/token", { + refresh_token: this.getRefreshToken(), + grant_type: "refresh_token", + }) + .then((response) => { + this.updatingToken = false; + this.storeTokens(response.data); + resolve(response.data.access_token); + }) + .catch((error) => { + this.updatingToken = false; + this.removeTokensAndNotify(); + console.error(error); + }); + }); this.updateTokenPromise = getNewTokenPromise; return getNewTokenPromise; } From 7de6e09e95eced2f4fc1888a27a4f576c88a2131 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 5 Apr 2021 02:45:46 +0200 Subject: [PATCH 14/98] Created recommendationsSlice --- .../admin/Grading/ApplicantInformation.tsx | 19 +++++++- .../UploadRecommendationLetter.tsx} | 25 ++++++++--- .../recommendations/recommendationHooks.ts | 23 ++++++++++ .../recommendations/recommendationsSlice.ts | 43 +++++++++++++++++++ src/features/router/index.tsx | 4 +- src/store.ts | 2 + src/types/recommendations.ts | 14 ++++++ 7 files changed, 122 insertions(+), 8 deletions(-) rename src/features/{recommendation/index.tsx => files/UploadRecommendationLetter.tsx} (87%) create mode 100644 src/features/recommendations/recommendationHooks.ts create mode 100644 src/features/recommendations/recommendationsSlice.ts create mode 100644 src/types/recommendations.ts diff --git a/src/features/admin/Grading/ApplicantInformation.tsx b/src/features/admin/Grading/ApplicantInformation.tsx index b9b8ccf..beea1df 100644 --- a/src/features/admin/Grading/ApplicantInformation.tsx +++ b/src/features/admin/Grading/ApplicantInformation.tsx @@ -1,8 +1,10 @@ import { FileType } from "types/files"; import React from "react"; import Upload from "features/files/Upload"; +import { UploadRecommendationLetter } from "features/files/UploadRecommendationLetter"; import portal from "config/portal.json"; import { useFiles } from "features/files/filesHooks"; +import { useRecommendations } from "features/recommendations/recommendationHooks"; interface ApplicantInformationProps { email: string; @@ -13,12 +15,18 @@ function ApplicantInformation({ email, applicantID, }: ApplicantInformationProps) { - const { loading, error } = useFiles(applicantID); + const { loading: loadingFiles, error: errorFiles } = useFiles(applicantID); + const { + loading: loadingRecommendations, + data: recommendations, + error, + } = useRecommendations(applicantID); + return (
Email: {email} - {loading ? ( + {loadingFiles ? (
Loading
) : ( portal.chapters.map((chapter) => @@ -32,6 +40,13 @@ function ApplicantInformation({ ) : null ) )} + {loadingRecommendations &&
Loading
} + {recommendations?.map((recommendation) => ( + + ))}
); } diff --git a/src/features/recommendation/index.tsx b/src/features/files/UploadRecommendationLetter.tsx similarity index 87% rename from src/features/recommendation/index.tsx rename to src/features/files/UploadRecommendationLetter.tsx index ef021f8..442bac0 100644 --- a/src/features/recommendation/index.tsx +++ b/src/features/files/UploadRecommendationLetter.tsx @@ -53,18 +53,33 @@ const UploadState = ({ ); }; -const Recommendation = (): React.ReactElement => { - const { recommendationCode } = useParams<{ recommendationCode: string }>(); +interface UploadRecommendationLetterProps { + recommendationCode: string; +} +export const UploadRecommendationLetter = ({ + recommendationCode, +}: UploadRecommendationLetterProps): React.ReactElement => { const [{ response, error, loading }] = useAxios( `/application/recommendation/${recommendationCode}` ); - const { t } = useTranslation(); + return ( + + ); +}; +const RecommendationCard = () => { + const { recommendationCode } = useParams<{ recommendationCode: string }>(); + const [{ response, error, loading }] = useAxios( + `/application/recommendation/${recommendationCode}` + ); + const { t } = useTranslation(); const name = response?.data.applicantFirstName + " " + response?.data.applicantLastName; - return ( {loading ? ( @@ -125,4 +140,4 @@ const Recommendation = (): React.ReactElement => { ); }; -export default Recommendation; +export default RecommendationCard; diff --git a/src/features/recommendations/recommendationHooks.ts b/src/features/recommendations/recommendationHooks.ts new file mode 100644 index 0000000..a80d8ce --- /dev/null +++ b/src/features/recommendations/recommendationHooks.ts @@ -0,0 +1,23 @@ +import { useDispatch, useSelector } from "react-redux"; + +import { RecommendationRequest } from "types/recommendations"; +import { selectApplicantRecommendations } from "./recommendationsSlice"; +import useAxios from "axios-hooks"; + +export const useRecommendations = (applicantID = "@me") => { + const dispatch = useDispatch(); + const [{ loading, data, error }] = useAxios({ + url: `/application/${applicantID}/recommendation`, + }); + const applicantRecommendations = useSelector( + selectApplicantRecommendations(applicantID) + ); + if (data && !applicantRecommendations) { + dispatch(data); + } + return { + loading, + data: applicantRecommendations, + error, + }; +}; diff --git a/src/features/recommendations/recommendationsSlice.ts b/src/features/recommendations/recommendationsSlice.ts new file mode 100644 index 0000000..261b051 --- /dev/null +++ b/src/features/recommendations/recommendationsSlice.ts @@ -0,0 +1,43 @@ +import { PayloadAction, createSlice } from "@reduxjs/toolkit"; + +import { RecommendationRequest } from "types/recommendations"; +import { RootState } from "store"; + +type RecommendationsState = Record; + +const initialState: RecommendationsState = {}; + +const recommendationsSlice = createSlice({ + name: "files", + initialState, + reducers: { + addPersonSuccess(state, action: PayloadAction) { + action.payload.forEach((recommendation) => { + if (state[recommendation.applicantId]) + state[recommendation.applicantId].push(recommendation); + else state[recommendation.applicantId] = [recommendation]; + }); + }, + }, +}); + +export const { addPersonSuccess } = recommendationsSlice.actions; + +export const selectApplicantRecommendations = (applicantID?: string) => ( + state: RootState +): RecommendationRequest[] | undefined => { + const id = applicantID || state.auth.user?.id; + if (!id) return; + return state.recommendations[id]; +}; + +export const selectRecommendationByIndexAndApplicant = ( + recommendationIndex: number, + applicantID?: string +) => (state: RootState): RecommendationRequest | undefined => { + const id = applicantID || state.auth.user?.id; + if (!id) return undefined; + return state.recommendations[id][recommendationIndex]; +}; + +export default recommendationsSlice.reducer; diff --git a/src/features/router/index.tsx b/src/features/router/index.tsx index e10cae0..1427a49 100644 --- a/src/features/router/index.tsx +++ b/src/features/router/index.tsx @@ -9,7 +9,9 @@ const Login = lazy(() => import("features/auth/login")); const Portal = lazy(() => import("features/portal")); const Register = lazy(() => import("features/auth/register")); const NoMatch = lazy(() => import("features/nomatch")); -const Recommendation = lazy(() => import("features/recommendation")); +const Recommendation = lazy( + () => import("features/files/UploadRecommendationLetter") +); const LoginWithCodeRoute = lazy( () => import("features/auth/login/LoginWithCodeRoute") ); diff --git a/src/store.ts b/src/store.ts index 391696d..a1b4afa 100644 --- a/src/store.ts +++ b/src/store.ts @@ -18,6 +18,7 @@ import admin from "features/admin/adminSlice"; import auth from "features/auth/authSlice"; import files from "features/files/filesSlice"; import portal from "features/portal/portalSlice"; +import recommendations from "features/recommendations/recommendationsSlice"; import storage from "redux-persist/lib/storage"; // defaults to localStorage for web const persistConfig = { @@ -31,6 +32,7 @@ const rootReducer = combineReducers({ portal, admin, files, + recommendations, }); const persistedReducer = persistReducer(persistConfig, rootReducer); diff --git a/src/types/recommendations.ts b/src/types/recommendations.ts new file mode 100644 index 0000000..087bef4 --- /dev/null +++ b/src/types/recommendations.ts @@ -0,0 +1,14 @@ +export type RecommendationRequest = { + applicantId: string; + email: string; + lastSent: string; + received: null | string; + index: number; + id: string; +}; + +export interface RecommendationFile extends RecommendationRequest { + code?: string; + applicantId: string; + fileId: null | string; +} From ed304a64d0900bc6994659439e58c80fcab35177 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 5 Apr 2021 02:53:42 +0200 Subject: [PATCH 15/98] Fixed warnings --- src/api/files.ts | 13 +++++++--- src/features/admin/ApplicantInformation.tsx | 21 +++------------ src/features/admin/GradingView.tsx | 2 +- .../files/UploadRecommendationLetter.tsx | 4 +-- src/features/files/filesHooks.ts | 4 +-- src/features/portal/References/index.tsx | 7 +++-- src/features/portal/portalSlice.ts | 26 ------------------- .../recommendations/recommendationHooks.ts | 10 ++++--- 8 files changed, 25 insertions(+), 62 deletions(-) diff --git a/src/api/files.ts b/src/api/files.ts index 4d4c398..fe576e1 100644 --- a/src/api/files.ts +++ b/src/api/files.ts @@ -3,7 +3,10 @@ import { FileInfo, FileType } from "types/files"; import FileSaver from "file-saver"; import api from "axios"; -export const downloadFile = (fileID: string, applicantID = "@me") => +export const downloadFile = ( + fileID: string, + applicantID = "@me" +): Promise => api .get(`application/${applicantID}/file/${fileID}`, { responseType: "blob", @@ -24,15 +27,17 @@ export const downloadFile = (fileID: string, applicantID = "@me") => ); }); -export const deleteFile = (fileID: string, applicantID = "@me") => - api.delete(`/application/${applicantID}/file/${fileID}`); +export const deleteFile = ( + fileID: string, + applicantID = "@me" +): Promise => api.delete(`/application/${applicantID}/file/${fileID}`); export const uploadFile = ( fileType: FileType, file: File, fileName: string, applicantID = "@me" -) => { +): Promise => { const form = new FormData(); form.append("file", file, fileName); return api diff --git a/src/features/admin/ApplicantInformation.tsx b/src/features/admin/ApplicantInformation.tsx index beea1df..bafb4b3 100644 --- a/src/features/admin/ApplicantInformation.tsx +++ b/src/features/admin/ApplicantInformation.tsx @@ -1,10 +1,8 @@ import { FileType } from "types/files"; import React from "react"; import Upload from "features/files/Upload"; -import { UploadRecommendationLetter } from "features/files/UploadRecommendationLetter"; import portal from "config/portal.json"; import { useFiles } from "features/files/filesHooks"; -import { useRecommendations } from "features/recommendations/recommendationHooks"; interface ApplicantInformationProps { email: string; @@ -14,19 +12,13 @@ interface ApplicantInformationProps { function ApplicantInformation({ email, applicantID, -}: ApplicantInformationProps) { - const { loading: loadingFiles, error: errorFiles } = useFiles(applicantID); - const { - loading: loadingRecommendations, - data: recommendations, - error, - } = useRecommendations(applicantID); - +}: ApplicantInformationProps): React.ReactElement { + const { loading } = useFiles(applicantID); return (
Email: {email} - {loadingFiles ? ( + {loading ? (
Loading
) : ( portal.chapters.map((chapter) => @@ -40,13 +32,6 @@ function ApplicantInformation({ ) : null ) )} - {loadingRecommendations &&
Loading
} - {recommendations?.map((recommendation) => ( - - ))}
); } diff --git a/src/features/admin/GradingView.tsx b/src/features/admin/GradingView.tsx index 0d41191..54e0ce5 100644 --- a/src/features/admin/GradingView.tsx +++ b/src/features/admin/GradingView.tsx @@ -101,7 +101,7 @@ class Grading extends React.Component { ]; const expandRow = { - renderer: (row: any) => ( + renderer: (row: Application) => ( ), showExpandColumn: true, diff --git a/src/features/files/UploadRecommendationLetter.tsx b/src/features/files/UploadRecommendationLetter.tsx index 6304dd6..381d812 100644 --- a/src/features/files/UploadRecommendationLetter.tsx +++ b/src/features/files/UploadRecommendationLetter.tsx @@ -60,7 +60,7 @@ interface UploadRecommendationLetterProps { export const UploadRecommendationLetter = ({ recommendationCode, }: UploadRecommendationLetterProps): React.ReactElement => { - const [{ response, error, loading }] = useAxios( + const [{ response }] = useAxios( `/application/recommendation/${recommendationCode}` ); @@ -72,7 +72,7 @@ export const UploadRecommendationLetter = ({ ); }; -const RecommendationCard = () => { +const RecommendationCard = (): React.ReactElement => { const { recommendationCode } = useParams<{ recommendationCode: string }>(); const [{ response, error, loading }] = useAxios( `/application/recommendation/${recommendationCode}` diff --git a/src/features/files/filesHooks.ts b/src/features/files/filesHooks.ts index d3f170f..57797a5 100644 --- a/src/features/files/filesHooks.ts +++ b/src/features/files/filesHooks.ts @@ -6,13 +6,12 @@ import useAxios from "axios-hooks"; type UseFiles = { loading: boolean; - error: any; }; export function useFiles(applicantID = "@me"): UseFiles { const dispatch = useDispatch(); const files = useSelector(selectApplicantFilesLoaded(applicantID)); - const [{ loading, data, error }] = useAxios({ + const [{ loading, data }] = useAxios({ url: `/application/${applicantID}/file`, }); if (Boolean(files) === false && data) { @@ -20,6 +19,5 @@ export function useFiles(applicantID = "@me"): UseFiles { } return { loading, - error, }; } diff --git a/src/features/portal/References/index.tsx b/src/features/portal/References/index.tsx index 25f9482..029c464 100644 --- a/src/features/portal/References/index.tsx +++ b/src/features/portal/References/index.tsx @@ -5,9 +5,8 @@ import { useDispatch, useSelector } from "react-redux"; import Axios from "api/axios"; import ContactPerson from "components/ContactPerson"; -import { RootState } from "store"; import moment from "moment"; -import { selectRecommendation } from "features/portal/portalSlice"; +import { selectRecommendationByIndexAndApplicant } from "features/recommendations/recommendationsSlice"; import { toast } from "react-toastify"; const UploadLink = ({ code }: { code: string }) => ( @@ -35,8 +34,8 @@ const Person = ({ disabled, }: PersonProps) => { const [loading, setLoading] = useState(false); - const recommendation = useSelector((state: RootState) => - selectRecommendation(state, recommendationIndex) + const recommendation = useSelector( + selectRecommendationByIndexAndApplicant(recommendationIndex) ); const dispatch = useDispatch(); const applicationHasClosed = diff --git a/src/features/portal/portalSlice.ts b/src/features/portal/portalSlice.ts index ea605b1..c4c7557 100644 --- a/src/features/portal/portalSlice.ts +++ b/src/features/portal/portalSlice.ts @@ -97,32 +97,6 @@ const portalSlice = createSlice({ }, }); -export const selectAllFiles = (state: RootState) => state.portal.files; -export const selectSingleFileByFileType = ( - state: RootState, - type: FileType -): FileInfo | undefined => { - if (state.portal.filesByType[type]) { - const files = state.portal.filesByType[type]; - if (files) return state.portal.files[files[0]]; - else return undefined; - } - return undefined; -}; -export const selectFilesByFileType = ( - state: RootState, - type: FileType -): FileInfo[] | undefined => { - const array = state.portal.filesByType[type]?.map( - (fileID) => state.portal.files[fileID] - ); - if (array === undefined || type === "APPENDIX") return array; -}; -export const selectRecommendation = ( - state: RootState, - recommendationIndex: number -): Recommendation | undefined => - state.portal.recommendations[recommendationIndex]; export const selectSurvey = (state: RootState): SurveyAnswers | undefined => state.portal.survey; export const selectProgress = (state: RootState): number => { diff --git a/src/features/recommendations/recommendationHooks.ts b/src/features/recommendations/recommendationHooks.ts index a80d8ce..59ac92f 100644 --- a/src/features/recommendations/recommendationHooks.ts +++ b/src/features/recommendations/recommendationHooks.ts @@ -1,12 +1,14 @@ +import useApi, { UseApi } from "hooks/useApi"; import { useDispatch, useSelector } from "react-redux"; import { RecommendationRequest } from "types/recommendations"; import { selectApplicantRecommendations } from "./recommendationsSlice"; -import useAxios from "axios-hooks"; -export const useRecommendations = (applicantID = "@me") => { +export const useRecommendations = ( + applicantID = "@me" +): UseApi => { const dispatch = useDispatch(); - const [{ loading, data, error }] = useAxios({ + const [{ loading, data, error }] = useApi({ url: `/application/${applicantID}/recommendation`, }); const applicantRecommendations = useSelector( @@ -17,7 +19,7 @@ export const useRecommendations = (applicantID = "@me") => { } return { loading, - data: applicantRecommendations, + data: applicantRecommendations || [], error, }; }; From bc558052dd8017863f1a26d8480d04952ae327b1 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 5 Apr 2021 03:27:28 +0200 Subject: [PATCH 16/98] Improved references --- src/api/auth.ts | 29 ++++++++++++++ src/api/files.ts | 5 ++- src/api/recommendations.ts | 13 +++++++ src/features/auth/api.ts | 38 +++++-------------- src/features/files/filesHooks.ts | 4 +- src/features/portal/Chapters/index.tsx | 3 +- src/features/portal/References/index.tsx | 29 +++++--------- src/features/portal/index.tsx | 19 +++------- src/features/portal/portalSlice.ts | 8 +--- .../recommendations/recommendationHooks.ts | 9 +++-- .../recommendations/recommendationsSlice.ts | 15 ++++++-- src/types/recommendations.ts | 2 +- src/types/tokens.ts | 6 +++ src/utils/tokenInterceptor.ts | 9 +---- 14 files changed, 100 insertions(+), 89 deletions(-) create mode 100644 src/api/auth.ts create mode 100644 src/api/recommendations.ts create mode 100644 src/types/tokens.ts diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..7457b7d --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,29 @@ +import { ServerTokenResponse } from "types/tokens"; +import api from "./axios"; + +export const authorizeWithEmailAndCode = ( + email: string, + code: string +): Promise => + api.format.post( + "/user/oauth/token", + { + grant_type: "client_credentials", + }, + { + headers: { Authorization: `Email ${btoa(email + ":" + code)}` }, + } + ); + +export const authorizeWithToken = ( + token: string +): Promise => + api.format.post( + "/user/oauth/token", + { + grant_type: "client_credentials", + }, + { + headers: { Authorization: `Email ${token}` }, + } + ); diff --git a/src/api/files.ts b/src/api/files.ts index fe576e1..5fe2068 100644 --- a/src/api/files.ts +++ b/src/api/files.ts @@ -1,7 +1,10 @@ import { FileInfo, FileType } from "types/files"; import FileSaver from "file-saver"; -import api from "axios"; +import api from "./axios"; + +export const getFiles = (applicantID = "@me"): Promise => + api.format.get(`/application/${applicantID}/file`); export const downloadFile = ( fileID: string, diff --git a/src/api/recommendations.ts b/src/api/recommendations.ts new file mode 100644 index 0000000..019ec6b --- /dev/null +++ b/src/api/recommendations.ts @@ -0,0 +1,13 @@ +import { RecommendationRequest } from "types/recommendations"; +import api from "./axios"; + +export const requestRecommendation = ( + recommendationIndex: number, + email: string +): Promise => + api.format.post( + `/application/@me/recommendation/${recommendationIndex}`, + { + email, + } + ); diff --git a/src/features/auth/api.ts b/src/features/auth/api.ts index 0213c3e..6da9d2b 100644 --- a/src/features/auth/api.ts +++ b/src/features/auth/api.ts @@ -1,37 +1,19 @@ -import { ServerTokenResponse, TokenStorage } from "utils/tokenInterceptor"; +import { authorizeWithEmailAndCode, authorizeWithToken } from "api/auth"; -import Axios from "api/axios"; -import { AxiosResponse } from "axios"; +import { ServerTokenResponse } from "types/tokens"; +import { TokenStorage } from "utils/tokenInterceptor"; export const loginWithCode = ( email: string, - loginCode: string -): Promise> => - Axios.post( - "/user/oauth/token", - { - grant_type: "client_credentials", - }, - { - headers: { Authorization: `Email ${btoa(email + ":" + loginCode)}` }, - } - ).then((res) => { - TokenStorage.storeTokens(res.data); + code: string +): Promise => + authorizeWithEmailAndCode(email, code).then((res) => { + TokenStorage.storeTokens(res); return res; }); -export const loginWithToken = ( - token: string -): Promise> => - Axios.post( - "/user/oauth/token", - { - grant_type: "client_credentials", - }, - { - headers: { Authorization: `Email ${token}` }, - } - ).then((res) => { - TokenStorage.storeTokens(res.data); +export const loginWithToken = (token: string): Promise => + authorizeWithToken(token).then((res) => { + TokenStorage.storeTokens(res); return res; }); diff --git a/src/features/files/filesHooks.ts b/src/features/files/filesHooks.ts index 57797a5..9194db5 100644 --- a/src/features/files/filesHooks.ts +++ b/src/features/files/filesHooks.ts @@ -2,7 +2,7 @@ import { selectApplicantFilesLoaded, setFiles } from "./filesSlice"; import { useDispatch, useSelector } from "react-redux"; import { FileInfo } from "types/files"; -import useAxios from "axios-hooks"; +import useApi from "hooks/useApi"; type UseFiles = { loading: boolean; @@ -11,7 +11,7 @@ type UseFiles = { export function useFiles(applicantID = "@me"): UseFiles { const dispatch = useDispatch(); const files = useSelector(selectApplicantFilesLoaded(applicantID)); - const [{ loading, data }] = useAxios({ + const [{ loading, data }] = useApi({ url: `/application/${applicantID}/file`, }); if (Boolean(files) === false && data) { diff --git a/src/features/portal/Chapters/index.tsx b/src/features/portal/Chapters/index.tsx index 3f2235b..f3ac15b 100644 --- a/src/features/portal/Chapters/index.tsx +++ b/src/features/portal/Chapters/index.tsx @@ -16,7 +16,6 @@ interface ChaptersProps extends WithTranslation { const Chapters: React.FC = ({ t, filesLoading, - referencesLoading, }): React.ReactElement => ( <> {portal.chapters.map((chapter) => ( @@ -34,7 +33,7 @@ const Chapters: React.FC = ({ multiple={chapter.upload.multiple} /> )} - {chapter.contactPeople && } + {chapter.contactPeople && } {chapter.survey && } ))} diff --git a/src/features/portal/References/index.tsx b/src/features/portal/References/index.tsx index 029c464..9192a1f 100644 --- a/src/features/portal/References/index.tsx +++ b/src/features/portal/References/index.tsx @@ -1,13 +1,14 @@ import React, { useState } from "react"; -import { Recommendation, addPersonSuccess } from "features/portal/portalSlice"; import { Trans, withTranslation } from "react-i18next"; import { useDispatch, useSelector } from "react-redux"; -import Axios from "api/axios"; import ContactPerson from "components/ContactPerson"; +import { addPersonSuccess } from "features/recommendations/recommendationsSlice"; import moment from "moment"; +import { requestRecommendation } from "api/recommendations"; import { selectRecommendationByIndexAndApplicant } from "features/recommendations/recommendationsSlice"; import { toast } from "react-toastify"; +import { useRecommendations } from "features/recommendations/recommendationHooks"; const UploadLink = ({ code }: { code: string }) => ( ( - `/application/@me/recommendation/${recommendationIndex}`, - { - email, - } - ) + requestRecommendation(recommendationIndex, email) .then((res) => { setLoading(false); - dispatch(addPersonSuccess([res.data])); - if ( - res.data.code && - res.config.baseURL === "https://devapi.infrarays.digitalungdom.se" - ) - toast(, { + dispatch(addPersonSuccess([res])); + if (res.code) + toast(, { position: "bottom-center", autoClose: false, }); @@ -64,6 +57,7 @@ const Person = ({ } return ( { +const References = (): React.ReactElement => { const map = []; + const { loading } = useRecommendations(); for (let i = 0; i < 3; i += 1) { map[i] = ( { const dispatch = useDispatch(); const [filesLoading, setFilesLoading] = useState(true); - const [referencesLoading, setReferencesLoading] = useState(true); useEffect(() => { - Axios.get("/application/@me/file") + getFiles() .then((res) => { - dispatch(setFiles(res.data)); + dispatch(setFiles(res)); setFilesLoading(false); }) .catch(console.error); - Axios.get("/application/@me/recommendation").then((res) => { - dispatch(addPersonSuccess(res.data)); - setReferencesLoading(false); - }); }, []); const progress = useSelector(selectProgress); @@ -58,10 +52,7 @@ const Hook = (): React.ReactElement => {
- +
{progress === 5 && ( {t("Application complete")} diff --git a/src/features/portal/portalSlice.ts b/src/features/portal/portalSlice.ts index c4c7557..c3abf04 100644 --- a/src/features/portal/portalSlice.ts +++ b/src/features/portal/portalSlice.ts @@ -75,12 +75,6 @@ const portalSlice = createSlice({ state.filesByType[file.type]?.push(file.id); else state.filesByType[file.type] = [file.id]; }, - addPersonSuccess(state, action: PayloadAction) { - action.payload.forEach( - (recommendation) => - (state.recommendations[recommendation.index] = recommendation) - ); - }, setSurvey(state, action: PayloadAction) { state.survey = action.payload; }, @@ -116,7 +110,7 @@ export const selectProgress = (state: RootState): number => { export const { setFiles, uploadSuccess, - addPersonSuccess, + setSurvey, clearPortal, deleteFileSuccess, diff --git a/src/features/recommendations/recommendationHooks.ts b/src/features/recommendations/recommendationHooks.ts index 59ac92f..ed46605 100644 --- a/src/features/recommendations/recommendationHooks.ts +++ b/src/features/recommendations/recommendationHooks.ts @@ -1,8 +1,11 @@ +import { + addPersonSuccess, + selectApplicantRecommendations, +} from "./recommendationsSlice"; import useApi, { UseApi } from "hooks/useApi"; import { useDispatch, useSelector } from "react-redux"; import { RecommendationRequest } from "types/recommendations"; -import { selectApplicantRecommendations } from "./recommendationsSlice"; export const useRecommendations = ( applicantID = "@me" @@ -14,8 +17,8 @@ export const useRecommendations = ( const applicantRecommendations = useSelector( selectApplicantRecommendations(applicantID) ); - if (data && !applicantRecommendations) { - dispatch(data); + if (data && !applicantRecommendations?.length) { + dispatch(addPersonSuccess(data)); } return { loading, diff --git a/src/features/recommendations/recommendationsSlice.ts b/src/features/recommendations/recommendationsSlice.ts index 261b051..4347015 100644 --- a/src/features/recommendations/recommendationsSlice.ts +++ b/src/features/recommendations/recommendationsSlice.ts @@ -8,14 +8,21 @@ type RecommendationsState = Record; const initialState: RecommendationsState = {}; const recommendationsSlice = createSlice({ - name: "files", + name: "recommendations", initialState, reducers: { addPersonSuccess(state, action: PayloadAction) { action.payload.forEach((recommendation) => { if (state[recommendation.applicantId]) - state[recommendation.applicantId].push(recommendation); - else state[recommendation.applicantId] = [recommendation]; + state[recommendation.applicantId][ + recommendation.index + ] = recommendation; + else { + state[recommendation.applicantId] = []; + state[recommendation.applicantId][ + recommendation.index + ] = recommendation; + } }); }, }, @@ -37,7 +44,7 @@ export const selectRecommendationByIndexAndApplicant = ( ) => (state: RootState): RecommendationRequest | undefined => { const id = applicantID || state.auth.user?.id; if (!id) return undefined; - return state.recommendations[id][recommendationIndex]; + return state.recommendations[id]?.[recommendationIndex]; }; export default recommendationsSlice.reducer; diff --git a/src/types/recommendations.ts b/src/types/recommendations.ts index 087bef4..05fc214 100644 --- a/src/types/recommendations.ts +++ b/src/types/recommendations.ts @@ -5,10 +5,10 @@ export type RecommendationRequest = { received: null | string; index: number; id: string; + code?: string; }; export interface RecommendationFile extends RecommendationRequest { - code?: string; applicantId: string; fileId: null | string; } diff --git a/src/types/tokens.ts b/src/types/tokens.ts new file mode 100644 index 0000000..5778ba0 --- /dev/null +++ b/src/types/tokens.ts @@ -0,0 +1,6 @@ +export interface ServerTokenResponse { + access_token: string; + refresh_token: string; + expires: number; + token_type: string; +} diff --git a/src/utils/tokenInterceptor.ts b/src/utils/tokenInterceptor.ts index 946bdba..10fd87d 100644 --- a/src/utils/tokenInterceptor.ts +++ b/src/utils/tokenInterceptor.ts @@ -1,19 +1,12 @@ import { authFail, authSuccess } from "features/auth/authSlice"; +import { ServerTokenResponse } from "types/tokens"; import axios from "api/axios"; -// import axios from "axios"; import { clearPortal } from "features/portal/portalSlice"; import i18n from "i18n"; import store from "store"; import { toast } from "react-toastify"; -export interface ServerTokenResponse { - access_token: string; - refresh_token: string; - expires: number; - token_type: string; -} - export class TokenStorage { private static readonly LOCAL_STORAGE_ACCESS_TOKEN = "access_token"; private static readonly LOCAL_STORAGE_REFRESH_TOKEN = "refresh_token"; From 98765a151787da7c7d943fb733b7449cc8863a54 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 5 Apr 2021 03:54:09 +0200 Subject: [PATCH 17/98] Made axios calls into api calls --- src/api/admin.ts | 3 +++ src/api/files.ts | 10 ++++++++ src/api/recommendations.ts | 19 ++++++++++++++- src/api/survey.ts | 8 +++++++ src/api/user.ts | 9 +++++--- src/components/Survey/index.tsx | 13 +---------- src/features/admin/RandomiseGradingOrder.tsx | 7 +++--- src/features/auth/AuthenticatedLayer.tsx | 6 ++--- src/features/auth/authSlice.ts | 15 ++---------- .../auth/login/LoginWithCodeRoute.tsx | 2 +- .../files/UploadRecommendationLetter.tsx | 15 +++++------- src/features/portal/Delete.tsx | 4 ++-- src/features/portal/Download.tsx | 13 ++--------- src/features/portal/Survey/index.tsx | 23 +++++++++++-------- src/features/portal/portalSlice.ts | 2 +- src/types/recommendations.ts | 1 + src/types/survey.ts | 4 ++-- src/types/user.ts | 7 ++++-- 18 files changed, 88 insertions(+), 73 deletions(-) create mode 100644 src/api/survey.ts diff --git a/src/api/admin.ts b/src/api/admin.ts index 1a7a626..1a93cb7 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -35,3 +35,6 @@ export const postApplicationGrade = ( export const addAdmin = (admin: NewAdmin): Promise => api.format.post("/admin", admin); + +export const randomiseOrder = (): Promise => + api.format.post("/admin/grading/randomise"); diff --git a/src/api/files.ts b/src/api/files.ts index 5fe2068..8d0ced5 100644 --- a/src/api/files.ts +++ b/src/api/files.ts @@ -30,6 +30,16 @@ export const downloadFile = ( ); }); +export const downloadFullPDF = (applicantID = "@me"): Promise => + api + .get(`/application/${applicantID}/pdf`, { responseType: "blob" }) + .then((res) => { + FileSaver.saveAs( + res.data, + res.headers["content-disposition"].split("filename=")[1] + ); + }); + export const deleteFile = ( fileID: string, applicantID = "@me" diff --git a/src/api/recommendations.ts b/src/api/recommendations.ts index 019ec6b..facf0e9 100644 --- a/src/api/recommendations.ts +++ b/src/api/recommendations.ts @@ -1,4 +1,8 @@ -import { RecommendationRequest } from "types/recommendations"; +import { + RecommendationFile, + RecommendationRequest, +} from "types/recommendations"; + import api from "./axios"; export const requestRecommendation = ( @@ -11,3 +15,16 @@ export const requestRecommendation = ( email, } ); + +export const uploadLetterOfRecommendation = ( + file: File, + fileName: string, + code: string +): Promise => { + const form = new FormData(); + form.append("file", file, fileName); + return api.format.post( + `/application/recommendation/${code}`, + form + ); +}; diff --git a/src/api/survey.ts b/src/api/survey.ts new file mode 100644 index 0000000..d85656c --- /dev/null +++ b/src/api/survey.ts @@ -0,0 +1,8 @@ +import { SurveyAnswers } from "types/survey"; +import api from "./axios"; + +export const postSurvey = ( + survey: SurveyAnswers, + applicantID = "@me" +): Promise => + api.format.post(`/application/${applicantID}/survey`, survey); diff --git a/src/api/user.ts b/src/api/user.ts index e244947..e85eeb4 100644 --- a/src/api/user.ts +++ b/src/api/user.ts @@ -1,8 +1,11 @@ +import { User } from "types/user"; import api from "./axios"; /** - * Delete account forever + * Delete user forever * @returns {Promise} */ -export const deleteAccount = (): Promise => - api.format.delete("/user/@me"); +export const deleteUser = (): Promise => api.format.delete("/user/@me"); + +export const getUser = (applicantID = "@me"): Promise => + api.format.get(`/user/${applicantID}`); diff --git a/src/components/Survey/index.tsx b/src/components/Survey/index.tsx index e56594c..b42b3b5 100644 --- a/src/components/Survey/index.tsx +++ b/src/components/Survey/index.tsx @@ -2,6 +2,7 @@ import * as Yup from "yup"; import { Accordion, Button, Card, Spinner } from "react-bootstrap"; import { Form, Formik } from "formik"; +import { Gender, SurveyAnswers } from "types/survey"; import FormControl from "react-bootstrap/FormControl"; import FormGroup from "react-bootstrap/FormGroup"; @@ -41,18 +42,6 @@ const StyledCard = styled(Card)` } `; -type Gender = "MALE" | "FEMALE" | "OTHER" | "UNDISCLOSED"; - -export interface SurveyAnswers { - city: string; - school: string; - gender: Gender; - applicationPortal: number; - applicationProcess: number; - improvement: string; - informant: string; -} - interface SurveyProps { survey?: SurveyAnswers; onSubmit: ( diff --git a/src/features/admin/RandomiseGradingOrder.tsx b/src/features/admin/RandomiseGradingOrder.tsx index d984861..8f2b4ef 100644 --- a/src/features/admin/RandomiseGradingOrder.tsx +++ b/src/features/admin/RandomiseGradingOrder.tsx @@ -1,7 +1,7 @@ import { Button, Spinner } from "react-bootstrap"; import React, { useState } from "react"; -import axios from "api/axios"; +import { randomiseOrder } from "api/admin"; import { toast } from "react-toastify"; import { updateGradingOrder } from "./adminSlice"; import { useDispatch } from "react-redux"; @@ -16,11 +16,10 @@ const RandomiseOrder = (): React.ReactElement => { variant="success" onClick={() => { setRandomising(true); - axios - .post("/admin/grading/randomise") + randomiseOrder() .then((res) => { setRandomising(false); - dispatch(updateGradingOrder(res.data)); + dispatch(updateGradingOrder(res)); }) .catch(() => { setRandomising(false); diff --git a/src/features/auth/AuthenticatedLayer.tsx b/src/features/auth/AuthenticatedLayer.tsx index a996df1..558eb74 100644 --- a/src/features/auth/AuthenticatedLayer.tsx +++ b/src/features/auth/AuthenticatedLayer.tsx @@ -5,7 +5,7 @@ import { userInfoSuccess, } from "features/auth/authSlice"; -import Axios from "api/axios"; +import { getUser } from "api/user"; import { useDispatch } from "react-redux"; import { useSelector } from "react-redux"; @@ -19,10 +19,10 @@ export default function AuthenticatedLayer( const dispatch = useDispatch(); const isAuthenticated = useSelector(selectAuthenticated); useEffect(() => { - Axios.get("/user/@me") + getUser() .then((res) => { dispatch(authSuccess()); - dispatch(userInfoSuccess(res.data)); + dispatch(userInfoSuccess(res)); }) .catch(console.error); }, [isAuthenticated]); diff --git a/src/features/auth/authSlice.ts b/src/features/auth/authSlice.ts index 3cea096..b5554cc 100644 --- a/src/features/auth/authSlice.ts +++ b/src/features/auth/authSlice.ts @@ -1,21 +1,10 @@ /* eslint-disable camelcase */ /* eslint-disable no-param-reassign */ import { PayloadAction, createSlice } from "@reduxjs/toolkit"; +import { User, UserTypes } from "types/user"; import { RootState } from "store"; -type UserType = "APPLICANT" | "ADMIN" | "SUPER_ADMIN"; - -interface User { - id: string; - email: string; - firstName: string; - lastName: string; - type: UserType; - verified: boolean; - created: string; -} - interface AuthState { isAuthorised: boolean; user: User | null; @@ -46,7 +35,7 @@ const authSlice = createSlice({ export const selectAuthenticated = (state: RootState): boolean => state.auth.isAuthorised; -export const selectUserType = (state: RootState): UserType | undefined => +export const selectUserType = (state: RootState): UserTypes | undefined => state.auth.user?.type; export const selectUserID = (state: RootState): string | undefined => diff --git a/src/features/auth/login/LoginWithCodeRoute.tsx b/src/features/auth/login/LoginWithCodeRoute.tsx index f375dfe..fa3cfb4 100644 --- a/src/features/auth/login/LoginWithCodeRoute.tsx +++ b/src/features/auth/login/LoginWithCodeRoute.tsx @@ -11,7 +11,7 @@ const LoginWithCodeRoute = (): React.ReactElement => { onSubmit={(values, { setErrors, setSubmitting }) => { loginWithCode(atob(emailInBase64), values.code).catch((err) => { setSubmitting(false); - if (err.request.status) setErrors({ code: "Wrong code" }); + if (err.params.Authorization) setErrors({ code: "Wrong code" }); else setErrors({ code: "Network error" }); }); }} diff --git a/src/features/files/UploadRecommendationLetter.tsx b/src/features/files/UploadRecommendationLetter.tsx index 381d812..f416d9d 100644 --- a/src/features/files/UploadRecommendationLetter.tsx +++ b/src/features/files/UploadRecommendationLetter.tsx @@ -4,7 +4,7 @@ import { Trans, useTranslation } from "react-i18next"; import CenterCard from "components/CenterCard"; import Upload from "components/portal/Upload"; -import axios from "api/axios"; +import { uploadLetterOfRecommendation } from "api/recommendations"; import useAxios from "axios-hooks"; import { useParams } from "react-router-dom"; @@ -37,17 +37,14 @@ const UploadState = ({ setError({ msg: t("too large"), fileName }); return; } - console.log(fileName); - const body = new FormData(); - body.append("file", file, fileName); setUploading(true); - axios - .post(`/application/recommendation/${recommendationCode}`, body) - .then((res) => { + uploadLetterOfRecommendation(file, fileName, recommendationCode).then( + (res) => { setUploading(false); setError(undefined); - setUploaded(res.data.name); - }); + setUploaded(res.fileName); + } + ); }} /> ); diff --git a/src/features/portal/Delete.tsx b/src/features/portal/Delete.tsx index cffc7c0..1f766a3 100644 --- a/src/features/portal/Delete.tsx +++ b/src/features/portal/Delete.tsx @@ -1,8 +1,8 @@ import { Button, Modal, Spinner } from "react-bootstrap"; import React, { useState } from "react"; -import Axios from "api/axios"; import { TokenStorage } from "utils/tokenInterceptor"; +import { deleteUser } from "api/user"; import { useTranslation } from "react-i18next"; interface ConfirmModalProps { @@ -36,7 +36,7 @@ const ConfirmModal = ({ show, onHide }: ConfirmModalProps) => { disabled={deleting} onClick={() => { setDelete(true); - Axios.delete("/user/@me").then(() => { + deleteUser().then(() => { TokenStorage.clear(); }); }} diff --git a/src/features/portal/Download.tsx b/src/features/portal/Download.tsx index 0ee2f04..f6de55b 100644 --- a/src/features/portal/Download.tsx +++ b/src/features/portal/Download.tsx @@ -1,9 +1,8 @@ import { Button, Spinner } from "react-bootstrap"; import React, { useState } from "react"; -import Axios from "api/axios"; import CSS from "csstype"; -import FileSaver from "file-saver"; +import { downloadFullPDF } from "api/files"; import { useTranslation } from "react-i18next"; interface DownloadProps { @@ -18,15 +17,7 @@ const Download = ({ style }: DownloadProps): React.ReactElement => { style={style} onClick={() => { setDownload(true); - Axios.get("/application/@me/pdf", { responseType: "blob" }).then( - (res) => { - setDownload(false); - FileSaver.saveAs( - res.data, - res.headers["content-disposition"].split("filename=")[1] - ); - } - ); + downloadFullPDF().then(() => setDownload(false)); }} disabled={downloading} > diff --git a/src/features/portal/Survey/index.tsx b/src/features/portal/Survey/index.tsx index 581f10c..9a6a456 100644 --- a/src/features/portal/Survey/index.tsx +++ b/src/features/portal/Survey/index.tsx @@ -1,14 +1,15 @@ -import Survey, { SurveyAnswers } from "components/Survey"; import { selectSurvey, setSurvey } from "../portalSlice"; import { useDispatch, useSelector } from "react-redux"; -import Axios from "api/axios"; import React from "react"; +import Survey from "components/Survey"; +import { SurveyAnswers } from "types/survey"; import moment from "moment"; -import useAxios from "axios-hooks"; +import { postSurvey } from "api/survey"; +import useApi from "hooks/useApi"; function useSurvey(): [SurveyAnswers | undefined, boolean] { - const [{ data, loading }] = useAxios("/application/@me/survey"); + const [{ data, loading }] = useApi("/application/@me/survey"); const dispatch = useDispatch(); if (data) dispatch(setSurvey(data)); const survey = useSelector(selectSurvey); @@ -21,14 +22,18 @@ const PortalSurvey = (): React.ReactElement => { if (loading) return
; const applicationHasClosed = moment.utc().month(2).endOf("month").diff(Date.now()) < 0; + const handleSubmit = React.useCallback( + (survey: SurveyAnswers) => + postSurvey(survey).then(() => { + dispatch(setSurvey(survey)); + return; + }), + [dispatch] + ); return ( { - return Axios.post("/application/@me/survey", newSurvey).then(() => { - dispatch(setSurvey(newSurvey)); - }); - }} + onSubmit={handleSubmit} disabled={applicationHasClosed} /> ); diff --git a/src/features/portal/portalSlice.ts b/src/features/portal/portalSlice.ts index c3abf04..62156b2 100644 --- a/src/features/portal/portalSlice.ts +++ b/src/features/portal/portalSlice.ts @@ -3,7 +3,7 @@ import { PayloadAction, createSlice } from "@reduxjs/toolkit"; import { RootState } from "store"; -import { SurveyAnswers } from "components/Survey"; +import { SurveyAnswers } from "types/survey"; export type FileType = | "CV" diff --git a/src/types/recommendations.ts b/src/types/recommendations.ts index 05fc214..b456e85 100644 --- a/src/types/recommendations.ts +++ b/src/types/recommendations.ts @@ -11,4 +11,5 @@ export type RecommendationRequest = { export interface RecommendationFile extends RecommendationRequest { applicantId: string; fileId: null | string; + fileName?: string; } diff --git a/src/types/survey.ts b/src/types/survey.ts index 8ec7914..c1c172a 100644 --- a/src/types/survey.ts +++ b/src/types/survey.ts @@ -12,8 +12,8 @@ export interface SurveyAnswers { city: string; school: string; gender: Gender; - applicationPortal: Grade; - applicationProcess: Grade; + applicationPortal: number; + applicationProcess: number; improvement: string; informant: string; } diff --git a/src/types/user.ts b/src/types/user.ts index f7e1042..a1eb5bf 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -5,9 +5,12 @@ export type User = { id: string; created: string; verified: boolean; + type?: UserTypes; }; -export interface Applicant extends User { +export type UserTypes = Applicant["type"] & Admin["type"]; + +export interface Applicant extends Omit { birthdate: string; finnish: boolean; type: "APPLICANT"; @@ -17,6 +20,6 @@ export type ServerUserFields = "id" | "created" | "verified"; export type NewAdmin = Omit; -export interface Admin extends User { +export interface Admin extends Omit { type: "ADMIN" | "SUPER_ADMIN"; } From 80b7a03a8e80fd656162f88c4ed9b822afa899f9 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 5 Apr 2021 10:25:17 +0200 Subject: [PATCH 18/98] Extracted Chapters and References from Portal --- src/config/portal.json | 8 ++-- src/features/portal/Chapters/index.tsx | 42 ------------------- src/features/portal/FileChapters.tsx | 34 +++++++++++++++ .../{References/index.tsx => References.tsx} | 5 ++- .../portal/{Survey/index.tsx => Survey.tsx} | 21 ++++++---- src/features/portal/TranslatedChapter.tsx | 25 +++++++++++ src/features/portal/index.tsx | 26 ++++-------- src/resources/locales/portal_en.json | 2 +- src/resources/locales/portal_sv.json | 2 +- 9 files changed, 89 insertions(+), 76 deletions(-) delete mode 100644 src/features/portal/Chapters/index.tsx create mode 100644 src/features/portal/FileChapters.tsx rename src/features/portal/{References/index.tsx => References.tsx} (94%) rename src/features/portal/{Survey/index.tsx => Survey.tsx} (78%) create mode 100644 src/features/portal/TranslatedChapter.tsx diff --git a/src/config/portal.json b/src/config/portal.json index b9630aa..411039d 100644 --- a/src/config/portal.json +++ b/src/config/portal.json @@ -34,10 +34,6 @@ "accept": ".pdf" } }, - { - "fileType": "survey", - "survey": true - }, { "fileType": "APPENDIX", "upload": { @@ -45,6 +41,10 @@ "accept": ".pdf" } }, + { + "fileType": "SURVEY", + "survey": true + }, { "fileType": "RECOMMENDATION_LETTER", "contactPeople": true diff --git a/src/features/portal/Chapters/index.tsx b/src/features/portal/Chapters/index.tsx deleted file mode 100644 index f3ac15b..0000000 --- a/src/features/portal/Chapters/index.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { WithTranslation, withTranslation } from "react-i18next"; - -import Chapter from "components/Chapter"; -import { FileType } from "../portalSlice"; -import PortalSurvey from "../Survey"; -import React from "react"; -import References from "../References"; -import Upload from "features/files/Upload"; -import portal from "config/portal.json"; - -interface ChaptersProps extends WithTranslation { - filesLoading?: boolean; - referencesLoading?: boolean; -} - -const Chapters: React.FC = ({ - t, - filesLoading, -}): React.ReactElement => ( - <> - {portal.chapters.map((chapter) => ( - - {chapter.upload && ( - - )} - {chapter.contactPeople && } - {chapter.survey && } - - ))} - -); -export default withTranslation()(Chapters); diff --git a/src/features/portal/FileChapters.tsx b/src/features/portal/FileChapters.tsx new file mode 100644 index 0000000..7303653 --- /dev/null +++ b/src/features/portal/FileChapters.tsx @@ -0,0 +1,34 @@ +import { WithTranslation, withTranslation } from "react-i18next"; + +import Chapter from "components/Chapter"; +import { FileType } from "./portalSlice"; +import React from "react"; +import Upload from "features/files/Upload"; +import portal from "config/portal.json"; +import { useFiles } from "features/files/filesHooks"; + +const Chapters = ({ t }: WithTranslation): React.ReactElement => { + const { loading } = useFiles(); + return ( + <> + {portal.chapters + .filter((chapter) => chapter.upload !== undefined) + .map((chapter) => ( + + + + ))} + + ); +}; +export default withTranslation()(Chapters); diff --git a/src/features/portal/References/index.tsx b/src/features/portal/References.tsx similarity index 94% rename from src/features/portal/References/index.tsx rename to src/features/portal/References.tsx index 9192a1f..82e3be7 100644 --- a/src/features/portal/References/index.tsx +++ b/src/features/portal/References.tsx @@ -3,6 +3,7 @@ import { Trans, withTranslation } from "react-i18next"; import { useDispatch, useSelector } from "react-redux"; import ContactPerson from "components/ContactPerson"; +import TranslatedChapter from "./TranslatedChapter"; import { addPersonSuccess } from "features/recommendations/recommendationsSlice"; import moment from "moment"; import { requestRecommendation } from "api/recommendations"; @@ -80,7 +81,9 @@ const References = (): React.ReactElement => { /> ); } - return <>{map}; + return ( + {map} + ); }; export default References; diff --git a/src/features/portal/Survey/index.tsx b/src/features/portal/Survey.tsx similarity index 78% rename from src/features/portal/Survey/index.tsx rename to src/features/portal/Survey.tsx index 9a6a456..3d045e0 100644 --- a/src/features/portal/Survey/index.tsx +++ b/src/features/portal/Survey.tsx @@ -1,9 +1,10 @@ -import { selectSurvey, setSurvey } from "../portalSlice"; +import { selectSurvey, setSurvey } from "./portalSlice"; import { useDispatch, useSelector } from "react-redux"; import React from "react"; import Survey from "components/Survey"; import { SurveyAnswers } from "types/survey"; +import TranslatedChapter from "./TranslatedChapter"; import moment from "moment"; import { postSurvey } from "api/survey"; import useApi from "hooks/useApi"; @@ -19,9 +20,6 @@ function useSurvey(): [SurveyAnswers | undefined, boolean] { const PortalSurvey = (): React.ReactElement => { const [survey, loading] = useSurvey(); const dispatch = useDispatch(); - if (loading) return
; - const applicationHasClosed = - moment.utc().month(2).endOf("month").diff(Date.now()) < 0; const handleSubmit = React.useCallback( (survey: SurveyAnswers) => postSurvey(survey).then(() => { @@ -30,12 +28,17 @@ const PortalSurvey = (): React.ReactElement => { }), [dispatch] ); + if (loading) return
; + const applicationHasClosed = + moment.utc().month(2).endOf("month").diff(Date.now()) < 0; return ( - + + + ); }; diff --git a/src/features/portal/TranslatedChapter.tsx b/src/features/portal/TranslatedChapter.tsx new file mode 100644 index 0000000..e1b8ee7 --- /dev/null +++ b/src/features/portal/TranslatedChapter.tsx @@ -0,0 +1,25 @@ +import { WithTranslation, withTranslation } from "react-i18next"; + +import Chapter from "components/Chapter"; +import React from "react"; + +interface TranslatedChapterProps extends WithTranslation { + type: string; +} + +const TranslatedChapter: React.FC = ({ + t, + children, + type, +}) => ( + + {children} + +); + +export default withTranslation()(TranslatedChapter); diff --git a/src/features/portal/index.tsx b/src/features/portal/index.tsx index 33c1492..824334a 100644 --- a/src/features/portal/index.tsx +++ b/src/features/portal/index.tsx @@ -1,35 +1,23 @@ import { ButtonGroup, ProgressBar } from "react-bootstrap"; -import React, { useEffect, useState } from "react"; -import { useDispatch, useSelector } from "react-redux"; import Alert from "react-bootstrap/Alert"; import Center from "components/Center"; -import Chapters from "./Chapters"; import Delete from "./Delete"; import Download from "./Download"; +import FileChapters from "./FileChapters"; import Logo from "components/Logo"; import Logout from "./Logout"; +import React from "react"; import ReactMarkdown from "react-markdown"; +import References from "./References"; import StyledPlate from "components/Plate"; -import { getFiles } from "api/files"; +import Survey from "./Survey"; import { selectProgress } from "./portalSlice"; -import { setFiles } from "features/files/filesSlice"; +import { useSelector } from "react-redux"; import { useTranslation } from "react-i18next"; const Hook = (): React.ReactElement => { - const dispatch = useDispatch(); - const [filesLoading, setFilesLoading] = useState(true); - - useEffect(() => { - getFiles() - .then((res) => { - dispatch(setFiles(res)); - setFilesLoading(false); - }) - .catch(console.error); - }, []); const progress = useSelector(selectProgress); - const { t } = useTranslation(); return ( @@ -52,7 +40,9 @@ const Hook = (): React.ReactElement => {
- + + +
{progress === 5 && ( {t("Application complete")} diff --git a/src/resources/locales/portal_en.json b/src/resources/locales/portal_en.json index 1bd3eaa..0d3d0e4 100644 --- a/src/resources/locales/portal_en.json +++ b/src/resources/locales/portal_en.json @@ -33,7 +33,7 @@ "label": "Upload grades" } }, - "survey": { + "SURVEY": { "title": "Survey", "subtitle": "", "description": "We who arrange Rays want to know where you're from, what school you're studying at, how you've heard of Rays as well as what you think of the application process. This is so we can become even better at marketing us and develop our application process. Please fill in the survey and press save to save your answers." diff --git a/src/resources/locales/portal_sv.json b/src/resources/locales/portal_sv.json index 5212b92..ee756e3 100644 --- a/src/resources/locales/portal_sv.json +++ b/src/resources/locales/portal_sv.json @@ -33,7 +33,7 @@ "label": "Ladda upp betyg" } }, - "survey": { + "SURVEY": { "title": "Formulär", "subtitle": "", "description": "Vi som arrangerar Rays vill veta varifrån du kommer, på vilken gymnasieskola du studerar, hur du hört talas om Rays samt vad du tycker om ansökningsprocessen. Allt detta för att vi ska kunna bli ännu bättre på att marknadsföra oss samt utveckla ansökningprocessen. Fyll därför i formuläret nedan och klicka på skicka för att spara ditt svar." From d2286bc331d378e1adda7064c492afbf3c17d284 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 5 Apr 2021 10:55:43 +0200 Subject: [PATCH 19/98] Extracted survey from portal --- src/features/portal/FileChapters.tsx | 2 +- src/features/portal/Progress.tsx | 26 ++++ src/features/portal/References.tsx | 2 +- src/features/portal/SurveyChapter.tsx | 11 ++ src/features/portal/index.tsx | 26 +--- src/features/portal/portalSelectors.ts | 18 +++ src/features/portal/portalSlice.ts | 120 ------------------ .../{portal/Survey.tsx => survey/index.tsx} | 15 +-- src/features/survey/surveySlice.ts | 28 ++++ src/store.ts | 4 +- src/utils/tokenInterceptor.ts | 4 +- 11 files changed, 100 insertions(+), 156 deletions(-) create mode 100644 src/features/portal/Progress.tsx create mode 100644 src/features/portal/SurveyChapter.tsx create mode 100644 src/features/portal/portalSelectors.ts delete mode 100644 src/features/portal/portalSlice.ts rename src/features/{portal/Survey.tsx => survey/index.tsx} (78%) create mode 100644 src/features/survey/surveySlice.ts diff --git a/src/features/portal/FileChapters.tsx b/src/features/portal/FileChapters.tsx index 7303653..4e0899b 100644 --- a/src/features/portal/FileChapters.tsx +++ b/src/features/portal/FileChapters.tsx @@ -1,7 +1,7 @@ import { WithTranslation, withTranslation } from "react-i18next"; import Chapter from "components/Chapter"; -import { FileType } from "./portalSlice"; +import { FileType } from "types/files"; import React from "react"; import Upload from "features/files/Upload"; import portal from "config/portal.json"; diff --git a/src/features/portal/Progress.tsx b/src/features/portal/Progress.tsx new file mode 100644 index 0000000..6f35f34 --- /dev/null +++ b/src/features/portal/Progress.tsx @@ -0,0 +1,26 @@ +import Alert from "react-bootstrap/Alert"; +import ProgressBar from "react-bootstrap/ProgressBar"; +import React from "react"; +import { selectProgress } from "./portalSelectors"; +import { useSelector } from "react-redux"; +import { useTranslation } from "react-i18next"; + +const Progress = (): React.ReactElement => { + const { t } = useTranslation(); + const progress = useSelector(selectProgress()); + return ( + <> + + {progress === 5 && ( + + {t("Application complete")} + + )} + + ); +}; +export default Progress; diff --git a/src/features/portal/References.tsx b/src/features/portal/References.tsx index 82e3be7..78bf559 100644 --- a/src/features/portal/References.tsx +++ b/src/features/portal/References.tsx @@ -14,7 +14,7 @@ import { useRecommendations } from "features/recommendations/recommendationHooks const UploadLink = ({ code }: { code: string }) => ( diff --git a/src/features/portal/SurveyChapter.tsx b/src/features/portal/SurveyChapter.tsx new file mode 100644 index 0000000..7c538ae --- /dev/null +++ b/src/features/portal/SurveyChapter.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import Survey from "features/survey"; +import TranslatedChapter from "./TranslatedChapter"; + +const SurveyChapter = (): React.ReactElement => ( + + + +); + +export default SurveyChapter; diff --git a/src/features/portal/index.tsx b/src/features/portal/index.tsx index 824334a..4969b8c 100644 --- a/src/features/portal/index.tsx +++ b/src/features/portal/index.tsx @@ -1,23 +1,19 @@ -import { ButtonGroup, ProgressBar } from "react-bootstrap"; - -import Alert from "react-bootstrap/Alert"; +import ButtonGroup from "react-bootstrap/ButtonGroup"; import Center from "components/Center"; import Delete from "./Delete"; import Download from "./Download"; import FileChapters from "./FileChapters"; import Logo from "components/Logo"; import Logout from "./Logout"; +import Progress from "./Progress"; import React from "react"; import ReactMarkdown from "react-markdown"; import References from "./References"; import StyledPlate from "components/Plate"; -import Survey from "./Survey"; -import { selectProgress } from "./portalSlice"; -import { useSelector } from "react-redux"; +import Survey from "features/survey"; import { useTranslation } from "react-i18next"; const Hook = (): React.ReactElement => { - const progress = useSelector(selectProgress); const { t } = useTranslation(); return ( @@ -27,26 +23,14 @@ const Hook = (): React.ReactElement => {

{t("title")}

- - {progress === 5 && ( - - {t("Application complete")} - - )}
-
- {progress === 5 && ( - {t("Application complete")} - )} + +
diff --git a/src/features/portal/portalSelectors.ts b/src/features/portal/portalSelectors.ts new file mode 100644 index 0000000..9d85b3d --- /dev/null +++ b/src/features/portal/portalSelectors.ts @@ -0,0 +1,18 @@ +import { FileType } from "types/files"; +import { RootState } from "store"; + +export const selectProgress = (applicantID?: string) => ( + state: RootState +): number => { + const id = applicantID || state.auth.user?.id; + let progress = 0; + if (!id) return progress; + const check: Array = ["CV", "COVER_LETTER", "GRADES", "ESSAY"]; + check.forEach((type) => { + const filesByTypes = state.files.fileTypesByApplicants[id]; + const files = filesByTypes?.[type]; + if (files?.length) progress++; + }); + if (state.survey[id]) progress++; + return progress; +}; diff --git a/src/features/portal/portalSlice.ts b/src/features/portal/portalSlice.ts deleted file mode 100644 index 62156b2..0000000 --- a/src/features/portal/portalSlice.ts +++ /dev/null @@ -1,120 +0,0 @@ -/* eslint-disable camelcase */ -/* eslint-disable no-param-reassign */ -import { PayloadAction, createSlice } from "@reduxjs/toolkit"; - -import { RootState } from "store"; -import { SurveyAnswers } from "types/survey"; - -export type FileType = - | "CV" - | "COVER_LETTER" - | "GRADES" - | "RECOMMENDATION_LETTER" - | "APPENDIX" - | "ESSAY"; - -export type FileID = string; - -export type FileInfo = { - id: FileID; - userId: string; - type: FileType; - created: string; - name: string; - mime: string; -}; - -export type Recommendation = { - id: string; - code?: string; - applicantId: string; - email: string; - lastSent: string; - received: null | string; - fileId: null | string; - index: number; -}; - -interface PortalState { - files: Record; - filesByType: Partial>; - recommendations: Recommendation[]; - survey?: SurveyAnswers; -} - -export const initialState: PortalState = { - files: {}, - filesByType: {}, - recommendations: [], - survey: undefined, -}; - -const portalSlice = createSlice({ - name: "portal", - initialState, - reducers: { - setFiles(state, action: PayloadAction) { - action.payload.forEach((file) => { - state.files[file.id] = file; - if (state.filesByType[file.type]) - state.filesByType[file.type]?.unshift(file.id); - else state.filesByType[file.type] = [file.id]; - }); - }, - replaceFile(state, action: PayloadAction) { - const file = action.payload; - state.files[file.id] = file; - if (state.filesByType[file.type]) - state.filesByType[file.type] = [file.id]; - else state.filesByType[file.type] = [file.id]; - }, - uploadSuccess(state, action: PayloadAction) { - const file = action.payload; - state.files[file.id] = file; - if (state.filesByType[file.type]) - state.filesByType[file.type]?.push(file.id); - else state.filesByType[file.type] = [file.id]; - }, - setSurvey(state, action: PayloadAction) { - state.survey = action.payload; - }, - clearPortal(state) { - Object.assign(state, initialState); - }, - deleteFileSuccess(state, action: PayloadAction) { - const file = state.files[action.payload]; - const index = state.filesByType[file.type]?.indexOf(action.payload); - if (index !== undefined && index > -1) { - (state.filesByType[file.type] as FileID[]).splice(index, 1); - } - }, - }, -}); - -export const selectSurvey = (state: RootState): SurveyAnswers | undefined => - state.portal.survey; -export const selectProgress = (state: RootState): number => { - let i = 0; - const check: FileType[] = ["CV", "COVER_LETTER", "GRADES", "ESSAY"]; - check.forEach((name: FileType) => { - if ( - state.portal.filesByType[name] !== undefined && - state.portal.filesByType[name]?.length - ) - i++; - }); - if (state.portal.survey !== undefined) i++; - return i; -}; - -export const { - setFiles, - uploadSuccess, - - setSurvey, - clearPortal, - deleteFileSuccess, - replaceFile, -} = portalSlice.actions; - -export default portalSlice.reducer; diff --git a/src/features/portal/Survey.tsx b/src/features/survey/index.tsx similarity index 78% rename from src/features/portal/Survey.tsx rename to src/features/survey/index.tsx index 3d045e0..6c0136b 100644 --- a/src/features/portal/Survey.tsx +++ b/src/features/survey/index.tsx @@ -1,10 +1,9 @@ -import { selectSurvey, setSurvey } from "./portalSlice"; +import { selectSurvey, setSurvey } from "./surveySlice"; import { useDispatch, useSelector } from "react-redux"; import React from "react"; import Survey from "components/Survey"; import { SurveyAnswers } from "types/survey"; -import TranslatedChapter from "./TranslatedChapter"; import moment from "moment"; import { postSurvey } from "api/survey"; import useApi from "hooks/useApi"; @@ -32,13 +31,11 @@ const PortalSurvey = (): React.ReactElement => { const applicationHasClosed = moment.utc().month(2).endOf("month").diff(Date.now()) < 0; return ( - - - + ); }; diff --git a/src/features/survey/surveySlice.ts b/src/features/survey/surveySlice.ts new file mode 100644 index 0000000..1109a26 --- /dev/null +++ b/src/features/survey/surveySlice.ts @@ -0,0 +1,28 @@ +import { PayloadAction, createSlice } from "@reduxjs/toolkit"; + +import { RootState } from "store"; +import { SurveyAnswers } from "types/survey"; + +type SurveyState = Record; + +export const initialState: SurveyState = {}; + +const surveySlice = createSlice({ + name: "survey", + initialState, + reducers: { + setSurvey(state, action: PayloadAction) { + state.survey = action.payload; + }, + clearSurvey(state) { + Object.assign(state, initialState); + }, + }, +}); + +export const selectSurvey = (state: RootState): SurveyAnswers | undefined => + state.survey.survey; + +export const { setSurvey, clearSurvey } = surveySlice.actions; + +export default surveySlice.reducer; diff --git a/src/store.ts b/src/store.ts index a1b4afa..6726edf 100644 --- a/src/store.ts +++ b/src/store.ts @@ -17,9 +17,9 @@ import { import admin from "features/admin/adminSlice"; import auth from "features/auth/authSlice"; import files from "features/files/filesSlice"; -import portal from "features/portal/portalSlice"; import recommendations from "features/recommendations/recommendationsSlice"; import storage from "redux-persist/lib/storage"; // defaults to localStorage for web +import survey from "features/survey/surveySlice"; const persistConfig = { key: "root", @@ -29,10 +29,10 @@ const persistConfig = { const rootReducer = combineReducers({ auth, - portal, admin, files, recommendations, + survey, }); const persistedReducer = persistReducer(persistConfig, rootReducer); diff --git a/src/utils/tokenInterceptor.ts b/src/utils/tokenInterceptor.ts index 10fd87d..b937833 100644 --- a/src/utils/tokenInterceptor.ts +++ b/src/utils/tokenInterceptor.ts @@ -2,7 +2,7 @@ import { authFail, authSuccess } from "features/auth/authSlice"; import { ServerTokenResponse } from "types/tokens"; import axios from "api/axios"; -import { clearPortal } from "features/portal/portalSlice"; +import { clearSurvey } from "features/survey/surveySlice"; import i18n from "i18n"; import store from "store"; import { toast } from "react-toastify"; @@ -99,7 +99,7 @@ export class TokenStorage { localStorage.removeItem(TokenStorage.LOCAL_STORAGE_REFRESH_TOKEN); localStorage.removeItem(TokenStorage.LOCAL_STORAGE_TOKEN_EXPIRY); store.dispatch(authFail()); - store.dispatch(clearPortal()); + store.dispatch(clearSurvey()); } private static getRefreshToken(): string | null { From feb1952a98263f1d7f4a5361857d9192608d0674 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 5 Apr 2021 10:59:03 +0200 Subject: [PATCH 20/98] Extracted Introduction from portal --- src/features/portal/Introduction.tsx | 13 +++++++++++++ src/features/portal/index.tsx | 15 ++++----------- 2 files changed, 17 insertions(+), 11 deletions(-) create mode 100644 src/features/portal/Introduction.tsx diff --git a/src/features/portal/Introduction.tsx b/src/features/portal/Introduction.tsx new file mode 100644 index 0000000..98958ce --- /dev/null +++ b/src/features/portal/Introduction.tsx @@ -0,0 +1,13 @@ +import { WithTranslation, withTranslation } from "react-i18next"; + +import React from "react"; +import ReactMarkdown from "react-markdown"; + +const Introduction = ({ t }: WithTranslation) => ( +
+

{t("title")}

+ +
+
+); +export default withTranslation()(Introduction); diff --git a/src/features/portal/index.tsx b/src/features/portal/index.tsx index 4969b8c..0f62339 100644 --- a/src/features/portal/index.tsx +++ b/src/features/portal/index.tsx @@ -3,28 +3,21 @@ import Center from "components/Center"; import Delete from "./Delete"; import Download from "./Download"; import FileChapters from "./FileChapters"; +import Introduction from "./Introduction"; import Logo from "components/Logo"; import Logout from "./Logout"; import Progress from "./Progress"; import React from "react"; -import ReactMarkdown from "react-markdown"; import References from "./References"; import StyledPlate from "components/Plate"; import Survey from "features/survey"; -import { useTranslation } from "react-i18next"; - -const Hook = (): React.ReactElement => { - const { t } = useTranslation(); +const Portal = (): React.ReactElement => { return (
-
-

{t("title")}

- -
-
+
@@ -43,4 +36,4 @@ const Hook = (): React.ReactElement => { ); }; -export default Hook; +export default Portal; From 4339b2e0f9d204c0dd51fd184759528dd83c8c9b Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 5 Apr 2021 11:19:50 +0200 Subject: [PATCH 21/98] Extracted survey hooks --- src/api/recommendations.ts | 3 ++ src/api/survey.ts | 3 ++ src/features/admin/Statistics.tsx | 51 +++++++++++++++++------------- src/features/files/filesHooks.ts | 17 ++++++++-- src/features/files/filesSlice.ts | 11 ++++--- src/features/portal/index.tsx | 4 +-- src/features/survey/index.tsx | 30 +++--------------- src/features/survey/surveyHooks.ts | 27 ++++++++++++++++ src/hooks/useApi.ts | 6 ++-- 9 files changed, 92 insertions(+), 60 deletions(-) create mode 100644 src/features/survey/surveyHooks.ts diff --git a/src/api/recommendations.ts b/src/api/recommendations.ts index facf0e9..df21251 100644 --- a/src/api/recommendations.ts +++ b/src/api/recommendations.ts @@ -16,6 +16,9 @@ export const requestRecommendation = ( } ); +export const getRecommendationRequestConfig = (code: string): string => + `/application/recommendation/${code}`; + export const uploadLetterOfRecommendation = ( file: File, fileName: string, diff --git a/src/api/survey.ts b/src/api/survey.ts index d85656c..3d53c5e 100644 --- a/src/api/survey.ts +++ b/src/api/survey.ts @@ -1,6 +1,9 @@ import { SurveyAnswers } from "types/survey"; import api from "./axios"; +export const getSurveyConfig = (applicantID = "@me"): string => + `/application/${applicantID}/survey`; + export const postSurvey = ( survey: SurveyAnswers, applicantID = "@me" diff --git a/src/features/admin/Statistics.tsx b/src/features/admin/Statistics.tsx index 51a0156..726ef63 100644 --- a/src/features/admin/Statistics.tsx +++ b/src/features/admin/Statistics.tsx @@ -84,28 +84,35 @@ function StatisticsPage(): React.ReactElement { ); return (
- - - - - - - + {data && ( + <> + + + + + + + + + )}
); } diff --git a/src/features/files/filesHooks.ts b/src/features/files/filesHooks.ts index 9194db5..ed028e0 100644 --- a/src/features/files/filesHooks.ts +++ b/src/features/files/filesHooks.ts @@ -1,8 +1,10 @@ import { selectApplicantFilesLoaded, setFiles } from "./filesSlice"; +import useApi, { UseApi } from "hooks/useApi"; import { useDispatch, useSelector } from "react-redux"; import { FileInfo } from "types/files"; -import useApi from "hooks/useApi"; +import { RecommendationFile } from "types/recommendations"; +import { getRecommendationRequestConfig } from "api/recommendations"; type UseFiles = { loading: boolean; @@ -10,14 +12,23 @@ type UseFiles = { export function useFiles(applicantID = "@me"): UseFiles { const dispatch = useDispatch(); - const files = useSelector(selectApplicantFilesLoaded(applicantID)); + const filesLoaded = useSelector(selectApplicantFilesLoaded(applicantID)); const [{ loading, data }] = useApi({ url: `/application/${applicantID}/file`, }); - if (Boolean(files) === false && data) { + if (filesLoaded === false && data) { dispatch(setFiles(data)); } return { loading, }; } + +export function useRecommendationLetter( + code: string +): UseApi { + const [{ loading, data, error }] = useApi( + getRecommendationRequestConfig(code) + ); + return { loading, data, error }; +} diff --git a/src/features/files/filesSlice.ts b/src/features/files/filesSlice.ts index a788a29..b848cc7 100644 --- a/src/features/files/filesSlice.ts +++ b/src/features/files/filesSlice.ts @@ -1,6 +1,4 @@ import { FileID, FileInfo, FileType } from "types/files"; -/* eslint-disable camelcase */ -/* eslint-disable no-param-reassign */ import { PayloadAction, createSlice } from "@reduxjs/toolkit"; import { RootState } from "store"; @@ -24,9 +22,14 @@ const filesSlice = createSlice({ if (state.fileTypesByApplicants[file.userId] === undefined) { state.fileTypesByApplicants[file.userId] = {}; } - if (state.fileTypesByApplicants[file.userId][file.type]) + if ( + state.fileTypesByApplicants[file.userId][file.type] && + state.fileTypesByApplicants[file.userId][file.type]?.findIndex( + (f) => f.id === file.id + ) === -1 + ) { state.fileTypesByApplicants[file.userId][file.type]?.push(file); - else state.fileTypesByApplicants[file.userId][file.type] = [file]; + } else state.fileTypesByApplicants[file.userId][file.type] = [file]; }); }, replaceFile(state, action: PayloadAction) { diff --git a/src/features/portal/index.tsx b/src/features/portal/index.tsx index 0f62339..f62ff3d 100644 --- a/src/features/portal/index.tsx +++ b/src/features/portal/index.tsx @@ -10,7 +10,7 @@ import Progress from "./Progress"; import React from "react"; import References from "./References"; import StyledPlate from "components/Plate"; -import Survey from "features/survey"; +import SurveyChapter from "./SurveyChapter"; const Portal = (): React.ReactElement => { return ( @@ -20,7 +20,7 @@ const Portal = (): React.ReactElement => {
- +
diff --git a/src/features/survey/index.tsx b/src/features/survey/index.tsx index 6c0136b..af34995 100644 --- a/src/features/survey/index.tsx +++ b/src/features/survey/index.tsx @@ -1,39 +1,17 @@ -import { selectSurvey, setSurvey } from "./surveySlice"; -import { useDispatch, useSelector } from "react-redux"; - import React from "react"; import Survey from "components/Survey"; -import { SurveyAnswers } from "types/survey"; import moment from "moment"; -import { postSurvey } from "api/survey"; -import useApi from "hooks/useApi"; - -function useSurvey(): [SurveyAnswers | undefined, boolean] { - const [{ data, loading }] = useApi("/application/@me/survey"); - const dispatch = useDispatch(); - if (data) dispatch(setSurvey(data)); - const survey = useSelector(selectSurvey); - return [survey, loading]; -} +import { useSurvey } from "./surveyHooks"; const PortalSurvey = (): React.ReactElement => { - const [survey, loading] = useSurvey(); - const dispatch = useDispatch(); - const handleSubmit = React.useCallback( - (survey: SurveyAnswers) => - postSurvey(survey).then(() => { - dispatch(setSurvey(survey)); - return; - }), - [dispatch] - ); + const { data, loading, updateSurvey } = useSurvey(); if (loading) return
; const applicationHasClosed = moment.utc().month(2).endOf("month").diff(Date.now()) < 0; return ( ); diff --git a/src/features/survey/surveyHooks.ts b/src/features/survey/surveyHooks.ts new file mode 100644 index 0000000..3b8d469 --- /dev/null +++ b/src/features/survey/surveyHooks.ts @@ -0,0 +1,27 @@ +import { getSurveyConfig, postSurvey } from "api/survey"; +import { selectSurvey, setSurvey } from "./surveySlice"; +import useApi, { UseApi } from "hooks/useApi"; +import { useDispatch, useSelector } from "react-redux"; + +import { SurveyAnswers } from "types/survey"; +import { useCallback } from "react"; + +interface UseSurvey extends UseApi { + updateSurvey: (survey: SurveyAnswers) => Promise; +} + +export function useSurvey(applicantID = "@me"): UseSurvey { + const [{ data, loading, error }] = useApi(getSurveyConfig(applicantID)); + const dispatch = useDispatch(); + const survey = useSelector(selectSurvey); + if (data && survey === undefined) dispatch(setSurvey(data)); + const updateSurvey = useCallback( + (survey: SurveyAnswers) => + postSurvey(survey).then(() => { + dispatch(setSurvey(survey)); + return; + }), + [dispatch] + ); + return { loading, data: survey, error, updateSurvey }; +} diff --git a/src/hooks/useApi.ts b/src/hooks/useApi.ts index 561fbea..0dde5b7 100644 --- a/src/hooks/useApi.ts +++ b/src/hooks/useApi.ts @@ -6,10 +6,10 @@ export const useApi = makeUseAxios({ }); // eslint-disable-next-line @typescript-eslint/no-explicit-any -export interface UseApi { +export interface UseApi { loading: boolean; - data: T; - error: E; + data?: T; + error?: E; } export default useApi; From ab8cdd92e39973f65bfd33d624eb7247f283a3c3 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 5 Apr 2021 11:24:28 +0200 Subject: [PATCH 22/98] Moved NoMatch --- .../nomatch/index.tsx => components/NoMatch.tsx} | 8 ++++---- src/components/nomatch/index.jsx | 8 -------- src/features/admin/AdminView.tsx | 2 +- src/features/router/index.tsx | 2 +- 4 files changed, 6 insertions(+), 14 deletions(-) rename src/{features/nomatch/index.tsx => components/NoMatch.tsx} (68%) delete mode 100644 src/components/nomatch/index.jsx diff --git a/src/features/nomatch/index.tsx b/src/components/NoMatch.tsx similarity index 68% rename from src/features/nomatch/index.tsx rename to src/components/NoMatch.tsx index 424e709..ad33d32 100644 --- a/src/features/nomatch/index.tsx +++ b/src/components/NoMatch.tsx @@ -1,8 +1,8 @@ -import Center from "components/Center"; -import Plate from "components/Plate"; +import Center from "./Center"; +import Plate from "./Plate"; import React from "react"; -import Star from "components/Star"; -import StyledTitle from "components/StyledTitle"; +import Star from "./Star"; +import StyledTitle from "./StyledTitle"; import { Trans } from "react-i18next"; const NoMatch = (): React.ReactElement => ( diff --git a/src/components/nomatch/index.jsx b/src/components/nomatch/index.jsx deleted file mode 100644 index 9d74bbb..0000000 --- a/src/components/nomatch/index.jsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from 'react'; -import Plate from 'components/Plate'; - -export default () => ( - - 404 - -); diff --git a/src/features/admin/AdminView.tsx b/src/features/admin/AdminView.tsx index 882af1c..fd00ed0 100644 --- a/src/features/admin/AdminView.tsx +++ b/src/features/admin/AdminView.tsx @@ -9,7 +9,7 @@ import Plate from "components/Plate"; import Spinner from "react-bootstrap/Spinner"; const TopList = lazy(() => import("./TopList")); -const NoMatch = lazy(() => import("features/nomatch")); +const NoMatch = lazy(() => import("components/NoMatch")); const Administration = lazy(() => import("./Administration")); const Statistics = lazy(() => import("./Statistics")); const GradingView = lazy(() => import("./GradingView")); diff --git a/src/features/router/index.tsx b/src/features/router/index.tsx index f80efb9..82f2a69 100644 --- a/src/features/router/index.tsx +++ b/src/features/router/index.tsx @@ -8,7 +8,7 @@ const AutomaticLogin = lazy(() => import("features/auth/login/AutomaticLogin")); const Login = lazy(() => import("features/auth/login")); const Portal = lazy(() => import("features/portal")); const Register = lazy(() => import("features/auth/register")); -const NoMatch = lazy(() => import("features/nomatch")); +const NoMatch = lazy(() => import("components/NoMatch")); const Recommendation = lazy( () => import("features/files/UploadRecommendationLetter") ); From 7ac9072bade20e62135bce54fb3d8292c6c6d2d5 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 5 Apr 2021 11:57:49 +0200 Subject: [PATCH 23/98] Extracted Reference --- src/features/auth/register/index.tsx | 8 +- src/features/files/Upload.tsx | 8 +- src/features/portal/RecommendationChapter.tsx | 15 ++++ src/features/portal/References.tsx | 89 ------------------- src/features/portal/index.tsx | 4 +- src/features/recommendations/Reference.tsx | 49 ++++++++++ .../recommendations/TranslatedUploadLink.tsx | 18 ++++ .../recommendations/recommendationHooks.ts | 32 ++++++- src/features/survey/index.tsx | 13 +-- src/types/recommendations.ts | 9 +- src/utils/hasApplicationClosed.ts | 6 ++ 11 files changed, 134 insertions(+), 117 deletions(-) create mode 100644 src/features/portal/RecommendationChapter.tsx delete mode 100644 src/features/portal/References.tsx create mode 100644 src/features/recommendations/Reference.tsx create mode 100644 src/features/recommendations/TranslatedUploadLink.tsx create mode 100644 src/utils/hasApplicationClosed.ts diff --git a/src/features/auth/register/index.tsx b/src/features/auth/register/index.tsx index a425542..a48e4c2 100644 --- a/src/features/auth/register/index.tsx +++ b/src/features/auth/register/index.tsx @@ -17,6 +17,7 @@ import Plate from "components/Plate"; import React from "react"; import Spinner from "react-bootstrap/Spinner"; import StyledGroup from "components/StyledGroup"; +import hasApplicationClosed from "utils/hasApplicationClosed"; import moment from "moment"; import { register } from "api/register"; import sendLoginCodeAndShowCode from "api/sendLoginCode"; @@ -33,8 +34,7 @@ const MaskedField = (props: MaskedFieldProps) => ( const Register: React.FC = ({ t }) => { const { push } = useHistory(); const showCode = useShowCode(); - const applicationHasClosed = - moment.utc().month(2).endOf("month").diff(Date.now()) > 0; + const closed = hasApplicationClosed(); return (
@@ -218,9 +218,9 @@ const Register: React.FC = ({ t }) => { type="submit" variant="custom" style={{ minWidth: 300, width: "50%", margin: "0 25%" }} - disabled={isSubmitting || applicationHasClosed} + disabled={isSubmitting || closed} > - {applicationHasClosed ? ( + {closed ? ( t("Application has closed") ) : isSubmitting ? ( <> diff --git a/src/features/files/Upload.tsx b/src/features/files/Upload.tsx index 8ff1ab8..901368b 100644 --- a/src/features/files/Upload.tsx +++ b/src/features/files/Upload.tsx @@ -10,7 +10,7 @@ import { useDispatch, useSelector } from "react-redux"; import { FileType } from "types/files"; import Upload from "components/portal/Upload"; -import moment from "moment"; +import hasApplicationClosed from "utils/hasApplicationClosed"; import { toast } from "react-toastify"; import { useTranslation } from "react-i18next"; @@ -82,10 +82,8 @@ const UploadHook: React.FC = ({ const handleCancel = () => setUploadingFiles([]); - const applicationHasClosed = - moment.utc().month(2).endOf("month").diff(Date.now()) < 0; - const disabledUploading = - (applicationHasClosed && !alwaysAbleToUpload) || disabled; + const closed = hasApplicationClosed(); + const disabledUploading = (closed && !alwaysAbleToUpload) || disabled; const label = t(`${fileType}.upload.label`); return ( diff --git a/src/features/portal/RecommendationChapter.tsx b/src/features/portal/RecommendationChapter.tsx new file mode 100644 index 0000000..1899841 --- /dev/null +++ b/src/features/portal/RecommendationChapter.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import Reference from "features/recommendations/Reference"; +import TranslatedChapter from "./TranslatedChapter"; + +const RecommendationChapter = (): React.ReactElement => { + const map = []; + for (let i = 0; i < 3; i += 1) { + map[i] = ; + } + return ( + {map} + ); +}; + +export default RecommendationChapter; diff --git a/src/features/portal/References.tsx b/src/features/portal/References.tsx deleted file mode 100644 index 78bf559..0000000 --- a/src/features/portal/References.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React, { useState } from "react"; -import { Trans, withTranslation } from "react-i18next"; -import { useDispatch, useSelector } from "react-redux"; - -import ContactPerson from "components/ContactPerson"; -import TranslatedChapter from "./TranslatedChapter"; -import { addPersonSuccess } from "features/recommendations/recommendationsSlice"; -import moment from "moment"; -import { requestRecommendation } from "api/recommendations"; -import { selectRecommendationByIndexAndApplicant } from "features/recommendations/recommendationsSlice"; -import { toast } from "react-toastify"; -import { useRecommendations } from "features/recommendations/recommendationHooks"; - -const UploadLink = ({ code }: { code: string }) => ( - - - -); - -const TranslatedUploadLink = withTranslation()(UploadLink); - -interface PersonProps { - recommendationIndex: number; - initialLoading?: boolean; - disabled?: boolean; -} - -const Person = ({ - recommendationIndex, - initialLoading, - disabled, -}: PersonProps) => { - const [loading, setLoading] = useState(false); - const recommendation = useSelector( - selectRecommendationByIndexAndApplicant(recommendationIndex) - ); - const dispatch = useDispatch(); - const applicationHasClosed = - moment.utc().month(2).endOf("month").diff(Date.now()) < 0; - function handleSubmit(email: string) { - setLoading(true); - requestRecommendation(recommendationIndex, email) - .then((res) => { - setLoading(false); - dispatch(addPersonSuccess([res])); - if (res.code) - toast(, { - position: "bottom-center", - autoClose: false, - }); - }) - .catch(console.error); - } - return ( - - ); -}; - -const References = (): React.ReactElement => { - const map = []; - const { loading } = useRecommendations(); - for (let i = 0; i < 3; i += 1) { - map[i] = ( - - ); - } - return ( - {map} - ); -}; - -export default References; diff --git a/src/features/portal/index.tsx b/src/features/portal/index.tsx index f62ff3d..ec2a619 100644 --- a/src/features/portal/index.tsx +++ b/src/features/portal/index.tsx @@ -8,7 +8,7 @@ import Logo from "components/Logo"; import Logout from "./Logout"; import Progress from "./Progress"; import React from "react"; -import References from "./References"; +import RecommendationChapter from "./RecommendationChapter"; import StyledPlate from "components/Plate"; import SurveyChapter from "./SurveyChapter"; @@ -21,7 +21,7 @@ const Portal = (): React.ReactElement => {
- +
diff --git a/src/features/recommendations/Reference.tsx b/src/features/recommendations/Reference.tsx new file mode 100644 index 0000000..d2f0559 --- /dev/null +++ b/src/features/recommendations/Reference.tsx @@ -0,0 +1,49 @@ +import React, { useState } from "react"; + +import ContactPerson from "components/ContactPerson"; +import TranslatedUploadLink from "./TranslatedUploadLink"; +import hasApplicationClosed from "utils/hasApplicationClosed"; +import { toast } from "react-toastify"; +import { useRecommendations } from "./recommendationHooks"; + +interface ReferenceProps { + index: number; +} + +const Reference = ({ index }: ReferenceProps): React.ReactElement => { + const [loading, setLoading] = useState(false); + const { + data: recommendation, + loading: loadingReference, + addReference, + } = useRecommendations(index); + + const closed = hasApplicationClosed(); + + function handleSubmit(email: string) { + setLoading(true); + addReference({ index, email }) + .then((res) => { + setLoading(false); + if (res.code) + toast(, { + position: "bottom-center", + autoClose: false, + }); + }) + .catch(console.error); + } + return ( + + ); +}; + +export default Reference; diff --git a/src/features/recommendations/TranslatedUploadLink.tsx b/src/features/recommendations/TranslatedUploadLink.tsx new file mode 100644 index 0000000..a752460 --- /dev/null +++ b/src/features/recommendations/TranslatedUploadLink.tsx @@ -0,0 +1,18 @@ +import { Trans, withTranslation } from "react-i18next"; + +import React from "react"; + +const UploadLink = ({ code }: { code: string }) => ( + + + +); + +const TranslatedUploadLink = withTranslation()(UploadLink); + +export default TranslatedUploadLink; diff --git a/src/features/recommendations/recommendationHooks.ts b/src/features/recommendations/recommendationHooks.ts index ed46605..c684bf2 100644 --- a/src/features/recommendations/recommendationHooks.ts +++ b/src/features/recommendations/recommendationHooks.ts @@ -1,3 +1,7 @@ +import { + NewRecommendationRequest, + RecommendationRequest, +} from "types/recommendations"; import { addPersonSuccess, selectApplicantRecommendations, @@ -5,24 +9,44 @@ import { import useApi, { UseApi } from "hooks/useApi"; import { useDispatch, useSelector } from "react-redux"; -import { RecommendationRequest } from "types/recommendations"; +import { requestRecommendation } from "api/recommendations"; +import { useCallback } from "react"; + +interface UseRecommendations extends UseApi { + addReference: ( + request: NewRecommendationRequest + ) => Promise; +} export const useRecommendations = ( + index = -1, applicantID = "@me" -): UseApi => { +): UseRecommendations => { const dispatch = useDispatch(); const [{ loading, data, error }] = useApi({ url: `/application/${applicantID}/recommendation`, }); const applicantRecommendations = useSelector( - selectApplicantRecommendations(applicantID) + selectApplicantRecommendations( + applicantID === "@me" ? undefined : applicantID + ) ); if (data && !applicantRecommendations?.length) { dispatch(addPersonSuccess(data)); } + const addReference = useCallback( + ({ index, email }: NewRecommendationRequest) => { + return requestRecommendation(index, email).then((res) => { + dispatch(addPersonSuccess([res])); + return res; + }); + }, + [dispatch] + ); return { loading, - data: applicantRecommendations || [], + data: applicantRecommendations?.[index], error, + addReference, }; }; diff --git a/src/features/survey/index.tsx b/src/features/survey/index.tsx index af34995..ecb22d1 100644 --- a/src/features/survey/index.tsx +++ b/src/features/survey/index.tsx @@ -1,20 +1,13 @@ import React from "react"; import Survey from "components/Survey"; -import moment from "moment"; +import hasApplicationClosed from "utils/hasApplicationClosed"; import { useSurvey } from "./surveyHooks"; const PortalSurvey = (): React.ReactElement => { const { data, loading, updateSurvey } = useSurvey(); if (loading) return
; - const applicationHasClosed = - moment.utc().month(2).endOf("month").diff(Date.now()) < 0; - return ( - - ); + const closed = hasApplicationClosed(); + return ; }; export default PortalSurvey; diff --git a/src/types/recommendations.ts b/src/types/recommendations.ts index b456e85..27dd3d3 100644 --- a/src/types/recommendations.ts +++ b/src/types/recommendations.ts @@ -1,11 +1,14 @@ -export type RecommendationRequest = { +export interface RecommendationRequest extends NewRecommendationRequest { applicantId: string; - email: string; lastSent: string; received: null | string; - index: number; id: string; code?: string; +} + +export type NewRecommendationRequest = { + email: string; + index: number; }; export interface RecommendationFile extends RecommendationRequest { diff --git a/src/utils/hasApplicationClosed.ts b/src/utils/hasApplicationClosed.ts new file mode 100644 index 0000000..c837c38 --- /dev/null +++ b/src/utils/hasApplicationClosed.ts @@ -0,0 +1,6 @@ +import moment from "moment"; + +const hasApplicationClosed = (): boolean => + moment.utc().month(2).endOf("month").diff(Date.now()) < 0; + +export default hasApplicationClosed; From 58f476f5c3b91d300916564b26be537335940a5f Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 5 Apr 2021 14:18:32 +0200 Subject: [PATCH 24/98] Improved redux state Clearing files and recommendation information on logout --- src/features/admin/adminHooks.ts | 29 ++++++-- src/features/auth/AuthenticatedLayer.tsx | 7 +- src/features/files/Upload.tsx | 33 +++------ src/features/files/filesHooks.ts | 72 +++++++++++++++++-- src/features/files/filesSlice.ts | 18 +++-- .../recommendations/recommendationsSlice.ts | 8 ++- src/utils/tokenInterceptor.ts | 4 ++ 7 files changed, 120 insertions(+), 51 deletions(-) diff --git a/src/features/admin/adminHooks.ts b/src/features/admin/adminHooks.ts index 6d07bb8..7412a5d 100644 --- a/src/features/admin/adminHooks.ts +++ b/src/features/admin/adminHooks.ts @@ -1,12 +1,17 @@ import { Admin, NewAdmin } from "types/user"; -import { IndividualGrading, IndividualGradingWithName } from "types/grade"; +import { + ApplicationGrade, + IndividualGrading, + IndividualGradingWithName, +} from "types/grade"; import { Statistics, SurveyAnswers } from "types/survey"; -import { addAdmin, getGradesConfig } from "api/admin"; +import { addAdmin, getGradesConfig, postApplicationGrade } from "api/admin"; import { selectAdmins, selectGradesByApplicant, setAdmins, setGrades, + setMyGrade, } from "./adminSlice"; import useApi, { UseApi } from "hooks/useApi"; import { useCallback, useEffect } from "react"; @@ -14,21 +19,33 @@ import { useDispatch, useSelector } from "react-redux"; import average from "utils/average"; -export function useGrades( +type UseGrades = ( applicantId: string -): UseApi { +) => UseApi & { + addMyGrade: (grades: ApplicationGrade) => Promise; +}; + +export const useGrades: UseGrades = (applicantId: string) => { useAdmins(); const [{ loading, data, error }] = useApi( getGradesConfig(applicantId) ); const dispatch = useDispatch(); const grades = useSelector(selectGradesByApplicant(applicantId)); + const addMyGrade = useCallback( + (grades) => + postApplicationGrade(applicantId, grades).then((grading) => { + setMyGrade(grading); + }), + [applicantId] + ); + useEffect(() => { if (data && Boolean(grades) === false) dispatch(setGrades({ grades: data, applicantId })); }, [data]); - return { loading, data: grades, error }; -} + return { loading, data: grades, error, addMyGrade }; +}; interface UseAdmins extends UseApi { addAdmin: (admin: NewAdmin) => Promise; diff --git a/src/features/auth/AuthenticatedLayer.tsx b/src/features/auth/AuthenticatedLayer.tsx index 558eb74..73e7327 100644 --- a/src/features/auth/AuthenticatedLayer.tsx +++ b/src/features/auth/AuthenticatedLayer.tsx @@ -1,9 +1,5 @@ import React, { useEffect } from "react"; -import { - authSuccess, - selectAuthenticated, - userInfoSuccess, -} from "features/auth/authSlice"; +import { selectAuthenticated, userInfoSuccess } from "features/auth/authSlice"; import { getUser } from "api/user"; import { useDispatch } from "react-redux"; @@ -21,7 +17,6 @@ export default function AuthenticatedLayer( useEffect(() => { getUser() .then((res) => { - dispatch(authSuccess()); dispatch(userInfoSuccess(res)); }) .catch(console.error); diff --git a/src/features/files/Upload.tsx b/src/features/files/Upload.tsx index 901368b..46f6c0c 100644 --- a/src/features/files/Upload.tsx +++ b/src/features/files/Upload.tsx @@ -1,17 +1,11 @@ import React, { useState } from "react"; -import { deleteFile, downloadFile, uploadFile } from "api/files"; -import { - deleteFileSuccess, - replaceFile, - selectFilesByFileTypeAndApplicant, - setFiles, -} from "./filesSlice"; -import { useDispatch, useSelector } from "react-redux"; import { FileType } from "types/files"; import Upload from "components/portal/Upload"; +import { downloadFile } from "api/files"; import hasApplicationClosed from "utils/hasApplicationClosed"; import { toast } from "react-toastify"; +import { useFiles } from "./filesHooks"; import { useTranslation } from "react-i18next"; interface UploadHookProps { @@ -34,26 +28,19 @@ const UploadHook: React.FC = ({ disabled, accept, fileType, - applicantID, maxFileSize = 5 * 10 ** 6, multiple = 1, alwaysAbleToUpload, + applicantID, }) => { const [uploadingFiles, setUploadingFiles] = useState([]); - const dispatch = useDispatch(); - const files = useSelector( - selectFilesByFileTypeAndApplicant(fileType, applicantID) - ); + const { removeFile, data: files, addFile } = useFiles(applicantID, fileType); const { t } = useTranslation(); const handleDelete = (fileID: string, applicantID: string) => - deleteFile(fileID, applicantID) - .then(() => { - dispatch(deleteFileSuccess([applicantID, fileType, fileID])); - }) - .catch((err) => { - toast.error(err.message); - }); + removeFile(fileID, applicantID).catch((err) => { + toast.error(err.message); + }); const handleUpload = (file: File, fileName: string) => { if (file.size > maxFileSize) { @@ -66,10 +53,8 @@ const UploadHook: React.FC = ({ ]); } else { setUploadingFiles([{ name: file.name, uploading: true }]); - uploadFile(fileType, file, fileName) - .then((res) => { - if (multiple > 1) dispatch(replaceFile(res)); - else dispatch(setFiles([res])); + addFile(fileType, file, fileName) + .then(() => { setUploadingFiles([]); }) .catch((err) => { diff --git a/src/features/files/filesHooks.ts b/src/features/files/filesHooks.ts index ed028e0..ea8db65 100644 --- a/src/features/files/filesHooks.ts +++ b/src/features/files/filesHooks.ts @@ -1,28 +1,86 @@ -import { selectApplicantFilesLoaded, setFiles } from "./filesSlice"; +import { FileInfo, FileType } from "types/files"; +import { deleteFile, uploadFile } from "api/files"; +import { + deleteFileSuccess, + replaceFile, + selectApplicantFilesLoaded, + selectFilesByFileTypeAndApplicant, + setFiles, +} from "./filesSlice"; import useApi, { UseApi } from "hooks/useApi"; import { useDispatch, useSelector } from "react-redux"; -import { FileInfo } from "types/files"; import { RecommendationFile } from "types/recommendations"; import { getRecommendationRequestConfig } from "api/recommendations"; +import { useCallback } from "react"; -type UseFiles = { - loading: boolean; +type UseFiles = ( + applicantID?: string, + type?: FileType +) => UseApi & { + removeFile: (fileID: string, applicantID?: string) => Promise; + addFile: ( + fileType: FileType, + file: File, + fileName: string, + replace?: boolean + ) => Promise; }; -export function useFiles(applicantID = "@me"): UseFiles { +export const useFiles: UseFiles = (applicantID = "@me", type?: FileType) => { const dispatch = useDispatch(); - const filesLoaded = useSelector(selectApplicantFilesLoaded(applicantID)); + const id = applicantID === "@me" ? undefined : applicantID; + const filesLoaded = useSelector(selectApplicantFilesLoaded(id)); + const files = + type === undefined + ? undefined + : useSelector(selectFilesByFileTypeAndApplicant(type, id)); + // console.log(type, id, files); const [{ loading, data }] = useApi({ url: `/application/${applicantID}/file`, }); if (filesLoaded === false && data) { dispatch(setFiles(data)); } + + const removeFile = useCallback( + (fileID, applicantID) => + deleteFile(fileID, applicantID).then(() => { + type && dispatch(deleteFileSuccess([applicantID, type, fileID])); + }), + [dispatch, type] + ); + + const addFile = useCallback( + (fileType, file, fileName, replace) => + uploadFile(fileType, file, fileName).then((res) => { + if (replace > 1) dispatch(replaceFile(res)); + else dispatch(setFiles([res])); + }), + [dispatch, type] + ); + return { loading, + data: files, + removeFile, + addFile, }; -} +}; + +// type UseFile= (fileType: FileType, applicantID?: string) => { +// data?: FileInfo[]; +// } + +// export const useFilesByType(fileType, applicantID = "@me") { +// const dispatch = useDispatch(); +// const files = useSelector( +// selectFilesByFileTypeAndApplicant(fileType, applicantID) +// ); +// return { +// data: files +// } +// } export function useRecommendationLetter( code: string diff --git a/src/features/files/filesSlice.ts b/src/features/files/filesSlice.ts index b848cc7..bfd873c 100644 --- a/src/features/files/filesSlice.ts +++ b/src/features/files/filesSlice.ts @@ -50,9 +50,20 @@ const filesSlice = createSlice({ const files = state.fileTypesByApplicants[applicantID][fileType]; files?.filter((file) => file.id !== fileID); }, + clearFiles(state) { + Object.assign(state, initialState); + }, }, }); +export const { + setFiles, + uploadSuccess, + deleteFileSuccess, + replaceFile, + clearFiles, +} = filesSlice.actions; + export const selectApplicantFilesLoaded = (applicantID?: string) => ( state: RootState ): boolean => { @@ -73,11 +84,4 @@ export const selectFilesByFileTypeAndApplicant = ( return []; }; -export const { - setFiles, - uploadSuccess, - deleteFileSuccess, - replaceFile, -} = filesSlice.actions; - export default filesSlice.reducer; diff --git a/src/features/recommendations/recommendationsSlice.ts b/src/features/recommendations/recommendationsSlice.ts index 4347015..634027d 100644 --- a/src/features/recommendations/recommendationsSlice.ts +++ b/src/features/recommendations/recommendationsSlice.ts @@ -25,10 +25,16 @@ const recommendationsSlice = createSlice({ } }); }, + clearRecommendations(state) { + Object.assign(state, initialState); + }, }, }); -export const { addPersonSuccess } = recommendationsSlice.actions; +export const { + addPersonSuccess, + clearRecommendations, +} = recommendationsSlice.actions; export const selectApplicantRecommendations = (applicantID?: string) => ( state: RootState diff --git a/src/utils/tokenInterceptor.ts b/src/utils/tokenInterceptor.ts index b937833..6cdf16c 100644 --- a/src/utils/tokenInterceptor.ts +++ b/src/utils/tokenInterceptor.ts @@ -2,6 +2,8 @@ import { authFail, authSuccess } from "features/auth/authSlice"; import { ServerTokenResponse } from "types/tokens"; import axios from "api/axios"; +import { clearFiles } from "features/files/filesSlice"; +import { clearRecommendations } from "features/recommendations/recommendationsSlice"; import { clearSurvey } from "features/survey/surveySlice"; import i18n from "i18n"; import store from "store"; @@ -100,6 +102,8 @@ export class TokenStorage { localStorage.removeItem(TokenStorage.LOCAL_STORAGE_TOKEN_EXPIRY); store.dispatch(authFail()); store.dispatch(clearSurvey()); + store.dispatch(clearFiles()); + store.dispatch(clearRecommendations()); } private static getRefreshToken(): string | null { From 9674335e069ef1c5bbcfbc2c6d9282ed7dd4fc8c Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 5 Apr 2021 14:36:15 +0200 Subject: [PATCH 25/98] Added progress bars to two places --- src/features/portal/Introduction.tsx | 1 - src/features/portal/index.tsx | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/features/portal/Introduction.tsx b/src/features/portal/Introduction.tsx index 98958ce..e4ba577 100644 --- a/src/features/portal/Introduction.tsx +++ b/src/features/portal/Introduction.tsx @@ -7,7 +7,6 @@ const Introduction = ({ t }: WithTranslation) => (

{t("title")}

-
); export default withTranslation()(Introduction); diff --git a/src/features/portal/index.tsx b/src/features/portal/index.tsx index ec2a619..e025ab6 100644 --- a/src/features/portal/index.tsx +++ b/src/features/portal/index.tsx @@ -18,6 +18,8 @@ const Portal = (): React.ReactElement => { + +
From 3b1b23de3e86293cd0ef9fe5efe570ee8558d48c Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 5 Apr 2021 14:39:41 +0200 Subject: [PATCH 26/98] Changed name of FileChapters export --- src/features/portal/FileChapters.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/portal/FileChapters.tsx b/src/features/portal/FileChapters.tsx index 4e0899b..74e829a 100644 --- a/src/features/portal/FileChapters.tsx +++ b/src/features/portal/FileChapters.tsx @@ -7,7 +7,7 @@ import Upload from "features/files/Upload"; import portal from "config/portal.json"; import { useFiles } from "features/files/filesHooks"; -const Chapters = ({ t }: WithTranslation): React.ReactElement => { +const FileChapters = ({ t }: WithTranslation): React.ReactElement => { const { loading } = useFiles(); return ( <> @@ -31,4 +31,4 @@ const Chapters = ({ t }: WithTranslation): React.ReactElement => { ); }; -export default withTranslation()(Chapters); +export default withTranslation()(FileChapters); From cebfb45530713c53ddf2bc4c0912ba9aedbcee79 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 5 Apr 2021 14:43:31 +0200 Subject: [PATCH 27/98] Changed FileChapters into TranslatedChapters to improve resuability --- src/features/portal/FileChapters.tsx | 41 ++++++++++------------------ 1 file changed, 15 insertions(+), 26 deletions(-) diff --git a/src/features/portal/FileChapters.tsx b/src/features/portal/FileChapters.tsx index 74e829a..8e2f459 100644 --- a/src/features/portal/FileChapters.tsx +++ b/src/features/portal/FileChapters.tsx @@ -1,34 +1,23 @@ -import { WithTranslation, withTranslation } from "react-i18next"; - -import Chapter from "components/Chapter"; import { FileType } from "types/files"; import React from "react"; +import TranslatedChapter from "./TranslatedChapter"; import Upload from "features/files/Upload"; import portal from "config/portal.json"; import { useFiles } from "features/files/filesHooks"; -const FileChapters = ({ t }: WithTranslation): React.ReactElement => { +const FileChapters = (): React.ReactElement[] => { const { loading } = useFiles(); - return ( - <> - {portal.chapters - .filter((chapter) => chapter.upload !== undefined) - .map((chapter) => ( - - - - ))} - - ); + return portal.chapters + .filter((chapter) => chapter.upload !== undefined) + .map((chapter) => ( + + + + )); }; -export default withTranslation()(FileChapters); +export default FileChapters; From e4095410647b2783230035f203bd84e84eeb3612 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 5 Apr 2021 14:47:51 +0200 Subject: [PATCH 28/98] Moved disabled loading logic from FileChapters to Upload feature --- src/features/files/Upload.tsx | 8 +++++--- src/features/portal/FileChapters.tsx | 8 ++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/features/files/Upload.tsx b/src/features/files/Upload.tsx index 46f6c0c..e49b4be 100644 --- a/src/features/files/Upload.tsx +++ b/src/features/files/Upload.tsx @@ -25,7 +25,6 @@ interface UploadingFileInfo { } const UploadHook: React.FC = ({ - disabled, accept, fileType, maxFileSize = 5 * 10 ** 6, @@ -34,7 +33,10 @@ const UploadHook: React.FC = ({ applicantID, }) => { const [uploadingFiles, setUploadingFiles] = useState([]); - const { removeFile, data: files, addFile } = useFiles(applicantID, fileType); + const { removeFile, data: files, addFile, loading } = useFiles( + applicantID, + fileType + ); const { t } = useTranslation(); const handleDelete = (fileID: string, applicantID: string) => @@ -68,7 +70,7 @@ const UploadHook: React.FC = ({ const handleCancel = () => setUploadingFiles([]); const closed = hasApplicationClosed(); - const disabledUploading = (closed && !alwaysAbleToUpload) || disabled; + const disabledUploading = (closed && !alwaysAbleToUpload) || loading; const label = t(`${fileType}.upload.label`); return ( diff --git a/src/features/portal/FileChapters.tsx b/src/features/portal/FileChapters.tsx index 8e2f459..8dcddc3 100644 --- a/src/features/portal/FileChapters.tsx +++ b/src/features/portal/FileChapters.tsx @@ -3,21 +3,17 @@ import React from "react"; import TranslatedChapter from "./TranslatedChapter"; import Upload from "features/files/Upload"; import portal from "config/portal.json"; -import { useFiles } from "features/files/filesHooks"; -const FileChapters = (): React.ReactElement[] => { - const { loading } = useFiles(); - return portal.chapters +const FileChapters = (): React.ReactElement[] => + portal.chapters .filter((chapter) => chapter.upload !== undefined) .map((chapter) => ( )); -}; export default FileChapters; From b2a2ebd3ba22af92677ea2dbffc1cab5b1e5fc32 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 5 Apr 2021 14:56:03 +0200 Subject: [PATCH 29/98] Extracted downloadFile from Upload --- src/features/files/Upload.tsx | 16 +++++++--------- src/features/files/filesHooks.ts | 31 ++++++++++++++----------------- 2 files changed, 21 insertions(+), 26 deletions(-) diff --git a/src/features/files/Upload.tsx b/src/features/files/Upload.tsx index e49b4be..1b4c207 100644 --- a/src/features/files/Upload.tsx +++ b/src/features/files/Upload.tsx @@ -2,7 +2,6 @@ import React, { useState } from "react"; import { FileType } from "types/files"; import Upload from "components/portal/Upload"; -import { downloadFile } from "api/files"; import hasApplicationClosed from "utils/hasApplicationClosed"; import { toast } from "react-toastify"; import { useFiles } from "./filesHooks"; @@ -11,7 +10,6 @@ import { useTranslation } from "react-i18next"; interface UploadHookProps { accept?: string; fileType: FileType; - disabled?: boolean; applicantID?: string; multiple?: number; maxFileSize?: number; @@ -32,15 +30,15 @@ const UploadHook: React.FC = ({ alwaysAbleToUpload, applicantID, }) => { - const [uploadingFiles, setUploadingFiles] = useState([]); - const { removeFile, data: files, addFile, loading } = useFiles( + const { t } = useTranslation(); + const { removeFile, data: files, addFile, loading, downloadFile } = useFiles( applicantID, fileType ); - const { t } = useTranslation(); + const [uploadingFiles, setUploadingFiles] = useState([]); - const handleDelete = (fileID: string, applicantID: string) => - removeFile(fileID, applicantID).catch((err) => { + const handleDelete = (fileID: string) => + removeFile(fileID).catch((err) => { toast.error(err.message); }); @@ -83,8 +81,8 @@ const UploadHook: React.FC = ({ accept={accept} disabled={multiple > 1 || disabledUploading} uploadLabel={t("Choose file")} - onDownload={() => downloadFile(file.id, file.userId)} - onDelete={() => handleDelete(file.id, file.userId)} + onDownload={() => downloadFile(file.id)} + onDelete={() => handleDelete(file.id)} onChange={handleUpload} /> ))} diff --git a/src/features/files/filesHooks.ts b/src/features/files/filesHooks.ts index ea8db65..18ebaed 100644 --- a/src/features/files/filesHooks.ts +++ b/src/features/files/filesHooks.ts @@ -1,5 +1,9 @@ import { FileInfo, FileType } from "types/files"; -import { deleteFile, uploadFile } from "api/files"; +import { + deleteFile, + downloadFile as downloadIndividualFile, + uploadFile, +} from "api/files"; import { deleteFileSuccess, replaceFile, @@ -18,13 +22,14 @@ type UseFiles = ( applicantID?: string, type?: FileType ) => UseApi & { - removeFile: (fileID: string, applicantID?: string) => Promise; + removeFile: (fileID: string) => Promise; addFile: ( fileType: FileType, file: File, fileName: string, replace?: boolean ) => Promise; + downloadFile: (fileID: string) => Promise; }; export const useFiles: UseFiles = (applicantID = "@me", type?: FileType) => { @@ -44,7 +49,7 @@ export const useFiles: UseFiles = (applicantID = "@me", type?: FileType) => { } const removeFile = useCallback( - (fileID, applicantID) => + (fileID) => deleteFile(fileID, applicantID).then(() => { type && dispatch(deleteFileSuccess([applicantID, type, fileID])); }), @@ -60,28 +65,20 @@ export const useFiles: UseFiles = (applicantID = "@me", type?: FileType) => { [dispatch, type] ); + const downloadFile = useCallback( + (fileID: string) => downloadIndividualFile(fileID, applicantID), + [applicantID] + ); + return { loading, data: files, removeFile, addFile, + downloadFile, }; }; -// type UseFile= (fileType: FileType, applicantID?: string) => { -// data?: FileInfo[]; -// } - -// export const useFilesByType(fileType, applicantID = "@me") { -// const dispatch = useDispatch(); -// const files = useSelector( -// selectFilesByFileTypeAndApplicant(fileType, applicantID) -// ); -// return { -// data: files -// } -// } - export function useRecommendationLetter( code: string ): UseApi { From 87128ce308830ed478d03f521e4f14cf3538f214 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 5 Apr 2021 15:03:53 +0200 Subject: [PATCH 30/98] Add comments to useFiles --- src/api/files.ts | 5 ++++- src/features/files/filesHooks.ts | 25 ++++++++++++++++++------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/api/files.ts b/src/api/files.ts index 8d0ced5..81e8a20 100644 --- a/src/api/files.ts +++ b/src/api/files.ts @@ -3,8 +3,11 @@ import { FileInfo, FileType } from "types/files"; import FileSaver from "file-saver"; import api from "./axios"; +export const getFilesConfiguration = (applicantID = "@me"): string => + `/application/${applicantID}/file`; + export const getFiles = (applicantID = "@me"): Promise => - api.format.get(`/application/${applicantID}/file`); + api.format.get(getFilesConfiguration(applicantID)); export const downloadFile = ( fileID: string, diff --git a/src/features/files/filesHooks.ts b/src/features/files/filesHooks.ts index 18ebaed..4f938fe 100644 --- a/src/features/files/filesHooks.ts +++ b/src/features/files/filesHooks.ts @@ -2,6 +2,7 @@ import { FileInfo, FileType } from "types/files"; import { deleteFile, downloadFile as downloadIndividualFile, + getFilesConfiguration, uploadFile, } from "api/files"; import { @@ -34,20 +35,28 @@ type UseFiles = ( export const useFiles: UseFiles = (applicantID = "@me", type?: FileType) => { const dispatch = useDispatch(); + // if applicantID is @me then we don't want to call redux with + // any function as it will automatically replace with the user id const id = applicantID === "@me" ? undefined : applicantID; - const filesLoaded = useSelector(selectApplicantFilesLoaded(id)); + + // if the function is not called with any file type, there are no files to get const files = type === undefined ? undefined : useSelector(selectFilesByFileTypeAndApplicant(type, id)); - // console.log(type, id, files); - const [{ loading, data }] = useApi({ - url: `/application/${applicantID}/file`, - }); + + // use an API hook to load in the data with a configured get request. + const [{ loading, data }] = useApi( + getFilesConfiguration(applicantID) + ); + + // if files are already loaded in redux we don't want to dispatch them again + const filesLoaded = useSelector(selectApplicantFilesLoaded(id)); if (filesLoaded === false && data) { dispatch(setFiles(data)); } + // callback to remove a file and delete it from the store const removeFile = useCallback( (fileID) => deleteFile(fileID, applicantID).then(() => { @@ -56,10 +65,12 @@ export const useFiles: UseFiles = (applicantID = "@me", type?: FileType) => { [dispatch, type] ); + // callback to upload a file and add it to the store const addFile = useCallback( - (fileType, file, fileName, replace) => + (fileType, file, fileName, replace?: boolean) => uploadFile(fileType, file, fileName).then((res) => { - if (replace > 1) dispatch(replaceFile(res)); + // replace if nece + if (replace) dispatch(replaceFile(res)); else dispatch(setFiles([res])); }), [dispatch, type] From ab29bd0df8fce42dc79d98cc3281dc9e02cb0e87 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 5 Apr 2021 15:16:01 +0200 Subject: [PATCH 31/98] Added comments to filesSlice --- src/features/files/filesSlice.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/features/files/filesSlice.ts b/src/features/files/filesSlice.ts index bfd873c..b09bd5a 100644 --- a/src/features/files/filesSlice.ts +++ b/src/features/files/filesSlice.ts @@ -3,8 +3,10 @@ import { PayloadAction, createSlice } from "@reduxjs/toolkit"; import { RootState } from "store"; +// Files are organized by their FileType, i.e. CV: [file1, file2, etc] type FilesByType = Partial>; +// File types are organized by the application IDs, i.e. [user1_ID]: {CV: files} interface FilesState { fileTypesByApplicants: Record; } @@ -18,28 +20,38 @@ const filesSlice = createSlice({ initialState, reducers: { setFiles(state, action: PayloadAction) { + // loop through each file action.payload.forEach((file) => { + // if no files have been added to user, create an empty object if (state.fileTypesByApplicants[file.userId] === undefined) { state.fileTypesByApplicants[file.userId] = {}; } + // add the file to the store if ( + // if there are already uploaded files for this FileType state.fileTypesByApplicants[file.userId][file.type] && + // and if the file is NOT already in there state.fileTypesByApplicants[file.userId][file.type]?.findIndex( (f) => f.id === file.id ) === -1 ) { + // add the file to the array state.fileTypesByApplicants[file.userId][file.type]?.push(file); - } else state.fileTypesByApplicants[file.userId][file.type] = [file]; + } // otherwise create a new array for the file + else state.fileTypesByApplicants[file.userId][file.type] = [file]; }); }, replaceFile(state, action: PayloadAction) { const file = action.payload; + // replace the array for the files - assuming only one file should exist state.fileTypesByApplicants[file.userId][file.type] = [file]; }, uploadSuccess(state, action: PayloadAction) { const file = action.payload; const files = state.fileTypesByApplicants[file.userId][file.type]; + // if there are files, add it to the array if (files) files.push(file); + // otherwise create a new array else state.fileTypesByApplicants[file.userId][file.type] = [file]; }, deleteFileSuccess( @@ -67,8 +79,11 @@ export const { export const selectApplicantFilesLoaded = (applicantID?: string) => ( state: RootState ): boolean => { + // if there is no id defined, use the userID const id = applicantID || state.auth.user?.id; + // if there is no id then there are no files if (!id) return false; + // check if there are files const fileTypesByApplicants = state.files.fileTypesByApplicants[id]; return Boolean(fileTypesByApplicants); }; @@ -78,9 +93,12 @@ export const selectFilesByFileTypeAndApplicant = ( applicantID?: string ) => (state: RootState): FileInfo[] => { const id = applicantID || state.auth.user?.id; + // no id -> no files if (!id) return []; const fileTypes = state.files.fileTypesByApplicants[id]; + // if there are files, get the relevant files by the type. undefined -> no files if (fileTypes) return fileTypes[type] || []; + // catch all return []; }; From 8220ea2361e48abb80e384bc77c4a9ea015b9640 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 5 Apr 2021 15:19:31 +0200 Subject: [PATCH 32/98] Added comments to api/files --- src/api/files.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/api/files.ts b/src/api/files.ts index 81e8a20..2df9c89 100644 --- a/src/api/files.ts +++ b/src/api/files.ts @@ -46,7 +46,8 @@ export const downloadFullPDF = (applicantID = "@me"): Promise => export const deleteFile = ( fileID: string, applicantID = "@me" -): Promise => api.delete(`/application/${applicantID}/file/${fileID}`); +): Promise => + api.format.delete(`/application/${applicantID}/file/${fileID}`); export const uploadFile = ( fileType: FileType, @@ -54,11 +55,15 @@ export const uploadFile = ( fileName: string, applicantID = "@me" ): Promise => { + // create FormData to append file with desired file name const form = new FormData(); form.append("file", file, fileName); - return api - .post(`application/${applicantID}/file/${fileType}`, form, { + // format the results, useful if there are errors! + return api.format.post( + `application/${applicantID}/file/${fileType}`, + form, + { headers: { "Content-Type": "multipart/form-data" }, - }) - .then((res) => res.data); + } + ); }; From 8e4353f542938238b47a5d0a9e9bb670a93506e1 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 5 Apr 2021 15:25:21 +0200 Subject: [PATCH 33/98] Bug fix to FileChapters --- src/features/portal/FileChapters.tsx | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/features/portal/FileChapters.tsx b/src/features/portal/FileChapters.tsx index 8dcddc3..012292e 100644 --- a/src/features/portal/FileChapters.tsx +++ b/src/features/portal/FileChapters.tsx @@ -4,16 +4,19 @@ import TranslatedChapter from "./TranslatedChapter"; import Upload from "features/files/Upload"; import portal from "config/portal.json"; -const FileChapters = (): React.ReactElement[] => - portal.chapters - .filter((chapter) => chapter.upload !== undefined) - .map((chapter) => ( - - - - )); +const FileChapters = (): React.ReactElement => ( + <> + {portal.chapters + .filter((chapter) => chapter.upload !== undefined) + .map((chapter) => ( + + + + ))} + +); export default FileChapters; From 1a439c332878700ab4a1b26b2b19272ed2a26de4 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 5 Apr 2021 15:35:54 +0200 Subject: [PATCH 34/98] Commented Upload --- src/components/Chapter/index.stories.jsx | 38 +++++----- src/components/StyledInputGroup.tsx | 53 ++++++++++++++ src/components/portal/Upload/index.tsx | 93 ++++++++++-------------- 3 files changed, 108 insertions(+), 76 deletions(-) create mode 100644 src/components/StyledInputGroup.tsx diff --git a/src/components/Chapter/index.stories.jsx b/src/components/Chapter/index.stories.jsx index fb3aff4..838e53a 100644 --- a/src/components/Chapter/index.stories.jsx +++ b/src/components/Chapter/index.stories.jsx @@ -1,17 +1,18 @@ -import React from 'react'; -import { UploadHook } from 'components/portal/Upload/index.stories'; -import Plate from 'components/Plate'; -import Chapter from './index'; +import Chapter from "./index"; +import Plate from "components/Plate"; +import React from "react"; +import { UploadHook } from "components/portal/Upload/index.stories"; export default { - title: 'Chapter', - decorators: [(Story) => ( -
- - - -
- ), + title: "Chapter", + decorators: [ + (Story) => ( +
+ + + +
+ ), ], }; @@ -20,15 +21,10 @@ export const withText = () => ( title="Personligt brev" subtitle="Max 600 ord" description={ - 'Vi som arrangerar Rays vill lära känna dig som ansöker så bra som möjligt.' - + 'I ditt personliga brev vill vi därför att du kortfattat berättar om dina intressen och varför du söker till Rays.' - + 'För oss är det intressant att höra varifrån din passion för naturvetenskap kommer och hur dina tidigare erfarenheter har påverkat dig. ' + "Vi som arrangerar Rays vill lära känna dig som ansöker så bra som möjligt." + + "I ditt personliga brev vill vi därför att du kortfattat berättar om dina intressen och varför du söker till Rays." + + "För oss är det intressant att höra varifrån din passion för naturvetenskap kommer och hur dina tidigare erfarenheter har påverkat dig. " } - upload={( - - )} + upload={} /> ); diff --git a/src/components/StyledInputGroup.tsx b/src/components/StyledInputGroup.tsx new file mode 100644 index 0000000..360cb44 --- /dev/null +++ b/src/components/StyledInputGroup.tsx @@ -0,0 +1,53 @@ +import InputGroup from "react-bootstrap/InputGroup"; +import styled from "styled-components"; + +const StyledInputGroup = styled(InputGroup)` + &.uploaded span, + &.uploaded .form-control { + color: #155724; + background-color: #d4edda; + border-color: #28a745; + } + + &.uploaded .form-control, + &.error .form-control { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + + &.uploaded span::after, + &.uploaded .input-group-append .dropdown-toggle { + color: #fff; + background-color: #42c15f; + border-color: #28a745; + } + + &.uploaded .input-group-append .dropdown-toggle { + background-color: rgba(40, 167, 69, 1); + } + + &.uploaded span { + border-color: rgb(40, 167, 69); + } + + &.error span, + &.error .form-control { + color: #bd2130; + background-color: #f8d7da; + border-color: #bd2130; + } + + &.error span::after, + &.error .input-group-append .dropdown-toggle { + color: #fff; + background-color: #e23d4d; + border-color: #bd2130; + } + + &.error .input-group-append .dropdown-toggle { + background-color: rgba(200, 35, 51, 1); + } +`; + +export default StyledInputGroup; diff --git a/src/components/portal/Upload/index.tsx b/src/components/portal/Upload/index.tsx index 56b491c..cfd1786 100644 --- a/src/components/portal/Upload/index.tsx +++ b/src/components/portal/Upload/index.tsx @@ -6,63 +6,15 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import FormControl from "react-bootstrap/FormControl"; import InputGroup from "react-bootstrap/InputGroup"; import Spinner from "react-bootstrap/Spinner"; +import StyledInputGroup from "components/StyledInputGroup"; import styled from "styled-components"; import { useTranslation } from "react-i18next"; -const StyledInputGroup = styled(InputGroup)` - &.uploaded span, - &.uploaded .form-control { - color: #155724; - background-color: #d4edda; - border-color: #28a745; - } - - &.uploaded .form-control, - &.error .form-control { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - } - - &.uploaded span::after, - &.uploaded .input-group-append .dropdown-toggle { - color: #fff; - background-color: #42c15f; - border-color: #28a745; - } - - &.uploaded .input-group-append .dropdown-toggle { - background-color: rgba(40, 167, 69, 1); - } - - &.uploaded span { - border-color: rgb(40, 167, 69); - } - - &.error span, - &.error .form-control { - color: #bd2130; - background-color: #f8d7da; - border-color: #bd2130; - } - - &.error span::after, - &.error .input-group-append .dropdown-toggle { - color: #fff; - background-color: #e23d4d; - border-color: #bd2130; - } - - &.error .input-group-append .dropdown-toggle { - background-color: rgba(200, 35, 51, 1); - } -`; - -interface StyledFormControlProps { +interface TranslatedFormControlProps { label?: string; } -const StyledFormControl = styled(FormControl)` +const TranslatedFormControl = styled(FormControl)` ${(props) => props ? `& ~ .custom-file-label::after {content: "${ @@ -74,17 +26,49 @@ const StyledFormControl = styled(FormControl)` `; export interface UploadProps { + /** + * Label for the field, i.e. "Upload CV" or "Upload letter of recommendation" + */ label?: string; + /** + * Function that returns a promise for downloading file + */ onDownload?: () => Promise; + /** + * Function that returns a promise for deleting a file + */ onDelete?: () => Promise; + /** + * Function that returns a cancelling an upload request + */ onCancel?: () => void; + /** + * The uploaded file name + */ uploaded?: string; + /** + * Whether the field is uploading or not + */ uploading?: boolean; - displayFileName?: boolean; + /** + * Which files should be accepted + */ accept?: string; + /** + * Error message + */ error?: string; + /** + * Label for "Choose file" + */ uploadLabel?: string; + /** + * Is the field disabled, i.e. should not allow things to be changed + */ disabled?: boolean; + /** + * Function that is called when a file is chosen + */ onChange?: (file: File, name: string) => void; } @@ -96,7 +80,6 @@ const Upload: React.FC = ({ onCancel, uploaded, uploading, - displayFileName, accept, error, uploadLabel, @@ -132,7 +115,7 @@ const Upload: React.FC = ({ const newLabel = ( <> - {!uploading && !uploaded && (displayFileName ? fileName || label : label)} + {!uploading && !uploaded && label} {uploading && ( Laddar upp{" "} @@ -170,7 +153,7 @@ const Upload: React.FC = ({
{newLabel}
) : (
- Date: Mon, 5 Apr 2021 15:42:19 +0200 Subject: [PATCH 35/98] Added translation to Upload and disabled property to Button in ContactPerson --- src/components/ContactPerson/index.tsx | 4 +++- src/components/portal/Upload/index.tsx | 4 ++-- src/resources/locales/en.json | 3 ++- src/resources/locales/sv.json | 3 ++- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/components/ContactPerson/index.tsx b/src/components/ContactPerson/index.tsx index 1404847..176f13c 100644 --- a/src/components/ContactPerson/index.tsx +++ b/src/components/ContactPerson/index.tsx @@ -97,7 +97,9 @@ function ContactPerson({ {status !== "received" && ( - -); diff --git a/src/components/Survey/index.stories.tsx b/src/components/Survey/index.stories.tsx new file mode 100644 index 0000000..ee9010d --- /dev/null +++ b/src/components/Survey/index.stories.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import Survey from "./index"; + +export default { + title: "Survey", +}; + +const onSubmit = (): Promise => + new Promise((res) => setInterval(res, 1000)); + +export const Basic = (): React.ReactElement => ; From b0cf8ee9d7319215f8551a4592731ae1c816f115 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Thu, 20 May 2021 23:58:50 +0200 Subject: [PATCH 61/98] Converted Plate component to TypeScript --- src/components/{Plate.jsx => Plate.tsx} | 3 --- 1 file changed, 3 deletions(-) rename src/components/{Plate.jsx => Plate.tsx} (93%) diff --git a/src/components/Plate.jsx b/src/components/Plate.tsx similarity index 93% rename from src/components/Plate.jsx rename to src/components/Plate.tsx index a7a84a9..35df29d 100644 --- a/src/components/Plate.jsx +++ b/src/components/Plate.tsx @@ -12,10 +12,7 @@ const Plate = styled.div` -o-box-shadow: 0 0 3px #ccc; box-shadow: 0 0 3px #ccc; border-radius: 8px; - min-width: 300px; - - /* display: block; */ `; export default Plate; From de5e59e807662cccd0ec98dfdd01d8dad117db73 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Fri, 21 May 2021 17:42:57 +0200 Subject: [PATCH 62/98] Convert Star component to TypeScript and renamed it to AnimatedStar --- src/components/{Star.jsx => AnimatedStar.tsx} | 12 +++++------- src/components/NoMatch.tsx | 4 ++-- 2 files changed, 7 insertions(+), 9 deletions(-) rename src/components/{Star.jsx => AnimatedStar.tsx} (56%) diff --git a/src/components/Star.jsx b/src/components/AnimatedStar.tsx similarity index 56% rename from src/components/Star.jsx rename to src/components/AnimatedStar.tsx index 63f9cf0..ecca89b 100644 --- a/src/components/Star.jsx +++ b/src/components/AnimatedStar.tsx @@ -1,23 +1,21 @@ -import styled from 'styled-components'; +import styled from "styled-components"; -const Star = styled.label` +const AnimatedStar = styled.label` color: ${(props) => props.theme.brand}; font-family: sans-serif; animation: scale 5s infinite; @keyframes scale { 0% { - transform: scale(1) + transform: scale(1); } - 50% { transform: scale(1.3); } - 100% { - transform: scale(1) + transform: scale(1); } } `; -export default Star; +export default AnimatedStar; diff --git a/src/components/NoMatch.tsx b/src/components/NoMatch.tsx index ad33d32..db42113 100644 --- a/src/components/NoMatch.tsx +++ b/src/components/NoMatch.tsx @@ -1,7 +1,7 @@ +import AnimatedStar from "./AnimatedStar"; import Center from "./Center"; import Plate from "./Plate"; import React from "react"; -import Star from "./Star"; import StyledTitle from "./StyledTitle"; import { Trans } from "react-i18next"; @@ -10,7 +10,7 @@ const NoMatch = (): React.ReactElement => ( 404 - * + *
No page was found on this URL. From 2c4366a1e8e152ec750e1d67df5a246c6cc24859 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Fri, 21 May 2021 17:45:54 +0200 Subject: [PATCH 63/98] Converted StyledTitle to TypeScript --- src/components/{StyledTitle.jsx => StyledTitle.tsx} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/components/{StyledTitle.jsx => StyledTitle.tsx} (78%) diff --git a/src/components/StyledTitle.jsx b/src/components/StyledTitle.tsx similarity index 78% rename from src/components/StyledTitle.jsx rename to src/components/StyledTitle.tsx index 3cf4485..3ed8b22 100644 --- a/src/components/StyledTitle.jsx +++ b/src/components/StyledTitle.tsx @@ -1,4 +1,4 @@ -import styled from 'styled-components'; +import styled from "styled-components"; const StyledTitle = styled.h1` color: ${(props) => props.theme.brand}; From 2d8cc8bddcd25f6f303ffc31946128c7ec6468b2 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Fri, 21 May 2021 18:11:05 +0200 Subject: [PATCH 64/98] Converted service worker code to TypeScript Code is taken from: https://github.com/cra-template/pwa --- src/index.tsx | 13 +- src/service-worker.ts | 80 ++++++++++++ ...Worker.js => serviceWorkerRegistration.ts} | 123 +++++++++--------- 3 files changed, 152 insertions(+), 64 deletions(-) create mode 100644 src/service-worker.ts rename src/{serviceWorker.js => serviceWorkerRegistration.ts} (68%) diff --git a/src/index.tsx b/src/index.tsx index 007733f..043df11 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,14 +1,15 @@ -/* eslint-disable react/jsx-filename-extension */ -import React from "react"; -import ReactDOM from "react-dom"; import "bootstrap/dist/css/bootstrap.min.css"; import "./i18n"; + +import * as serviceWorkerRegistration from "./serviceWorkerRegistration"; + import App from "./App"; -// import * as serviceWorker from './serviceWorker'; +import React from "react"; +import ReactDOM from "react-dom"; ReactDOM.render(, document.getElementById("root")); // If you want your app to work offline and load faster, you can change // unregister() to register() below. Note this comes with some pitfalls. -// Learn more about service workers: https://bit.ly/CRA-PWA -// serviceWorker.unregister(); +// Learn more about service workers: https://cra.link/PWA +serviceWorkerRegistration.unregister(); diff --git a/src/service-worker.ts b/src/service-worker.ts new file mode 100644 index 0000000..cb296d1 --- /dev/null +++ b/src/service-worker.ts @@ -0,0 +1,80 @@ +/// +/* eslint-disable no-restricted-globals */ + +// This service worker can be customized! +// See https://developers.google.com/web/tools/workbox/modules +// for the list of available Workbox modules, or add any other +// code you'd like. +// You can also remove this file if you'd prefer not to use a +// service worker, and the Workbox build step will be skipped. + +import { clientsClaim } from 'workbox-core'; +import { ExpirationPlugin } from 'workbox-expiration'; +import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching'; +import { registerRoute } from 'workbox-routing'; +import { StaleWhileRevalidate } from 'workbox-strategies'; + +declare const self: ServiceWorkerGlobalScope; + +clientsClaim(); + +// Precache all of the assets generated by your build process. +// Their URLs are injected into the manifest variable below. +// This variable must be present somewhere in your service worker file, +// even if you decide not to use precaching. See https://cra.link/PWA +precacheAndRoute(self.__WB_MANIFEST); + +// Set up App Shell-style routing, so that all navigation requests +// are fulfilled with your index.html shell. Learn more at +// https://developers.google.com/web/fundamentals/architecture/app-shell +const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$'); +registerRoute( + // Return false to exempt requests from being fulfilled by index.html. + ({ request, url }: { request: Request; url: URL }) => { + // If this isn't a navigation, skip. + if (request.mode !== 'navigate') { + return false; + } + + // If this is a URL that starts with /_, skip. + if (url.pathname.startsWith('/_')) { + return false; + } + + // If this looks like a URL for a resource, because it contains + // a file extension, skip. + if (url.pathname.match(fileExtensionRegexp)) { + return false; + } + + // Return true to signal that we want to use the handler. + return true; + }, + createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html') +); + +// An example runtime caching route for requests that aren't handled by the +// precache, in this case same-origin .png requests like those from in public/ +registerRoute( + // Add in any other file extensions or routing criteria as needed. + ({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.png'), + // Customize this strategy as needed, e.g., by changing to CacheFirst. + new StaleWhileRevalidate({ + cacheName: 'images', + plugins: [ + // Ensure that once this runtime cache reaches a maximum size the + // least-recently used images are removed. + new ExpirationPlugin({ maxEntries: 50 }), + ], + }) +); + +// This allows the web app to trigger skipWaiting via +// registration.waiting.postMessage({type: 'SKIP_WAITING'}) +self.addEventListener('message', (event) => { + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); + +// Any other custom service worker logic can go here. \ No newline at end of file diff --git a/src/serviceWorker.js b/src/serviceWorkerRegistration.ts similarity index 68% rename from src/serviceWorker.js rename to src/serviceWorkerRegistration.ts index 7cc3316..657c9e8 100644 --- a/src/serviceWorker.js +++ b/src/serviceWorkerRegistration.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-console */ // This optional code is used to register a service worker. // register() is not called by default. @@ -9,37 +8,75 @@ // resources are updated in the background. // To learn more about the benefits of this model and instructions on how to -// opt-in, read https://bit.ly/CRA-PWA +// opt-in, read https://cra.link/PWA const isLocalhost = Boolean( - window.location.hostname === 'localhost' || + window.location.hostname === "localhost" || // [::1] is the IPv6 localhost address. - window.location.hostname === '[::1]' || + window.location.hostname === "[::1]" || // 127.0.0.0/8 are considered localhost for IPv4. window.location.hostname.match( - /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/, - ), + /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ + ) ); -function registerValidSW(swUrl, config) { +type Config = { + onSuccess?: (registration: ServiceWorkerRegistration) => void; + onUpdate?: (registration: ServiceWorkerRegistration) => void; +}; + +export function register(config?: Config): void { + if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) { + // The URL constructor is available in all browsers that support SW. + const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); + if (publicUrl.origin !== window.location.origin) { + // Our service worker won't work if PUBLIC_URL is on a different origin + // from what our page is served on. This might happen if a CDN is used to + // serve assets; see https://github.com/facebook/create-react-app/issues/2374 + return; + } + + window.addEventListener("load", () => { + const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; + + if (isLocalhost) { + // This is running on localhost. Let's check if a service worker still exists or not. + checkValidServiceWorker(swUrl, config); + + // Add some additional logging to localhost, pointing developers to the + // service worker/PWA documentation. + navigator.serviceWorker.ready.then(() => { + console.log( + "This web app is being served cache-first by a service " + + "worker. To learn more, visit https://cra.link/PWA" + ); + }); + } else { + // Is not localhost. Just register service worker + registerValidSW(swUrl, config); + } + }); + } +} + +function registerValidSW(swUrl: string, config?: Config) { navigator.serviceWorker .register(swUrl) .then((registration) => { - // eslint-disable-next-line no-param-reassign registration.onupdatefound = () => { const installingWorker = registration.installing; if (installingWorker == null) { return; } installingWorker.onstatechange = () => { - if (installingWorker.state === 'installed') { + if (installingWorker.state === "installed") { if (navigator.serviceWorker.controller) { // At this point, the updated precached content has been fetched, // but the previous service worker will still serve the older // content until all client tabs are closed. console.log( - 'New content is available and will be used when all ' + - 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' + "New content is available and will be used when all " + + "tabs for this page are closed. See https://cra.link/PWA." ); // Execute callback @@ -50,7 +87,7 @@ function registerValidSW(swUrl, config) { // At this point, everything has been precached. // It's the perfect time to display a // "Content is cached for offline use." message. - console.log('Content is cached for offline use.'); + console.log("Content is cached for offline use."); // Execute callback if (config && config.onSuccess) { @@ -62,21 +99,21 @@ function registerValidSW(swUrl, config) { }; }) .catch((error) => { - console.error('Error during service worker registration:', error); + console.error("Error during service worker registration:", error); }); } -function checkValidServiceWorker(swUrl, config) { +function checkValidServiceWorker(swUrl: string, config?: Config) { // Check if the service worker can be found. If it can't reload the page. fetch(swUrl, { - headers: { 'Service-Worker': 'script' }, + headers: { "Service-Worker": "script" }, }) .then((response) => { // Ensure service worker exists, and that we really are getting a JS file. - const contentType = response.headers.get('content-type'); + const contentType = response.headers.get("content-type"); if ( - response.status === 404 - || (contentType != null && contentType.indexOf('javascript') === -1) + response.status === 404 || + (contentType != null && contentType.indexOf("javascript") === -1) ) { // No service worker found. Probably a different app. Reload the page. navigator.serviceWorker.ready.then((registration) => { @@ -91,49 +128,19 @@ function checkValidServiceWorker(swUrl, config) { }) .catch(() => { console.log( - 'No internet connection found. App is running in offline mode.' + "No internet connection found. App is running in offline mode." ); }); } -export function register(config) { - if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { - // The URL constructor is available in all browsers that support SW. - const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); - if (publicUrl.origin !== window.location.origin) { - // Our service worker won't work if PUBLIC_URL is on a different origin - // from what our page is served on. This might happen if a CDN is used to - // serve assets; see https://github.com/facebook/create-react-app/issues/2374 - return; - } - - window.addEventListener('load', () => { - const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; - - if (isLocalhost) { - // This is running on localhost. Let's check if a service worker still exists or not. - checkValidServiceWorker(swUrl, config); - - // Add some additional logging to localhost, pointing developers to the - // service worker/PWA documentation. - navigator.serviceWorker.ready.then(() => { - console.log( - 'This web app is being served cache-first by a service ' + - 'worker. To learn more, visit https://bit.ly/CRA-PWA' - ); - }); - } else { - // Is not localhost. Just register service worker - registerValidSW(swUrl, config); - } - }); - } -} - -export function unregister() { - if ('serviceWorker' in navigator) { - navigator.serviceWorker.ready.then((registration) => { - registration.unregister(); - }); +export function unregister(): void { + if ("serviceWorker" in navigator) { + navigator.serviceWorker.ready + .then((registration) => { + registration.unregister(); + }) + .catch((error) => { + console.error(error.message); + }); } } From 69fad0041893a20156db1bdacb0f8b4333feddce Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Fri, 21 May 2021 18:13:14 +0200 Subject: [PATCH 65/98] Converted test code to TypeScript --- src/{App.test.jsx => App.test.tsx} | 6 +++--- src/{setupTests.js => setupTests.ts} | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) rename src/{App.test.jsx => App.test.tsx} (66%) rename src/{setupTests.js => setupTests.ts} (80%) diff --git a/src/App.test.jsx b/src/App.test.tsx similarity index 66% rename from src/App.test.jsx rename to src/App.test.tsx index 9d2ea47..33ff808 100644 --- a/src/App.test.jsx +++ b/src/App.test.tsx @@ -1,9 +1,9 @@ +import App from "./App"; import React from "react"; import { render } from "@testing-library/react"; -import App from "./App"; -test('renders "Utvecklat av Digital Ungdom"', () => { +test('renders "Digital Ungdom"', () => { const { getByText } = render(); - const element = getByText(/Svenska/i); + const element = getByText(/Digital Ungdom/i); expect(element).toBeInTheDocument(); }); diff --git a/src/setupTests.js b/src/setupTests.ts similarity index 80% rename from src/setupTests.js rename to src/setupTests.ts index 74b1a27..1dd407a 100644 --- a/src/setupTests.js +++ b/src/setupTests.ts @@ -2,4 +2,4 @@ // allows you to do things like: // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom -import '@testing-library/jest-dom/extend-expect'; +import "@testing-library/jest-dom"; From ae5fa00cedf6c978ff9aa4363235a3ba31b674ff Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Fri, 21 May 2021 18:17:39 +0200 Subject: [PATCH 66/98] Converted StyledGroup to TypeScript --- .../StyledGroup/{index.jsx => index.tsx} | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) rename src/components/StyledGroup/{index.jsx => index.tsx} (87%) diff --git a/src/components/StyledGroup/index.jsx b/src/components/StyledGroup/index.tsx similarity index 87% rename from src/components/StyledGroup/index.jsx rename to src/components/StyledGroup/index.tsx index 1cc4795..e002d09 100644 --- a/src/components/StyledGroup/index.jsx +++ b/src/components/StyledGroup/index.tsx @@ -1,8 +1,12 @@ import Form from "react-bootstrap/Form"; -import PropTypes from "prop-types"; import styled from "styled-components"; -const StyledGroup = styled(Form.Group)` +interface StyledGroupProps { + x: number; + y: number; +} + +const StyledGroup = styled(Form.Group)` & { position: relative; margin-bottom: 1rem; @@ -14,7 +18,7 @@ const StyledGroup = styled(Form.Group)` } & > input { - padding: ${(props) => props.y}rem ${(props) => props.x}rem; + padding: ${({ props }) => props.y}rem ${(props) => props.x}rem; } & > label { @@ -76,9 +80,4 @@ StyledGroup.defaultProps = { y: 1.5, }; -StyledGroup.propTypes = { - x: PropTypes.number, - y: PropTypes.number, -}; - export default StyledGroup; From 3ac05fa605c4f307807aad65d861247e417b6741 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Fri, 21 May 2021 19:50:20 +0200 Subject: [PATCH 67/98] Fixed StyledGroup error The defaultProps weren't working --- src/components/StyledGroup/index.tsx | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/components/StyledGroup/index.tsx b/src/components/StyledGroup/index.tsx index e002d09..567ab6e 100644 --- a/src/components/StyledGroup/index.tsx +++ b/src/components/StyledGroup/index.tsx @@ -6,6 +6,11 @@ interface StyledGroupProps { y: number; } +const defaultProps = { + x: 0.75, + y: 1.5, +}; + const StyledGroup = styled(Form.Group)` & { position: relative; @@ -18,11 +23,13 @@ const StyledGroup = styled(Form.Group)` } & > input { - padding: ${({ props }) => props.y}rem ${(props) => props.x}rem; + padding: ${({ y = defaultProps.y }) => y}rem + ${({ x = defaultProps.x }) => x}rem; } & > label { - padding: ${(props) => props.y / 2}rem ${(props) => props.x}rem; + padding: ${({ y = defaultProps.y }) => y / 2}rem + ${({ x = defaultProps.x }) => x}rem; } & > label { @@ -62,22 +69,18 @@ const StyledGroup = styled(Form.Group)` & input:not(:placeholder-shown):not([type="date"]) { padding-top: calc( - ${(props) => props.y}rem + ${(props) => props.y}rem * (1 / 3) + ${({ y = defaultProps.y }) => y}rem + ${({ y = defaultProps.y }) => y}rem * + (1 / 3) ); - padding-bottom: calc(${(props) => props.y}rem * (2 / 3)); + padding-bottom: calc(${({ y = defaultProps.y }) => y}rem * (2 / 3)); } & input:not(:placeholder-shown) ~ label { - padding-top: calc(${(props) => props.y / 2}rem * (1 / 3)); - padding-bottom: calc(${(props) => props.y}rem * (2 / 3)); + padding-top: calc(${({ y = defaultProps.y }) => y / 2}rem * (1 / 3)); + padding-bottom: calc(${({ y = defaultProps.y }) => y}rem * (2 / 3)); font-size: 12px; color: ${(props) => props.theme.brand || "#777"}; } `; -StyledGroup.defaultProps = { - x: 0.75, - y: 1.5, -}; - export default StyledGroup; From e4970d618ad5f7662d05cb776876178c18c48fb0 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Fri, 21 May 2021 19:54:17 +0200 Subject: [PATCH 68/98] Fixed warnings in stories --- src/components/AdminContact/index.stories.tsx | 2 +- src/components/Upload/index.stories.tsx | 25 ++++++++++++------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/components/AdminContact/index.stories.tsx b/src/components/AdminContact/index.stories.tsx index c165ef2..9918553 100644 --- a/src/components/AdminContact/index.stories.tsx +++ b/src/components/AdminContact/index.stories.tsx @@ -30,7 +30,7 @@ export default { }, } as Meta; -export const Basic = () => ( +export const Basic = (): React.ReactElement => ( <> new Promise((res) => setTimeout(res, 1000))} diff --git a/src/components/Upload/index.stories.tsx b/src/components/Upload/index.stories.tsx index ec560c9..604970b 100644 --- a/src/components/Upload/index.stories.tsx +++ b/src/components/Upload/index.stories.tsx @@ -7,17 +7,19 @@ export default { title: "Upload", }; -export const TooLong = () => ( +export const TooLong = (): React.ReactElement => ( ); -export const Error = () => ; +export const Error = (): React.ReactElement => ( + +); -export const UploadHook = () => { +export const UploadHook = (): React.ReactElement => { const [uploading, setUploading] = useState(false); const [uploaded, setUploaded] = useState(""); - function handleChange(file: any, fileName: string) { + function handleChange(file: File, fileName: string) { action("file-change")(file, fileName); setUploading(true); setTimeout(() => { @@ -43,11 +45,13 @@ export const UploadHook = () => { ); }; -export const UploadedHook = () => { +export const UploadedHook = (): React.ReactElement => { const [uploading, setUploading] = useState(false); - const [uploaded, setUploaded] = useState(""); + const [uploaded, setUploaded] = useState( + "1289377128371298739812793871297392173987129371982379827319879387.pdf" + ); - function handleChange(file: any, fileName: string) { + function handleChange(file: File, fileName: string) { action("file-change")(file, fileName); setUploading(true); setTimeout(() => { @@ -62,15 +66,18 @@ export const UploadedHook = () => { return ( ); }; -export const ErrorStatic = () => ( +export const ErrorStatic = (): React.ReactElement => ( Date: Sun, 23 May 2021 01:26:54 +0200 Subject: [PATCH 69/98] Removed unused default react logo --- src/resources/logo.svg | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 src/resources/logo.svg diff --git a/src/resources/logo.svg b/src/resources/logo.svg deleted file mode 100644 index 6b60c10..0000000 --- a/src/resources/logo.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - From a349e2d1361ca5f5f95c94a07fd9a317e24daeba Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 24 May 2021 00:08:02 +0200 Subject: [PATCH 70/98] Moved logo to config folder --- src/components/Logo.tsx | 2 +- src/{resources/rays.png => config/logo.png} | Bin 2 files changed, 1 insertion(+), 1 deletion(-) rename src/{resources/rays.png => config/logo.png} (100%) diff --git a/src/components/Logo.tsx b/src/components/Logo.tsx index c532d1f..e0865ad 100644 --- a/src/components/Logo.tsx +++ b/src/components/Logo.tsx @@ -1,5 +1,5 @@ import React from "react"; -import logo from "resources/rays.png"; +import logo from " config/logo.png"; import styled from "styled-components"; const StyledImg = styled.img` diff --git a/src/resources/rays.png b/src/config/logo.png similarity index 100% rename from src/resources/rays.png rename to src/config/logo.png From 18f3b38c12dee1076b3bcb3a973c17fcd5b086d5 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 24 May 2021 17:33:01 +0200 Subject: [PATCH 71/98] Moved portal translations to config and removed unnecessary translations There might be more unnecessary translations in the general UI translations as well as the application specific translations. --- src/config/portal.json | 6 ------ src/{resources/locales => config}/portal_en.json | 0 src/{resources/locales => config}/portal_sv.json | 0 src/i18n.ts | 5 ++--- 4 files changed, 2 insertions(+), 9 deletions(-) rename src/{resources/locales => config}/portal_en.json (100%) rename src/{resources/locales => config}/portal_sv.json (100%) diff --git a/src/config/portal.json b/src/config/portal.json index 411039d..0012cad 100644 --- a/src/config/portal.json +++ b/src/config/portal.json @@ -1,12 +1,9 @@ { - "title": "Ansökan för Rays", - "introduction": "Tack för att du vill ansöka till Rays! Vi kommer att läsa alla ansökningar och återkomma så snart vi kan. Du ser nedan vilka uppgifter vi vill att du sänder in. Den 31 mars klockan 23:59 stänger möjligheten att ladda uppansökningar och då kommer din ansökan automatiskt att skickas in till Rays. För att din ansökan ska skickas måste du ha laddat upp alla filer samt fyllt i formuläret. Fram till detta datum kan du uppdatera en del genom att bara ladda upp en ny fil igen. Din gamla fil kommer då ersättas med den nya. Alla filer måste vara i pdf-format och de specifika begränsningarna för filstorlek och antalet ord står bredvid varje uppladdningsdel.\n\nVi som arrangerar Rays önskar dig ett stort lycka till och ser fram emot att få läsa din ansökan! [För mer information tryck här!](http://raysforexcellence.se/ansok/)", "chapters": [ { "fileType": "CV", "upload": { "multiple": 1, - "label": "Ladda upp CV", "accept": ".pdf" } }, @@ -14,7 +11,6 @@ "fileType": "COVER_LETTER", "upload": { "multiple": 1, - "label": "Ladda upp personligt brev", "accept": ".pdf" } }, @@ -22,7 +18,6 @@ "fileType": "ESSAY", "upload": { "multiple": 1, - "label": "Ladda upp essäsvar", "accept": ".pdf" } }, @@ -30,7 +25,6 @@ "fileType": "GRADES", "upload": { "multiple": 1, - "label": "Ladda upp betyg", "accept": ".pdf" } }, diff --git a/src/resources/locales/portal_en.json b/src/config/portal_en.json similarity index 100% rename from src/resources/locales/portal_en.json rename to src/config/portal_en.json diff --git a/src/resources/locales/portal_sv.json b/src/config/portal_sv.json similarity index 100% rename from src/resources/locales/portal_sv.json rename to src/config/portal_sv.json diff --git a/src/i18n.ts b/src/i18n.ts index 1c68fbb..c505da3 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -2,12 +2,11 @@ import LanguageDetector from "i18next-browser-languagedetector"; import en from "resources/locales/en.json"; import i18n from "i18next"; import { initReactI18next } from "react-i18next"; -import portalEn from "resources/locales/portal_en.json"; -import portalSv from "resources/locales/portal_sv.json"; +import portalEn from "config/portal_en.json"; +import portalSv from "config/portal_sv.json"; import sv from "resources/locales/sv.json"; // the translations -// (tip move them in a JSON file and import them) const resources = { en: { translation: { From 2e8b7dc6aadedef383224ba7314d5465667db4bf Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Mon, 24 May 2021 17:33:25 +0200 Subject: [PATCH 72/98] Fixed incorrect import in Logo --- src/components/Logo.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Logo.tsx b/src/components/Logo.tsx index e0865ad..4f8b7c1 100644 --- a/src/components/Logo.tsx +++ b/src/components/Logo.tsx @@ -1,5 +1,5 @@ import React from "react"; -import logo from " config/logo.png"; +import logo from "config/logo.png"; import styled from "styled-components"; const StyledImg = styled.img` From 53ae5e5bf18fa1202d5f6217debd34336f4a8435 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Tue, 25 May 2021 11:45:07 +0200 Subject: [PATCH 73/98] Added deadline to config --- src/config/portal.json | 3 ++- src/utils/hasApplicationClosed.ts | 5 ++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/config/portal.json b/src/config/portal.json index 0012cad..85c6442 100644 --- a/src/config/portal.json +++ b/src/config/portal.json @@ -43,5 +43,6 @@ "fileType": "RECOMMENDATION_LETTER", "contactPeople": true } - ] + ], + "deadline": 1617238799000 } diff --git a/src/utils/hasApplicationClosed.ts b/src/utils/hasApplicationClosed.ts index c837c38..1e060a1 100644 --- a/src/utils/hasApplicationClosed.ts +++ b/src/utils/hasApplicationClosed.ts @@ -1,6 +1,5 @@ -import moment from "moment"; +import { deadline } from "config/portal.json"; -const hasApplicationClosed = (): boolean => - moment.utc().month(2).endOf("month").diff(Date.now()) < 0; +const hasApplicationClosed = (): boolean => deadline < Date.now(); export default hasApplicationClosed; From 17fedcaaf66f31c4cd58c5e8db5071e56050b8d3 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Wed, 26 May 2021 11:54:44 +0200 Subject: [PATCH 74/98] Deleted unnecessary code in GradingView --- src/features/admin/GradingView.tsx | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/src/features/admin/GradingView.tsx b/src/features/admin/GradingView.tsx index 63b3d6f..d1b9759 100644 --- a/src/features/admin/GradingView.tsx +++ b/src/features/admin/GradingView.tsx @@ -21,26 +21,13 @@ import Spinner from "react-bootstrap/Spinner"; import { downloadAndOpen } from "api/downloadPDF"; import { faFileDownload } from "@fortawesome/free-solid-svg-icons"; -interface ApplicationInfo { - id: string; - email: string; - firstName: string; - lastName: string; - finnish: boolean; - birthdate: string; - city: string; - school: string; -} - interface GradingState { - loading: boolean[]; - applications: Record | undefined; + loading: boolean; } class Grading extends React.Component { state = { - loading: [true, true], - applications: {}, + loading: true, }; componentDidMount() { @@ -50,7 +37,7 @@ class Grading extends React.Component { }); getGradingOrder().then((res) => { this.props.updateGradingOrder(res); - this.setState({ loading: [this.state.loading[0], false] }); + this.setState({ loading: false }); }); } } @@ -127,7 +114,7 @@ class Grading extends React.Component { data={dataWithIndex} columns={columns} noDataIndication={() => - this.state.loading[1] ? ( + this.state.loading ? ( Date: Thu, 27 May 2021 23:25:01 +0200 Subject: [PATCH 75/98] Simplified survey component By removing helpers in onSubmit prop --- src/components/Survey/index.tsx | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/src/components/Survey/index.tsx b/src/components/Survey/index.tsx index b42b3b5..64d0e59 100644 --- a/src/components/Survey/index.tsx +++ b/src/components/Survey/index.tsx @@ -44,12 +44,7 @@ const StyledCard = styled(Card)` interface SurveyProps { survey?: SurveyAnswers; - onSubmit: ( - surveyAnswers: SurveyAnswers, - helpers: { - setSubmitting: (isSubmitting: boolean) => void; - } - ) => Promise; + onSubmit: (surveyAnswers: SurveyAnswers) => Promise; disabled?: boolean; } @@ -82,7 +77,10 @@ const Survey = ({ { + onSubmit={( + { gender, ...values }, + { setSubmitting, setErrors } + ) => { let processError, portalError, genderError = false; @@ -90,20 +88,17 @@ const Survey = ({ if (values.applicationPortal === 0) portalError = true; if (gender === "select") genderError = true; if (processError || portalError || genderError) { - helpers.setErrors({ + setErrors({ gender: genderError ? "Select an option" : undefined, applicationPortal: portalError ? "required" : undefined, applicationProcess: processError ? "required" : undefined, }); - helpers.setSubmitting(false); + setSubmitting(false); } else { - onSubmit( - { - ...values, - gender: gender as Gender, - }, - helpers - ).then(() => helpers.setSubmitting(false)); + onSubmit({ + ...values, + gender: gender as Gender, + }).then(() => setSubmitting(false)); } }} validationSchema={validationSchema} From 4a0632b5decf05c90e69b40d1db64c2dc0d72d39 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Fri, 28 May 2021 20:40:56 +0200 Subject: [PATCH 76/98] Began working on a custom survey feature --- src/components/CustomSurvey/index.stories.tsx | 20 ++++ src/components/CustomSurvey/index.tsx | 91 +++++++++++++++++++ src/types/survey.ts | 19 ++++ 3 files changed, 130 insertions(+) create mode 100644 src/components/CustomSurvey/index.stories.tsx create mode 100644 src/components/CustomSurvey/index.tsx diff --git a/src/components/CustomSurvey/index.stories.tsx b/src/components/CustomSurvey/index.stories.tsx new file mode 100644 index 0000000..630241b --- /dev/null +++ b/src/components/CustomSurvey/index.stories.tsx @@ -0,0 +1,20 @@ +import CustomSurvey from "./index"; +import React from "react"; + +export default { + title: "CustomSurvey", +}; + +const onSubmit = (): Promise => + new Promise((res) => setInterval(res, 1000)); + +const config = [ + { + type: "TEXT", + maxLength: 100, + }, +]; + +export const Basic = (): React.ReactElement => ( + +); diff --git a/src/components/CustomSurvey/index.tsx b/src/components/CustomSurvey/index.tsx new file mode 100644 index 0000000..2344ecf --- /dev/null +++ b/src/components/CustomSurvey/index.tsx @@ -0,0 +1,91 @@ +import { CustomSurveyAnswer, CustomSurveyQuestion } from "types/survey"; + +import Accordion from "react-bootstrap/Accordion"; +import Button from "react-bootstrap/Button"; +import Card from "react-bootstrap/Card"; +import Form from "react-bootstrap/Form"; +import React from "react"; +import styled from "styled-components"; +import { useTranslation } from "react-i18next"; + +const StyledCard = styled(Card)` + &.done { + background: rgba(40, 167, 69, 0.1); + border-color: rgb(40, 167, 69); + } + + &.done .card-header { + color: #155724; + background-color: #d4edda; + } + + &.done .card-header .btn { + color: #155724; + } + + & .form-label { + /* font-weight: 400; */ + } +`; + +interface CustomSurveyAccordionProps { + config: CustomSurveyQuestion[]; + initialValues?: CustomSurveyAnswer[]; + onSubmit: (surveyAnswers: CustomSurveyAnswer[]) => Promise; + disabled?: boolean; +} + +function mapQuestionToInput(question: CustomSurveyQuestion) { + switch (question.type) { + case "RANGE": + return ( + + range + + ); + case "TEXT": + return ( + + + + ); + default: + return null; + } +} + +const CustomSurveyAccordion = ({ + config, + onSubmit, + disabled, + initialValues, +}: CustomSurveyAccordionProps): React.ReactElement => { + const { t } = useTranslation(); + + const done = initialValues ? "done" : ""; + + return ( + + + + + {t("Open (verb)")} + + + + +
{ + e.preventDefault(); + }} + > + {config.map(mapQuestionToInput)} +
+
+
+
+
+ ); +}; + +export default CustomSurveyAccordion; diff --git a/src/types/survey.ts b/src/types/survey.ts index 2d2f626..046a3ea 100644 --- a/src/types/survey.ts +++ b/src/types/survey.ts @@ -28,3 +28,22 @@ export interface Statistics { school: string[]; gender: { count: Record }; } + +export type CustomSurveyQuestion = SurveyTextQuestion | SurveyRangeQuestion; + +export type SurveyTextQuestion = { + type: "TEXT"; + maxLength?: number; + id?: string; +}; + +export type SurveyRangeQuestion = { + type: "RANGE"; + range?: [number, number]; + id?: string; +}; + +export type CustomSurveyAnswer = { + id?: string; + value: number | string; +}; From 56e2cc93ffc76ac2adf6cd5e600c962f098c51ee Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Sat, 29 May 2021 18:17:39 +0200 Subject: [PATCH 77/98] Fixed type bug in CustomSurvey story --- src/components/CustomSurvey/index.stories.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/CustomSurvey/index.stories.tsx b/src/components/CustomSurvey/index.stories.tsx index 630241b..2de41fd 100644 --- a/src/components/CustomSurvey/index.stories.tsx +++ b/src/components/CustomSurvey/index.stories.tsx @@ -1,4 +1,5 @@ import CustomSurvey from "./index"; +import { CustomSurveyQuestion } from "types/survey"; import React from "react"; export default { @@ -8,7 +9,7 @@ export default { const onSubmit = (): Promise => new Promise((res) => setInterval(res, 1000)); -const config = [ +const config: CustomSurveyQuestion[] = [ { type: "TEXT", maxLength: 100, From 195241645fdf679085ac28403d1fc7503cb5160f Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Sun, 30 May 2021 15:13:21 +0200 Subject: [PATCH 78/98] Added survey questions to config --- src/config/portal.json | 37 +++++++++++++++++++++++++++++++++++++ src/config/portal_en.json | 17 +++++++++++++++++ src/config/portal_sv.json | 17 +++++++++++++++++ 3 files changed, 71 insertions(+) diff --git a/src/config/portal.json b/src/config/portal.json index 85c6442..4f8165f 100644 --- a/src/config/portal.json +++ b/src/config/portal.json @@ -44,5 +44,42 @@ "contactPeople": true } ], + "survey": [ + { + "type": "TEXT", + "maxLength": 256, + "id": "city" + }, + { + "type": "TEXT", + "maxLength": 256, + "id": "school" + }, + { + "type": "SELECT", + "options": ["MALE", "FEMALE", "OTHER", "UNDISCLOSED"], + "id": "gender" + }, + { + "type": "RANGE", + "range": [1, 5], + "id": "applicationPortal" + }, + { + "type": "RANGE", + "range": [1, 5], + "id": "applicationProcess" + }, + { + "type": "TEXT", + "maxLength": 8192, + "id": "improvement" + }, + { + "type": "TEXT", + "maxLength": 8192, + "id": "informant" + } + ], "deadline": 1617238799000 } diff --git a/src/config/portal_en.json b/src/config/portal_en.json index 0d3d0e4..1beafdd 100644 --- a/src/config/portal_en.json +++ b/src/config/portal_en.json @@ -51,5 +51,22 @@ "label": "Upload appendices" } }, + "survey": { + "city": "What city do you live in?", + "school": "Which school do you attend?", + "gender": { + "label": "Gender", + "options": { + "MALE": "Male", + "FEMALE": "Female", + "OTHER": "Other", + "UNDISCLOSED": "Prefer not to disclose" + } + }, + "applicationPortal": "What are your thoughts on the application portal?", + "applicationProcess": "What are your thoughts on the application process?", + "improvement": "How can the application process and portal be improved?", + "informant": "How did you hear about Rays?" + }, "GDPR": "### RAYS Application Portal GDPR\n\n When you create an account, you agree to:\n \n - RESEARCH ACADEMY FOR YOUNG SCIENTISTS (RAYS), hereinafter referred to as RAYS, and Digital Ungdom may store your personal data for up to one (1) month after result has been given.\n \n - RAYS and Digital Ungdom may store all uploaded data on the website for up to one (1) month after result has been given.\n\nYou have the right to:\n \n - Don't have your personal information disclosed to third parties.\n \n - Get a printout with all the information that RAYS has saved about you.\n \n - Get your information corrected if they are wrong.\n \n - Get information about you deleted.\n\nThis is done by sending an email to [e-application@raysforexcellence.se](mailto:e-application@raysforexcellence.se) and tell us what you want.\n\nRAYS uses your personal information to:\n \n - Generate relevant statistics for RAYS.\n \n - Contact you with relevant information.\n \n - Select applicants in the admissions process.\n \n - Publish information on about the accepted.\n\nRAYS specifically uses the below personal information to:\n\n- Name: identify you.\n\n- Email: contact you.\n\n- Applying through Finland: Know origin of application.\n\n- Birthday: Statistics and verification of identity.\n\n- Uploaded files and letters of recommendation: Selection in admission process.\n\nRAYS is responsible to:\n \n - Never disclose your personal information without your consent.\n \n - Be careful not to share your personal information to anyone outside the organization.\n\nIf you have questions about RAYS and the data protection for your information contact\n the office via email\n [application@raysforexcellence.se](mailto:e-application@raysforexcellence.se)." } diff --git a/src/config/portal_sv.json b/src/config/portal_sv.json index ee756e3..d99163c 100644 --- a/src/config/portal_sv.json +++ b/src/config/portal_sv.json @@ -51,5 +51,22 @@ "label": "Ladda upp bilagor" } }, + "survey": { + "city": "Vilken stad bor du i?", + "school": "Vilken skola går du på?", + "gender": { + "label": "Kön", + "options": { + "MALE": "Man", + "FEMALE": "Kvinna", + "OTHER": "Annat", + "UNDISCLOSED": "Vill ej uppge" + } + }, + "applicationPortal": "Vad tycker du om ansökningsportalen?", + "applicationProcess": "Vad tycker du om ansökningsprocessen?", + "improvement": "Hur kan ansökningsprocessen och portalen förbättras?", + "informant": "Hur hörde du talas om Rays?" + }, "GDPR": "#### RAYS Application Portal GDPR\n\nNär du skapar ett konto godkänner du att:\n\n- RESEARCH ACADEMY FOR YOUNG SCIENTISTS (RAYS), nedan kallat RAYS, och Digital Ungdom får spara dina personuppgifter upp till en (1) månad efter resultatet har meddelats.\n\n- RAYS och Digital Ungdom får spara all uppladdad data på hemsidan upp till en (1) månad efter resultatet har meddelats.\n\nDu har rätt att:\n\n- Slippa få dina personuppgifter utlämnade till tredje parter.\n\n- Få ut en utskrift med all information som RAYS sparat om dig.\n\n- Få dina uppgifter rättade om de är fel.\n\n- Få uppgifter om dig raderade.\n\nDet görs genom att skicka e-post till [e-application@raysforexcellence.se](mailto:e-application@raysforexcellence.se) och berätta vad du vill.\n\nRAYS använder dina personuppgifter till att:\n\n- Ta fram relevant statistik för RAYS.\n\n- Kontakta dig med eventuell relevant information.\n\n- Välja ut sökande i antagningsprocessen.\n\n- Publicera information om de antagna.\n\nRAYS använder specifikt dessa personuppgifter till att:\n\n- Namn: för att identifera dig.\n\n- Email: för att kontakta dig.\n\n- Ansöker via Finland: För att veta ursprunget av ansökan.\n\n- Födelsedag: Statistik och bekräftning av identitet.\n\n- Uppladdade filer och rekommendationsbrev: Urval i antagningsprocessen.\n\nRAYS ansvarar för att:\n\n- Aldrig lämna ut dina personuppgifter utan att du har godkänt det.\n\n- Vara försiktiga så att ingen utanför föreningen tar del av dina personuppgifter.\n\nHar ni frågor om RAYS och dataskyddet för dina uppgifter kontakta\nkansliet via e-post\n[e-application@raysforexcellence.se](mailto:e-application@raysforexcellence.se)." } From 44cd8950f16bfcc4bfdb2836d0cf527b47023ab1 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Sun, 30 May 2021 15:13:48 +0200 Subject: [PATCH 79/98] Added select question type to survey type definitions --- src/types/survey.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/types/survey.ts b/src/types/survey.ts index 046a3ea..962e64c 100644 --- a/src/types/survey.ts +++ b/src/types/survey.ts @@ -29,21 +29,30 @@ export interface Statistics { gender: { count: Record }; } -export type CustomSurveyQuestion = SurveyTextQuestion | SurveyRangeQuestion; +export type CustomSurveyQuestion = + | SurveyTextQuestion + | SurveyRangeQuestion + | SurveySelectQuestion; export type SurveyTextQuestion = { type: "TEXT"; - maxLength?: number; - id?: string; + maxLength: number; + id: string; }; export type SurveyRangeQuestion = { type: "RANGE"; - range?: [number, number]; - id?: string; + range: [number, number]; + id: string; +}; + +export type SurveySelectQuestion = { + type: "SELECT"; + options: string[]; + id: string; }; export type CustomSurveyAnswer = { - id?: string; + id: string; value: number | string; }; From 534c77ea52e6c69c53c2b5e5a08e90fea6d95bca Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Sun, 30 May 2021 15:14:24 +0200 Subject: [PATCH 80/98] Developed question type react components in CustomSurvey --- src/components/CustomSurvey/index.stories.tsx | 33 +++++++++++++++- src/components/CustomSurvey/index.tsx | 38 +++++++++++++++---- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/src/components/CustomSurvey/index.stories.tsx b/src/components/CustomSurvey/index.stories.tsx index 2de41fd..216e17e 100644 --- a/src/components/CustomSurvey/index.stories.tsx +++ b/src/components/CustomSurvey/index.stories.tsx @@ -12,7 +12,38 @@ const onSubmit = (): Promise => const config: CustomSurveyQuestion[] = [ { type: "TEXT", - maxLength: 100, + maxLength: 256, + id: "city", + }, + { + type: "TEXT", + maxLength: 256, + id: "school", + }, + { + type: "SELECT", + options: ["MALE", "FEMALE", "OTHER", "UNDISCLOSED"], + id: "gender", + }, + { + type: "RANGE", + range: [1, 5], + id: "applicationPortal", + }, + { + type: "RANGE", + range: [1, 5], + id: "applicationProcess", + }, + { + type: "TEXT", + maxLength: 8192, + id: "improvement", + }, + { + type: "TEXT", + maxLength: 8192, + id: "informant", }, ]; diff --git a/src/components/CustomSurvey/index.tsx b/src/components/CustomSurvey/index.tsx index 2344ecf..ed426ea 100644 --- a/src/components/CustomSurvey/index.tsx +++ b/src/components/CustomSurvey/index.tsx @@ -35,22 +35,41 @@ interface CustomSurveyAccordionProps { disabled?: boolean; } -function mapQuestionToInput(question: CustomSurveyQuestion) { - switch (question.type) { +function Question(props: CustomSurveyQuestion): React.ReactElement { + const { t } = useTranslation(); + const label = {t(`survey.${props.id}`)}; + switch (props.type) { case "RANGE": + return {label}; + case "TEXT": return ( - range + {label} + 256 ? "textarea" : "input"} + /> ); - case "TEXT": + case "SELECT": return ( - + {t(`survey.${props.id}.label`)} + + + {props.options.map((option) => ( + + ))} + ); default: - return null; + return {label}; } } @@ -63,9 +82,12 @@ const CustomSurveyAccordion = ({ const { t } = useTranslation(); const done = initialValues ? "done" : ""; + const form = config.map((question) => ( + + )); return ( - + @@ -79,7 +101,7 @@ const CustomSurveyAccordion = ({ e.preventDefault(); }} > - {config.map(mapQuestionToInput)} + {form}
From 6092256538ef25ee298690b2f3d718fc68996e8a Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Sun, 30 May 2021 16:24:59 +0200 Subject: [PATCH 81/98] Added range translation and component to CustomSurvey --- src/components/CustomSurvey/index.tsx | 23 +++++++++++++++++++++-- src/config/portal_en.json | 12 ++++++++++-- src/config/portal_sv.json | 12 ++++++++++-- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/components/CustomSurvey/index.tsx b/src/components/CustomSurvey/index.tsx index ed426ea..abb61a9 100644 --- a/src/components/CustomSurvey/index.tsx +++ b/src/components/CustomSurvey/index.tsx @@ -40,11 +40,30 @@ function Question(props: CustomSurveyQuestion): React.ReactElement { const label = {t(`survey.${props.id}`)}; switch (props.type) { case "RANGE": - return {label}; + return ( + + {t(`survey.${props.id}.label`)} +
+ {t(`survey.${props.id}.low`)} + {[...Array(props.range[1] - props.range[0] + 1)].map((_, i) => ( + + ))} + {t(`survey.${props.id}.high`)} +
+
+ ); + case "TEXT": return ( - {label} + {t(`survey.${props.id}`)} Date: Mon, 31 May 2021 10:45:04 +0200 Subject: [PATCH 82/98] Separated CustomSurveyAccordion into a Form and Question --- .../CustomSurvey/CustomSurveyForm.tsx | 30 +++++++ .../CustomSurvey/CustomSurveyQuestion.tsx | 65 ++++++++++++++ src/components/CustomSurvey/index.stories.tsx | 7 +- src/components/CustomSurvey/index.tsx | 85 ++----------------- 4 files changed, 107 insertions(+), 80 deletions(-) create mode 100644 src/components/CustomSurvey/CustomSurveyForm.tsx create mode 100644 src/components/CustomSurvey/CustomSurveyQuestion.tsx diff --git a/src/components/CustomSurvey/CustomSurveyForm.tsx b/src/components/CustomSurvey/CustomSurveyForm.tsx new file mode 100644 index 0000000..93ccc53 --- /dev/null +++ b/src/components/CustomSurvey/CustomSurveyForm.tsx @@ -0,0 +1,30 @@ +import { CustomSurveyAnswer, CustomSurveyQuestion } from "types/survey"; + +import Question from "./CustomSurveyQuestion"; +import React from "react"; + +interface CustomSurveyFormProps { + config: CustomSurveyQuestion[]; + initialValues?: CustomSurveyAnswer[]; + onSubmit: (surveyAnswers: CustomSurveyAnswer[]) => Promise; + disabled?: boolean; +} + +function CustomSurveyForm({ + config, +}: CustomSurveyFormProps): React.ReactElement { + const questions = config.map((question) => ( + + )); + return ( +
{ + e.preventDefault(); + }} + > + {questions} +
+ ); +} + +export default CustomSurveyForm; diff --git a/src/components/CustomSurvey/CustomSurveyQuestion.tsx b/src/components/CustomSurvey/CustomSurveyQuestion.tsx new file mode 100644 index 0000000..e702aab --- /dev/null +++ b/src/components/CustomSurvey/CustomSurveyQuestion.tsx @@ -0,0 +1,65 @@ +import { CustomSurveyQuestion } from "types/survey"; +import Form from "react-bootstrap/Form"; +import React from "react"; +import { useTranslation } from "react-i18next"; + +function Question(props: CustomSurveyQuestion): React.ReactElement { + const { t } = useTranslation(); + switch (props.type) { + case "RANGE": + return ( + + {t(`survey.${props.id}.label`)} +
+ + {t(`survey.${props.id}.low`)} + + {[...Array(props.range[1] - props.range[0] + 1)].map((_, i) => ( + + ))} + + {t(`survey.${props.id}.high`)} + +
+
+ ); + + case "TEXT": + return ( + + {t(`survey.${props.id}`)} + 256 ? "textarea" : "input"} + /> + + ); + case "SELECT": + return ( + + {t(`survey.${props.id}.label`)} + + + {props.options.map((option) => ( + + ))} + + + ); + default: + return <>; + } +} +export default Question; diff --git a/src/components/CustomSurvey/index.stories.tsx b/src/components/CustomSurvey/index.stories.tsx index 216e17e..1bb31de 100644 --- a/src/components/CustomSurvey/index.stories.tsx +++ b/src/components/CustomSurvey/index.stories.tsx @@ -1,4 +1,5 @@ import CustomSurvey from "./index"; +import CustomSurveyForm from "./CustomSurveyForm"; import { CustomSurveyQuestion } from "types/survey"; import React from "react"; @@ -47,6 +48,10 @@ const config: CustomSurveyQuestion[] = [ }, ]; -export const Basic = (): React.ReactElement => ( +export const Accordion = (): React.ReactElement => ( ); + +export const Form = (): React.ReactElement => ( + +); diff --git a/src/components/CustomSurvey/index.tsx b/src/components/CustomSurvey/index.tsx index abb61a9..2be6657 100644 --- a/src/components/CustomSurvey/index.tsx +++ b/src/components/CustomSurvey/index.tsx @@ -3,7 +3,7 @@ import { CustomSurveyAnswer, CustomSurveyQuestion } from "types/survey"; import Accordion from "react-bootstrap/Accordion"; import Button from "react-bootstrap/Button"; import Card from "react-bootstrap/Card"; -import Form from "react-bootstrap/Form"; +import CustomSurveyForm from "./CustomSurveyForm"; import React from "react"; import styled from "styled-components"; import { useTranslation } from "react-i18next"; @@ -22,10 +22,6 @@ const StyledCard = styled(Card)` &.done .card-header .btn { color: #155724; } - - & .form-label { - /* font-weight: 400; */ - } `; interface CustomSurveyAccordionProps { @@ -35,75 +31,12 @@ interface CustomSurveyAccordionProps { disabled?: boolean; } -function Question(props: CustomSurveyQuestion): React.ReactElement { - const { t } = useTranslation(); - const label = {t(`survey.${props.id}`)}; - switch (props.type) { - case "RANGE": - return ( - - {t(`survey.${props.id}.label`)} -
- {t(`survey.${props.id}.low`)} - {[...Array(props.range[1] - props.range[0] + 1)].map((_, i) => ( - - ))} - {t(`survey.${props.id}.high`)} -
-
- ); - - case "TEXT": - return ( - - {t(`survey.${props.id}`)} - 256 ? "textarea" : "input"} - /> - - ); - case "SELECT": - return ( - - {t(`survey.${props.id}.label`)} - - - {props.options.map((option) => ( - - ))} - - - ); - default: - return {label}; - } -} - -const CustomSurveyAccordion = ({ - config, - onSubmit, - disabled, - initialValues, -}: CustomSurveyAccordionProps): React.ReactElement => { +const CustomSurveyAccordion = ( + props: CustomSurveyAccordionProps +): React.ReactElement => { const { t } = useTranslation(); - const done = initialValues ? "done" : ""; - const form = config.map((question) => ( - - )); + const done = props.initialValues ? "done" : ""; return ( @@ -115,13 +48,7 @@ const CustomSurveyAccordion = ({ -
{ - e.preventDefault(); - }} - > - {form} -
+
From ef3b802c59fdcea9345e2d438a2622404fd45269 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Tue, 1 Jun 2021 16:52:27 +0200 Subject: [PATCH 83/98] Added initialValues to CustomSurvey --- .../CustomSurvey/CustomSurveyForm.tsx | 9 ++- .../CustomSurvey/CustomSurveyQuestion.tsx | 59 +++++++++++-------- src/components/CustomSurvey/index.stories.tsx | 16 ++++- src/types/survey.ts | 5 +- 4 files changed, 57 insertions(+), 32 deletions(-) diff --git a/src/components/CustomSurvey/CustomSurveyForm.tsx b/src/components/CustomSurvey/CustomSurveyForm.tsx index 93ccc53..5ea1d95 100644 --- a/src/components/CustomSurvey/CustomSurveyForm.tsx +++ b/src/components/CustomSurvey/CustomSurveyForm.tsx @@ -5,16 +5,21 @@ import React from "react"; interface CustomSurveyFormProps { config: CustomSurveyQuestion[]; - initialValues?: CustomSurveyAnswer[]; + initialValues?: Record; onSubmit: (surveyAnswers: CustomSurveyAnswer[]) => Promise; disabled?: boolean; } function CustomSurveyForm({ config, + initialValues, }: CustomSurveyFormProps): React.ReactElement { const questions = config.map((question) => ( - + )); return (
- {t(`survey.${props.id}.label`)} + {t(`survey.${question.id}.label`)}
- {t(`survey.${props.id}.low`)} + {t(`survey.${question.id}.low`)} - {[...Array(props.range[1] - props.range[0] + 1)].map((_, i) => ( - - ))} + {[...Array(question.range[1] - question.range[0] + 1)].map( + (_, i) => ( + + ) + )} - {t(`survey.${props.id}.high`)} + {t(`survey.${question.id}.high`)}
); - case "TEXT": return ( - {t(`survey.${props.id}`)} + {t(`survey.${question.id}`)} 256 ? "textarea" : "input"} + maxLength={question.maxLength} + as={question.maxLength > 256 ? "textarea" : "input"} + defaultValue={value} /> ); case "SELECT": return ( - {t(`survey.${props.id}.label`)} + {t(`survey.${question.id}.label`)} - - {props.options.map((option) => ( - ))} diff --git a/src/components/CustomSurvey/index.stories.tsx b/src/components/CustomSurvey/index.stories.tsx index 1bb31de..11d32d7 100644 --- a/src/components/CustomSurvey/index.stories.tsx +++ b/src/components/CustomSurvey/index.stories.tsx @@ -48,10 +48,24 @@ const config: CustomSurveyQuestion[] = [ }, ]; +const initialValues = { + city: "Stockholm", + school: "Nobel", + gender: 0, + applicationPortal: 5, + applicationProcess: 3, + improvement: "hmmm...", + informant: "vem?", +}; + export const Accordion = (): React.ReactElement => ( ); export const Form = (): React.ReactElement => ( - + ); diff --git a/src/types/survey.ts b/src/types/survey.ts index 962e64c..e585134 100644 --- a/src/types/survey.ts +++ b/src/types/survey.ts @@ -52,7 +52,4 @@ export type SurveySelectQuestion = { id: string; }; -export type CustomSurveyAnswer = { - id: string; - value: number | string; -}; +export type CustomSurveyAnswer = number | string | undefined; From 23fcd4df5388ae1ca6a3af7a95b03f57b0c3c241 Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Tue, 1 Jun 2021 16:57:48 +0200 Subject: [PATCH 84/98] Bug fix for initialValues in CustomSurvey --- src/components/CustomSurvey/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/CustomSurvey/index.tsx b/src/components/CustomSurvey/index.tsx index 2be6657..ce3aa2e 100644 --- a/src/components/CustomSurvey/index.tsx +++ b/src/components/CustomSurvey/index.tsx @@ -26,7 +26,7 @@ const StyledCard = styled(Card)` interface CustomSurveyAccordionProps { config: CustomSurveyQuestion[]; - initialValues?: CustomSurveyAnswer[]; + initialValues?: Record; onSubmit: (surveyAnswers: CustomSurveyAnswer[]) => Promise; disabled?: boolean; } From f28bfdf10087d9c8ef917c8781fc5df2d1be250d Mon Sep 17 00:00:00 2001 From: Nautman <28629647+Nautman@users.noreply.github.com> Date: Wed, 2 Jun 2021 12:45:36 +0200 Subject: [PATCH 85/98] Added submit button and required fields to CustomSurvey --- .../CustomSurvey/CustomSurveyForm.tsx | 27 +++++++++++++++++-- .../CustomSurvey/CustomSurveyQuestion.tsx | 7 +++-- src/components/CustomSurvey/index.stories.tsx | 11 +++++--- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/components/CustomSurvey/CustomSurveyForm.tsx b/src/components/CustomSurvey/CustomSurveyForm.tsx index 5ea1d95..12b45ae 100644 --- a/src/components/CustomSurvey/CustomSurveyForm.tsx +++ b/src/components/CustomSurvey/CustomSurveyForm.tsx @@ -1,18 +1,23 @@ import { CustomSurveyAnswer, CustomSurveyQuestion } from "types/survey"; +import React, { useState } from "react"; +import Button from "react-bootstrap/Button"; import Question from "./CustomSurveyQuestion"; -import React from "react"; +import Spinner from "react-bootstrap/Spinner"; +import { useTranslation } from "react-i18next"; interface CustomSurveyFormProps { config: CustomSurveyQuestion[]; initialValues?: Record; - onSubmit: (surveyAnswers: CustomSurveyAnswer[]) => Promise; + onSubmit: (values: Record) => Promise; disabled?: boolean; } function CustomSurveyForm({ config, initialValues, + onSubmit, + disabled, }: CustomSurveyFormProps): React.ReactElement { const questions = config.map((question) => ( )); + const [isSubmitting, setSubmitting] = useState(false); + const { t } = useTranslation(); return ( { e.preventDefault(); + const target = e.target as HTMLFormElement; + const values: Record = {}; + config.forEach(({ id }) => (values[id] = target[id].value)); + setSubmitting(true); + onSubmit(values).then(() => setSubmitting(false)); }} > {questions} + ); } diff --git a/src/components/CustomSurvey/CustomSurveyQuestion.tsx b/src/components/CustomSurvey/CustomSurveyQuestion.tsx index e5bd1ea..60855ed 100644 --- a/src/components/CustomSurvey/CustomSurveyQuestion.tsx +++ b/src/components/CustomSurvey/CustomSurveyQuestion.tsx @@ -30,6 +30,7 @@ function Question({ question, value }: QuestionProps): React.ReactElement { label={question.range[0] + i} value={question.range[0] + i} defaultChecked={question.range[0] + i === value} + required /> ) )} @@ -48,6 +49,8 @@ function Question({ question, value }: QuestionProps): React.ReactElement { maxLength={question.maxLength} as={question.maxLength > 256 ? "textarea" : "input"} defaultValue={value} + required + name={question.id} />
); @@ -55,8 +58,8 @@ function Question({ question, value }: QuestionProps): React.ReactElement { return ( {t(`survey.${question.id}.label`)} - -