diff --git a/.gitignore b/.gitignore index df34f5f..942e627 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ # storybook build-storybook.log +storybook-static # dependencies /node_modules diff --git a/.storybook/preview.js b/.storybook/preview.js index 91cec8a..ddb7d78 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,3 +1,5 @@ +import "bootstrap/dist/css/bootstrap.min.css"; + import CustomThemeProvider from "./../src/components/CustomThemeProvider"; import { I18nextProvider } from "react-i18next"; import i18n from "../src/i18n"; 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/App.tsx b/src/App.tsx index badd1e6..c92ee8c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,3 +1,4 @@ +import "bootstrap/dist/css/bootstrap.min.css"; import "utils/tokenInterceptor"; import "resources/app.css"; import "react-toastify/dist/ReactToastify.min.css"; @@ -5,6 +6,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,40 +15,21 @@ 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` - background: ${(props) => props.theme.bg}; - - height: 100%; - width: 100%; - overflow: auto; - - #centered { - width: 100%; - min-height: calc(100% - 2.5rem); - display: table; - } -`; - -axios.defaults.baseURL = - process.env.REACT_APP_API_URL || "https://devapi.infrarays.digitalungdom.se"; - -function App() { +function App(): React.ReactElement { return ( - +
-
+
diff --git a/src/api/admin.ts b/src/api/admin.ts new file mode 100644 index 0000000..90f7085 --- /dev/null +++ b/src/api/admin.ts @@ -0,0 +1,45 @@ +import { Admin, NewAdmin } from "types/user"; +import { + Application, + ApplicationGrade, + GradedApplication, + IndividualGrading, + OrderItem, +} from "types/grade"; + +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(getGradesConfig(applicantID)); + +export const getApplications = (): Promise< + (Application | GradedApplication)[] +> => api.format.get("/application"); + +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); + +/** + * Admins can randomise the order they view applications in, + * to decrease bias in the grading process + * @returns order of applicants + */ +export const randomiseOrder = (): Promise => + api.format.post("/admin/grading/randomise"); 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/axios.ts b/src/api/axios.ts new file mode 100644 index 0000000..353e101 --- /dev/null +++ b/src/api/axios.ts @@ -0,0 +1,47 @@ +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; + delete(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), + delete: (url, config) => + api + .delete(url, 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/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/api/files.ts b/src/api/files.ts new file mode 100644 index 0000000..2df9c89 --- /dev/null +++ b/src/api/files.ts @@ -0,0 +1,69 @@ +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(getFilesConfiguration(applicantID)); + +export const downloadFile = ( + fileID: string, + applicantID = "@me" +): Promise => + 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 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" +): Promise => + api.format.delete(`/application/${applicantID}/file/${fileID}`); + +export const uploadFile = ( + fileType: FileType, + file: File, + fileName: string, + applicantID = "@me" +): Promise => { + // create FormData to append file with desired file name + const form = new FormData(); + form.append("file", file, fileName); + // format the results, useful if there are errors! + return api.format.post( + `application/${applicantID}/file/${fileType}`, + form, + { + headers: { "Content-Type": "multipart/form-data" }, + } + ); +}; diff --git a/src/api/recommendations.ts b/src/api/recommendations.ts new file mode 100644 index 0000000..df21251 --- /dev/null +++ b/src/api/recommendations.ts @@ -0,0 +1,33 @@ +import { + RecommendationFile, + RecommendationRequest, +} from "types/recommendations"; + +import api from "./axios"; + +export const requestRecommendation = ( + recommendationIndex: number, + email: string +): Promise => + api.format.post( + `/application/@me/recommendation/${recommendationIndex}`, + { + email, + } + ); + +export const getRecommendationRequestConfig = (code: string): string => + `/application/recommendation/${code}`; + +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/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/api/survey.ts b/src/api/survey.ts new file mode 100644 index 0000000..3d53c5e --- /dev/null +++ b/src/api/survey.ts @@ -0,0 +1,11 @@ +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" +): Promise => + api.format.post(`/application/${applicantID}/survey`, survey); diff --git a/src/api/user.ts b/src/api/user.ts new file mode 100644 index 0000000..e85eeb4 --- /dev/null +++ b/src/api/user.ts @@ -0,0 +1,11 @@ +import { User } from "types/user"; +import api from "./axios"; + +/** + * Delete user forever + * @returns {Promise} + */ +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/AddButton/index.stories.jsx b/src/components/AddButton/index.stories.jsx deleted file mode 100644 index cd306c9..0000000 --- a/src/components/AddButton/index.stories.jsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import AddButton from './index'; - -export default { title: 'AddButton' }; - -export const Basic = () => ( -
- -
-); diff --git a/src/components/AddButton/index.stories.tsx b/src/components/AddButton/index.stories.tsx new file mode 100644 index 0000000..be32638 --- /dev/null +++ b/src/components/AddButton/index.stories.tsx @@ -0,0 +1,19 @@ +import AddButton, { AddButtonProps } from "./index"; + +import React from "react"; +import { Story } from "@storybook/react"; + +//👇 We create a "template" of how args map to rendering +const Template: Story = (args) => ; + +export const Primary = Template.bind({}); + +Primary.args = { + children: "Add admin", +}; + +export default { title: "AddButton" }; + +export const Basic = (): React.ReactElement => ( + Add something +); diff --git a/src/components/AddButton/index.tsx b/src/components/AddButton/index.tsx index 16eb6c7..8bfbff9 100644 --- a/src/components/AddButton/index.tsx +++ b/src/components/AddButton/index.tsx @@ -9,7 +9,13 @@ const StyledButton = styled(Button)` border: dashed; `; -const AddButton: React.FC = ({ disabled, children, ...props }) => ( +export type AddButtonProps = ButtonProps; + +const AddButton: React.FC = ({ + disabled, + children, + ...props +}) => ( {!disabled && } {children} diff --git a/src/components/AdminContact/index.stories.jsx b/src/components/AdminContact/index.stories.jsx deleted file mode 100644 index 9c41263..0000000 --- a/src/components/AdminContact/index.stories.jsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import AdminContact from './index'; - -export default { title: 'AdminContact' }; - -export const Basic = () => ( -
- - - - - -
-); diff --git a/src/components/AdminContact/index.stories.tsx b/src/components/AdminContact/index.stories.tsx new file mode 100644 index 0000000..9918553 --- /dev/null +++ b/src/components/AdminContact/index.stories.tsx @@ -0,0 +1,76 @@ +import AdminContact, { AdminContactProps } from "./index"; +import { Meta, Story } from "@storybook/react"; + +import React from "react"; + +//👇 We create a "template" of how args map to rendering +const Template: Story = (args) => ; + +export const Primary = Template.bind({}); + +Primary.args = { + firstName: "Alfred", + lastName: "Nobel", + email: "alfred@nobel.org", + superAdmin: false, + // status: ["LOADING", "REQUESTED", "RECEIVED"], +}; + +export default { + title: "AdminContact", + component: AdminContact, + //👇 Creates specific argTypes + argTypes: { + status: { + control: { + type: "select", + options: [undefined, "LOADING", "REQUESTED", "VERIFIED"], + }, + }, + }, +} as Meta; + +export const Basic = (): React.ReactElement => ( + <> + new Promise((res) => setTimeout(res, 1000))} + /> + + + + + + + +); diff --git a/src/components/AdminContact/index.tsx b/src/components/AdminContact/index.tsx index 5f2001d..503cd36 100644 --- a/src/components/AdminContact/index.tsx +++ b/src/components/AdminContact/index.tsx @@ -6,7 +6,7 @@ import { InputGroup, Spinner, } from "react-bootstrap"; -import { Formik, setNestedObjectValues } from "formik"; +import { Formik, FormikErrors } from "formik"; import React from "react"; import styled from "styled-components"; @@ -41,9 +41,11 @@ interface AdminContactFields { superAdmin: boolean; } -interface AdminContactProps extends Partial { - status?: "VERIFIED" | "REQUESTED" | "LOADING"; - initialErrors?: any; +type Status = "VERIFIED" | "REQUESTED" | "LOADING"; + +export interface AdminContactProps extends Partial { + status?: Status; + initialErrors?: FormikErrors; onSubmit?: (values: AdminContactFields) => Promise; } @@ -59,19 +61,11 @@ const AdminContact: React.FC = ({ { - setSubmitting(true); - if (onSubmit) - onSubmit(values) - .then(() => { - setValues({ firstName, lastName, email, superAdmin }); - setSubmitting(false); - }) - .catch(() => { - setErrors({ email: "already exists" }); - setSubmitting(false); - }); - }} + onSubmit={(values, { setErrors }) => + onSubmit?.(values).catch(() => { + setErrors({ email: "already exists" }); + }) + } > {({ handleChange, values, handleSubmit, isSubmitting, errors }) => (
@@ -84,12 +78,14 @@ const AdminContact: React.FC = ({ name="firstName" required type="text" + isInvalid={Boolean(errors.firstName)} placeholder="Förnamn" /> = ({ type="submit" disabled={isSubmitting || Boolean(status)} > - {(status === "loading" || isSubmitting) && ( + {(status === "LOADING" || isSubmitting) && ( <> {" "} 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/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/Chapter/index.stories.tsx b/src/components/Chapter/index.stories.tsx new file mode 100644 index 0000000..023241e --- /dev/null +++ b/src/components/Chapter/index.stories.tsx @@ -0,0 +1,18 @@ +import Chapter from "./index"; +import React from "react"; + +export default { + title: "Chapter", +}; + +export const withText = (): React.ReactElement => ( + +); diff --git a/src/components/Chapter/index.tsx b/src/components/Chapter/index.tsx new file mode 100644 index 0000000..453964c --- /dev/null +++ b/src/components/Chapter/index.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import ReactMarkdown from "react-markdown"; +import styled from "styled-components"; + +interface ChapterProps { + title: string; // title of the chapter + subtitle: string; // subtitle of the chapter, used for hints like "Max 2 pages" + description: string; // Description will be rendered with Markdown +} + +const StyledDiv = styled.div` + & > div { + margin-top: 1.5rem; + } + hr { + color: #b8b8b8; + } +`; + +const Chapter: React.FC = ({ + title, + subtitle, + description, + children, +}) => ( + +

{title}

+

{subtitle}

+ +
{children}
+
+
+); + +export default Chapter; diff --git a/src/components/ContactPerson/index.stories.tsx b/src/components/ContactPerson/index.stories.tsx new file mode 100644 index 0000000..9bcec43 --- /dev/null +++ b/src/components/ContactPerson/index.stories.tsx @@ -0,0 +1,28 @@ +import ContactPerson from "./index"; +import React from "react"; +import moment from "moment"; + +export default { title: "ContactPerson" }; + +export const ThreePeople = (): React.ReactElement => ( + <> + + + + + + + +); diff --git a/src/components/ContactPerson/index.tsx b/src/components/ContactPerson/index.tsx new file mode 100644 index 0000000..e451348 --- /dev/null +++ b/src/components/ContactPerson/index.tsx @@ -0,0 +1,117 @@ +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) 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/features/auth/login/CopyLoginCode.tsx b/src/components/CopyLoginCode.tsx similarity index 79% rename from src/features/auth/login/CopyLoginCode.tsx rename to src/components/CopyLoginCode.tsx index 7351c60..4e711ef 100644 --- a/src/features/auth/login/CopyLoginCode.tsx +++ b/src/components/CopyLoginCode.tsx @@ -4,7 +4,13 @@ import { CopyToClipboard } from "react-copy-to-clipboard"; import { useTranslation } from "react-i18next"; interface CopyLoginCodeProps { + /** + * Code that should be copied + */ code: string; + /** + * When the user clicks on the code and it has been copied + */ onCopy?: () => void; } @@ -13,11 +19,13 @@ const CopyLoginCode = ({ onCopy, }: CopyLoginCodeProps): React.ReactElement => { const [copied, setCopied] = useState(false); + // translate the messages const { t } = useTranslation(); return ( { + // set copied to true to change the text and hide the code setCopied(true); if (onCopy) onCopy(); }} diff --git a/src/components/CustomSurvey/CustomSurveyForm.tsx b/src/components/CustomSurvey/CustomSurveyForm.tsx new file mode 100644 index 0000000..af8b01e --- /dev/null +++ b/src/components/CustomSurvey/CustomSurveyForm.tsx @@ -0,0 +1,59 @@ +import { CustomSurveyAnswer, CustomSurveyQuestion } from "types/survey"; +import React, { useState } from "react"; + +import Button from "react-bootstrap/Button"; +import Question from "./CustomSurveyQuestion"; +import Spinner from "react-bootstrap/Spinner"; +import { useTranslation } from "react-i18next"; + +export interface CustomSurveyFormProps { + config: CustomSurveyQuestion[]; + initialValues?: Record; + 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} + + + ); +} + +export default CustomSurveyForm; diff --git a/src/components/CustomSurvey/CustomSurveyQuestion.tsx b/src/components/CustomSurvey/CustomSurveyQuestion.tsx new file mode 100644 index 0000000..1b8f3a9 --- /dev/null +++ b/src/components/CustomSurvey/CustomSurveyQuestion.tsx @@ -0,0 +1,97 @@ +import { CustomSurveyAnswer, CustomSurveyQuestion } from "types/survey"; + +import Form from "react-bootstrap/Form"; +import React from "react"; +import { useTranslation } from "react-i18next"; + +interface QuestionProps { + question: CustomSurveyQuestion; + value: CustomSurveyAnswer; + disabled?: boolean; +} + +function Question({ + question, + value, + disabled, +}: QuestionProps): React.ReactElement { + const { t } = useTranslation(); + switch (question.type) { + case "RANGE": + return ( + + + {t(`chapters.SURVEY.questions.${question.id}.label`)} + +
+ + {t(`chapters.SURVEY.questions.${question.id}.low`)} + + {[...Array(question.range[1] - question.range[0] + 1)].map( + (_, i) => ( + + ) + )} + + {t(`chapters.SURVEY.questions.${question.id}.high`)} + +
+
+ ); + case "TEXT": + return ( + + + {t(`chapters.SURVEY.questions.${question.id}`)} + + 256 ? "textarea" : "input"} + defaultValue={value} + required + name={question.id} + disabled={disabled} + /> + + ); + case "SELECT": + return ( + + + {t(`chapters.SURVEY.questions.${question.id}.label`)} + + + + {question.options.map((option, i) => ( + + ))} + + + ); + default: + return <>; + } +} +export default Question; diff --git a/src/components/CustomSurvey/index.stories.tsx b/src/components/CustomSurvey/index.stories.tsx new file mode 100644 index 0000000..6dff6dd --- /dev/null +++ b/src/components/CustomSurvey/index.stories.tsx @@ -0,0 +1,76 @@ +import { CustomSurveyAnswer, CustomSurveyQuestion } from "types/survey"; + +import CustomSurvey from "./index"; +import CustomSurveyForm from "./CustomSurveyForm"; +import React from "react"; +import { action } from "@storybook/addon-actions"; + +export default { + title: "CustomSurvey", +}; + +const onSubmit = (values: Record): Promise => + new Promise((res) => { + action("values")(values); + setInterval(res, 1000); + }); + +const config: CustomSurveyQuestion[] = [ + { + 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", + }, +]; + +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/components/CustomSurvey/index.tsx b/src/components/CustomSurvey/index.tsx new file mode 100644 index 0000000..d5bf8ae --- /dev/null +++ b/src/components/CustomSurvey/index.tsx @@ -0,0 +1,51 @@ +import CustomSurveyForm, { CustomSurveyFormProps } from "./CustomSurveyForm"; + +import Accordion from "react-bootstrap/Accordion"; +import Button from "react-bootstrap/Button"; +import Card from "react-bootstrap/Card"; +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; + } +`; + +export type CustomSurveyAccordionProps = CustomSurveyFormProps; + +const CustomSurveyAccordion = ( + props: CustomSurveyAccordionProps +): React.ReactElement => { + const { t } = useTranslation(); + const done = props.initialValues ? "done" : ""; + return ( + + + + + {t("Open (verb)")} + + + + + + + + + + ); +}; + +export default CustomSurveyAccordion; diff --git a/src/components/GradingData/index.stories.jsx b/src/components/GradingData/index.stories.jsx deleted file mode 100644 index 67c8160..0000000 --- a/src/components/GradingData/index.stories.jsx +++ /dev/null @@ -1,51 +0,0 @@ -import React from 'react'; -import GradingData from './index'; - -export default { title: 'GradingData' }; - -export const Basic = () => ( -
- -
-); diff --git a/src/components/GradingData/index.stories.tsx b/src/components/GradingData/index.stories.tsx new file mode 100644 index 0000000..39def92 --- /dev/null +++ b/src/components/GradingData/index.stories.tsx @@ -0,0 +1,48 @@ +import GradingData from "./index"; +import React from "react"; + +export default { title: "GradingData" }; + +export const Basic = (): React.ReactElement => ( + +); 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.stories.jsx b/src/components/GradingModal/index.stories.jsx deleted file mode 100644 index 900fe72..0000000 --- a/src/components/GradingModal/index.stories.jsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from "react"; - -import { action } from "@storybook/addon-actions"; -import GradingModal from "./index"; - -export default { title: "GradingModal" }; - -export const Basic = () => ( -
- { - const errors = {}; - [ - "cv", - "coverLetter", - "essay", - "grades", - "recommendation", - "overall", - ].forEach((key) => { - if (values[key] === 0) errors[key] = true; - }); - if (errors) { - action("errors")(errors); - setErrors(errors); - } - setSubmitting(true); - setTimeout(() => { - setSubmitting(false); - }, 1000); - action("submit")(values); - }} - /> -
-); diff --git a/src/components/GradingModal/index.stories.tsx b/src/components/GradingModal/index.stories.tsx new file mode 100644 index 0000000..574a773 --- /dev/null +++ b/src/components/GradingModal/index.stories.tsx @@ -0,0 +1,14 @@ +import GradingModal from "./index"; +import React from "react"; +import { action } from "@storybook/addon-actions"; + +export default { title: "GradingModal" }; + +export const Basic = (): React.ReactElement => ( + + new Promise(() => setInterval(() => action("submit")(values), 1000)) + } + /> +); diff --git a/src/components/GradingModal/index.tsx b/src/components/GradingModal/index.tsx index dcab5bd..112d76a 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 = ({ @@ -95,27 +95,27 @@ const GradingModal: React.FC = ({

{name}

{ + /** + * 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/Logo.tsx b/src/components/Logo.tsx index 3fa1b35..4f8b7c1 100644 --- a/src/components/Logo.tsx +++ b/src/components/Logo.tsx @@ -1,6 +1,5 @@ -import { Properties as CSSProperties } from "csstype"; 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/features/nomatch/index.tsx b/src/components/NoMatch.tsx similarity index 56% rename from src/features/nomatch/index.tsx rename to src/components/NoMatch.tsx index 2c25667..db42113 100644 --- a/src/features/nomatch/index.tsx +++ b/src/components/NoMatch.tsx @@ -1,16 +1,16 @@ +import AnimatedStar from "./AnimatedStar"; +import Center from "./Center"; +import Plate from "./Plate"; import React from "react"; -import Center from "components/Center"; -import Plate from "components/Plate"; -import Star from "components/Star"; -import StyledTitle from "components/StyledTitle"; +import StyledTitle from "./StyledTitle"; import { Trans } from "react-i18next"; -const NoMatch = () => ( +const NoMatch = (): React.ReactElement => (
404 - * + *
No page was found on this URL. diff --git a/src/components/OpenPDF/index.stories.tsx b/src/components/OpenPDF/index.stories.tsx new file mode 100644 index 0000000..e3acd5d --- /dev/null +++ b/src/components/OpenPDF/index.stories.tsx @@ -0,0 +1,12 @@ +import OpenPDF from "./index"; +import React from "react"; + +export default { title: "OpenPDF" }; + +export const Button = (): React.ReactElement => ( + new Promise((res) => setInterval(() => res(), 1000))} + > + Open + +); diff --git a/src/components/OpenPDF/index.tsx b/src/components/OpenPDF/index.tsx new file mode 100644 index 0000000..d6ad650 --- /dev/null +++ b/src/components/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/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; diff --git a/src/components/Rating/index.stories.jsx b/src/components/Rating/index.stories.jsx deleted file mode 100644 index faeb67c..0000000 --- a/src/components/Rating/index.stories.jsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import Rating from './index'; - -export default { title: 'Rating' }; - -export const Basic = () => ( -
- -
-); diff --git a/src/components/Rating/index.stories.tsx b/src/components/Rating/index.stories.tsx new file mode 100644 index 0000000..876a2de --- /dev/null +++ b/src/components/Rating/index.stories.tsx @@ -0,0 +1,6 @@ +import Rating from "./index"; +import React from "react"; + +export default { title: "Rating" }; + +export const Basic = (): React.ReactElement => ; diff --git a/src/components/Rating/index.tsx b/src/components/Rating/index.tsx index 098e7cd..d903889 100644 --- a/src/components/Rating/index.tsx +++ b/src/components/Rating/index.tsx @@ -19,7 +19,9 @@ const Star = styled(Icon)` margin-top: -3px; `; -const Rating = (props: RatingComponentProps) => ( +export type RatingProps = RatingComponentProps; + +const Rating = (props: RatingProps): React.ReactElement => ( } diff --git a/src/components/StyledGroup/index.stories.jsx b/src/components/StyledGroup/index.stories.jsx deleted file mode 100644 index 96b8cb7..0000000 --- a/src/components/StyledGroup/index.stories.jsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; -import Form from 'react-bootstrap/Form'; -import StyledGroup from './index'; - -export default { - title: 'StyledGroup' -}; - -export const withText = () => ( -
- - - E-mail - - -); diff --git a/src/components/StyledGroup/index.stories.tsx b/src/components/StyledGroup/index.stories.tsx new file mode 100644 index 0000000..48604d8 --- /dev/null +++ b/src/components/StyledGroup/index.stories.tsx @@ -0,0 +1,15 @@ +import FormControl from "react-bootstrap/FormControl"; +import FormLabel from "react-bootstrap/FormLabel"; +import React from "react"; +import StyledGroup from "./index"; + +export default { + title: "StyledGroup", +}; + +export const Basic = (): React.ReactElement => ( + + + E-mail + +); diff --git a/src/components/StyledGroup/index.jsx b/src/components/StyledGroup/index.tsx similarity index 63% rename from src/components/StyledGroup/index.jsx rename to src/components/StyledGroup/index.tsx index 1cc4795..567ab6e 100644 --- a/src/components/StyledGroup/index.jsx +++ b/src/components/StyledGroup/index.tsx @@ -1,8 +1,17 @@ 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 defaultProps = { + x: 0.75, + y: 1.5, +}; + +const StyledGroup = styled(Form.Group)` & { position: relative; margin-bottom: 1rem; @@ -14,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 { @@ -58,27 +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, -}; - -StyledGroup.propTypes = { - x: PropTypes.number, - y: PropTypes.number, -}; - export default StyledGroup; 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/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}; diff --git a/src/components/Survey/index.stories.jsx b/src/components/Survey/index.stories.jsx deleted file mode 100644 index 03f9b09..0000000 --- a/src/components/Survey/index.stories.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from "react"; -import Plate from "components/Plate"; -import { Form, Button } from "react-bootstrap"; -import MaskedInput from "react-maskedinput"; -import Survey from "./index"; - -export default { - title: "Survey", - decorators: [ - (Story) => ( -
- - - -
- ), - ], -}; - -export const withText = () => Hey!; - -export const MaskedInputField = () => ( -
{ - e.preventDefault(); - }} - > - - Birthdate - ( - - )} - /> - - - -); 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 => ; diff --git a/src/components/Survey/index.tsx b/src/components/Survey/index.tsx index fc7a84c..1c54976 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,30 +42,17 @@ 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: ( - surveyAnswers: SurveyAnswers, - helpers: { - setSubmitting: (isSubmitting: boolean) => void; - } - ) => Promise; + onSubmit: (surveyAnswers: SurveyAnswers) => Promise; disabled?: boolean; } -const Survey = ({ survey, onSubmit, disabled }: SurveyProps) => { +const Survey = ({ + survey, + onSubmit, + disabled, +}: SurveyProps): React.ReactElement => { const { t } = useTranslation(); const initialValues = { @@ -89,7 +77,10 @@ const Survey = ({ survey, onSubmit, disabled }: SurveyProps) => { { + onSubmit={( + { gender, ...values }, + { setSubmitting, setErrors } + ) => { let processError, portalError, genderError = false; @@ -97,20 +88,17 @@ const Survey = ({ survey, onSubmit, disabled }: SurveyProps) => { 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} @@ -184,7 +172,7 @@ const Survey = ({ survey, onSubmit, disabled }: SurveyProps) => { disabled={disabled} > { if (disabled) return; else { @@ -208,7 +196,7 @@ const Survey = ({ survey, onSubmit, disabled }: SurveyProps) => { disabled={disabled} > { if (disabled) return; else { diff --git a/src/components/portal/Upload/index.stories.tsx b/src/components/Upload/index.stories.tsx similarity index 70% rename from src/components/portal/Upload/index.stories.tsx rename to src/components/Upload/index.stories.tsx index ec560c9..604970b 100644 --- a/src/components/portal/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 => ( ` +const TranslatedFormControl = styled(FormControl)` ${(props) => props ? `& ~ .custom-file-label::after {content: "${ @@ -73,30 +25,53 @@ const StyledFormControl = styled(FormControl)` }`} `; -export interface MostUploadProps { +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; } -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, @@ -105,12 +80,10 @@ const Upload: React.FC = ({ onCancel, uploaded, uploading, - displayFileName, accept, error, uploadLabel, disabled, - multiple, }) => { const [fileName, updateFileName] = useState(""); const [downloading, setDownloading] = useState(false); @@ -128,16 +101,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(); @@ -147,11 +115,11 @@ const Upload: React.FC = ({ const newLabel = ( <> - {!uploading && !uploaded && (displayFileName ? fileName || label : label)} + {!uploading && !uploaded && label} {uploading && ( - Laddar upp{" "} - {fileName || uploaded} + {" "} + {t("Laddar upp")} {fileName || uploaded} )} {!uploading && uploaded && ( @@ -185,7 +153,7 @@ const Upload: React.FC = ({
{newLabel}
) : (
- = ({ accept={accept} isInvalid={Boolean(error)} label={uploadLabel} - multiple={multiple} /> {newLabel}
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/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/Chapter/index.stories.jsx b/src/components/portal/Chapter/index.stories.jsx deleted file mode 100644 index fb3aff4..0000000 --- a/src/components/portal/Chapter/index.stories.jsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import { UploadHook } from 'components/portal/Upload/index.stories'; -import Plate from 'components/Plate'; -import Chapter from './index'; - -export default { - title: 'Chapter', - decorators: [(Story) => ( -
- - - -
- ), - ], -}; - -export const withText = () => ( - - )} - /> -); 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/ContactPerson/index.stories.jsx b/src/components/portal/ContactPerson/index.stories.jsx deleted file mode 100644 index 106071e..0000000 --- a/src/components/portal/ContactPerson/index.stories.jsx +++ /dev/null @@ -1,38 +0,0 @@ -import ContactPerson from "./index"; -import React from "react"; -import moment from "moment"; - -export default { title: "ContactPerson" }; - -export const ThreePeople = () => ( -
- - - - - - -
-); 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.stories.jsx b/src/components/portal/OpenPDF/index.stories.jsx deleted file mode 100644 index 0c409c0..0000000 --- a/src/components/portal/OpenPDF/index.stories.jsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import OpenPDF from './index'; - -export default { title: 'OpenPDF' }; - -export const Button = () => ( -
- Open -
-); 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 diff --git a/src/config/portal.json b/src/config/portal.json index e1f2752..83b1687 100644 --- a/src/config/portal.json +++ b/src/config/portal.json @@ -1,49 +1,91 @@ { - "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", + "type": "FILES", + "id": "CV", "upload": { - "label": "Ladda upp CV", + "multiple": 1, "accept": ".pdf" } }, { - "fileType": "COVER_LETTER", + "type": "FILES", + "id": "COVER_LETTER", "upload": { - "label": "Ladda upp personligt brev", + "multiple": 1, "accept": ".pdf" } }, { - "fileType": "ESSAY", + "type": "FILES", + "id": "ESSAY", "upload": { - "label": "Ladda upp essĂ€svar", + "multiple": 1, "accept": ".pdf" } }, { - "fileType": "GRADES", + "type": "FILES", + "id": "GRADES", "upload": { - "label": "Ladda upp betyg", + "multiple": 1, "accept": ".pdf" } }, { - "fileType": "survey", - "survey": true - }, - { - "fileType": "APPENDIX", + "type": "FILES", + "id": "APPENDIX", "upload": { - "multiple": true, + "multiple": 5, "accept": ".pdf" } }, { - "fileType": "RECOMMENDATION_LETTER", - "contactPeople": true + "type": "SURVEY", + "id": "SURVEY", + "questions": [ + { + "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" + } + ] + }, + { + "id": "RECOMMENDATION_LETTER", + "type": "RECOMMENDATION_LETTER", + "max": 3 } - ] + ], + "deadline": 1617238799000 } diff --git a/src/config/portal_en.json b/src/config/portal_en.json new file mode 100644 index 0000000..1a2bc46 --- /dev/null +++ b/src/config/portal_en.json @@ -0,0 +1,82 @@ +{ + "title": "Application for Rays 2021", + "introduction": "Thank you for your interest in applying to Rays! We will be reading all applications and respond to you as we can. Below you can find the information we want you to send. On the **March 31st at 23:59** you will no longer be able to edit your application and it will automatically be sent to Rays. For your application to be sent you must have uplaoded all files and filled in the survey. Until this date you can update any part by simply uploading a new file again. Your old file will be replaced by the new one. All files must be in PDF-format and the specific requirements for word count can be located in each part. The file limit is 5 MB per part.\n\nWe who arrange Rays wish you the very best of luck and look forward to reading your application! [For more information please check the website!](http://raysforexcellence.se/ansok/)", + "chapters": { + "COVER_LETTER": { + "title": "Personal letter", + "subtitle": "Maximum 600 words", + "description": "We who arrange Rays want to get to know you applicants as well as possible. In your personal letter, we want you to tell us about your interests and why you are applying to Rays. We want to hear about where your passion for science comes from and how your previous experiences have shaped you.", + "upload": { + "label": "Upload cover letter" + } + }, + "CV": { + "title": "CV", + "subtitle": "Maximum 2 pages", + "description": "Apart from basic information, we recommend you to include descriptions of competitions you have partaken in (both scientific and athletic), awards won at school or in other contexts, experience of voluntary work and holding positions of trust.", + "upload": { + "label": "Upload CV" + } + }, + "ESSAY": { + "title": "Essays", + "subtitle": "Maximum 300 words on each essay", + "description": "1. Name two or three subjects in science, technology or mathematics that interest you especially, and tell us why you like about them. You may be very specific if you want. We will use your answer when matching students to mentors.\n2. Choose and answer **one** of the following questions:\n * Tell us about something you have done, which you think demonstrates your potential to become a future leader within the natural sciences, technology or mathematics.\n * Tell us about something or someone who inspires you. How have/has they/it influenced you, your goals, your dreams for the future and how you perceive the surroundings?\n * Describe how you work or have worked to develop one of your personal qualities and how that has helped you or is helping you?\n * Tell us about a challenge you have solved / want to solve. It might be an intellectual challenge, a research question, or an ethical dilemma – something that you care about, no matter the scope. Explain its importance to you and what challenges you have faced or would have to face and how you dealt with or would deal with these.\n\n**You need to have the answer to both questions in the same PDF.**", + "upload": { + "label": "Upload essays" + } + }, + "GRADES": { + "title": "Grades", + "subtitle": "", + "description": "Please scan your grades for all completed high school courses and attach them to your application. The document should be available from the school office and should be **signed by the headmaster or your responsible teacher**.", + "upload": { + "label": "Upload grades" + } + }, + "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.", + "questions": { + "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": { + "label": "What are your thoughts on the application portal?", + "low": "Very bad", + "high": "Very good" + }, + "applicationProcess": { + "label": "What are your thoughts on the application process?", + "low": "Very bad", + "high": "Very good" + }, + "improvement": "How can the application process and portal be improved?", + "informant": "How did you hear about Rays?" + } + }, + "RECOMMENDATION_LETTER": { + "title": "Letter of recommendation", + "subtitle": "", + "description": "Teachers, coaches and others could address questions such as how their student handles challenges or responsibility, and why the student has the potential of a future leader within research. Letters of Recommendation should be composed, signed and sent by the teacher/coach. [More information can be found here.](https://raysforexcellence.se/rekommendationsbev)\n\nBy submitting an email below, a link will be sent to the recipient who can upload their letter of recommendation. You will be able to resend and change the email as long as the recipient has not uploaded their letter. As soon as the recipient has uploaded the letter, it is locked to your application. \n\n**You should preferably submit at least 1 and max 3 letters of recommendation.**" + }, + "APPENDIX": { + "title": "Appendix", + "subtitle": "", + "description": "Below you can add up to five appendix files. They need to be PDFs and each file can be a maximum of 5 MB. **This is not necessary but has been requested by some people.**", + "upload": { + "label": "Upload appendices" + } + } + }, + "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 new file mode 100644 index 0000000..165c71b --- /dev/null +++ b/src/config/portal_sv.json @@ -0,0 +1,82 @@ +{ + "title": "Ansökan för Rays 2021", + "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. Filstorleken Ă€r 5 MB per del.\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": { + "COVER_LETTER": { + "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.", + "upload": { + "label": "Ladda upp personligt brev" + } + }, + "CV": { + "title": "CV", + "subtitle": "Max 2 sidor", + "description": "Förutom grundlĂ€ggande information rekommenderas ditt CV innehĂ„lla en beskrivning av exempelvis deltagande i sĂ„vĂ€l naturvetenskapliga som idrottsliga tĂ€vlingar, utmĂ€rkelser i skolan eller andra sammanhang, samt ideellt arbete och förtroendeuppdrag.", + "upload": { + "label": "Ladda upp CV" + } + }, + "ESSAY": { + "title": "EssĂ€svar", + "subtitle": "Max 300 ord pĂ„ vardera", + "description": "1. Ange tvĂ„ eller tre naturvetenskapliga, tekniska, eller matematiska Ă€mnen du tycker om och berĂ€tta varför. Du fĂ„r gĂ€rna vara specifik. Vi kommer anpassa ditt forskningsprojekt till de intressen du beskriver i denna frĂ„ga.\n2. VĂ€lj och besvara **en** av nedanstĂ„ende frĂ„gor:\n * BerĂ€tta om nĂ„got du gjort som du anser demonstrerar din potential att bli en ledande forskare inom naturvetenskap, teknik och/eller matematik.\n * BerĂ€tta om nĂ„got eller nĂ„gon som inspirerar dig. Hur har det/den/de pĂ„verkat dig, dina mĂ„l, dina drömmar om framtiden och hur du uppfattar din omgivning?\n * Beskriv hur du jobbar/har jobbat för att utveckla nĂ„gon av dina egenskaper och hur det har hjĂ€lpt/hjĂ€lper dig?\n * Beskriv ett problem du har löst eller en utmaning du vill lösa. Det kan vara en intellektuell utmaning, en forskningsfrĂ„ga, ett etiskt dilemma – nĂ„got som du bryr dig om, oavsett omfattningen. Förklara dess betydelse för dig och vilka utmaningar du stĂ€llts/skulle stĂ€llas inför och hur du löste/skulle lösa detta problem.\n\n**Du ska svara pĂ„ bĂ„da frĂ„gor i en och samma pdf.**", + "upload": { + "label": "Ladda upp essĂ€svar" + } + }, + "GRADES": { + "title": "Betyg", + "subtitle": "", + "description": "Scanna in och bifoga slutbetyg för de gymnasiekurser du avslutat. Detta gĂ„r att fĂ„ ifrĂ„n skolans expedition och ska vara **signerat av rektor eller ansvarig lĂ€rare**.", + "upload": { + "label": "Ladda upp betyg" + } + }, + "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.", + "questions": { + "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": { + "label": "Vad tycker du om ansökningsportalen?", + "low": "VĂ€ldigt dĂ„lig", + "high": "VĂ€ldigt bra" + }, + "applicationProcess": { + "label": "Vad tycker du om ansökningsprocessen?", + "low": "VĂ€ldigt dĂ„lig", + "high": "VĂ€ldigt bra" + }, + "improvement": "Hur kan ansökningsprocessen och portalen förbĂ€ttras?", + "informant": "Hur hörde du talas om Rays?" + } + }, + "RECOMMENDATION_LETTER": { + "title": "Rekommendationsbrev", + "subtitle": "", + "description": "Rekommendationsbrev frĂ„n lĂ€rare, trĂ€nare eller liknande. Exempel pĂ„ frĂ„gor som kan behandlas i en rekommendation Ă€r hur eleven hanterar utmaningar och ansvar, och varför eleven har potential att bli en framtida ledare inom forskning. Rekommendationsbrev skall komponeras, signeras och skickas av lĂ€raren, trĂ€naren eller liknande. [Mer info hittas hĂ€r.](https://raysforexcellence.se/rekommendationsbev)\n\nGenom att skriva in en e-post nedan kommer en lĂ€nk att skickas till mottagaren som kan ladda upp sitt rekommendationsbrev. Du kommer att kunna skicka om och Ă€ndra emailet sĂ„ lĂ€nge mottagaren inte har laddat upp sitt brev. SĂ„ fort mottagaren har laddat upp brevet Ă€r den lĂ„st till din ansökan.\n\n**Du bör helst skicka in minst 1 och max 3 rekommendationsbrev.**" + }, + "APPENDIX": { + "title": "Bilagor", + "subtitle": "", + "description": "HĂ€r kan du ladda upp maximalt fem bilagor. Filerna behöver vara i PDF och varje fil kan maximalt vara 5 MB stor. **Detta Ă€r inte nödvĂ€ndigt men har efterfrĂ„gats av nĂ„gra.**", + "upload": { + "label": "Ladda upp bilagor" + } + } + }, + "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)." +} 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/index.tsx b/src/features/admin/AdminView.tsx similarity index 79% rename from src/features/admin/index.tsx rename to src/features/admin/AdminView.tsx index afef554..fd00ed0 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 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 TopList = lazy(() => import("./TopList")); +const NoMatch = lazy(() => import("components/NoMatch")); +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 54% rename from src/features/admin/Administration/index.tsx rename to src/features/admin/Administration.tsx index 97e595e..3cbc0f0 100644 --- a/src/features/admin/Administration/index.tsx +++ b/src/features/admin/Administration.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 "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/ApplicantInformation.tsx b/src/features/admin/ApplicantInformation.tsx new file mode 100644 index 0000000..63347d8 --- /dev/null +++ b/src/features/admin/ApplicantInformation.tsx @@ -0,0 +1,38 @@ +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, + applicantID, +}: ApplicantInformationProps): React.ReactElement { + const { loading } = useFiles(applicantID); + return ( +
+ Email: + {email} + {loading ? ( +
Loading
+ ) : ( + portal.chapters.map((chapter) => + chapter.upload ? ( + + ) : null + ) + )} +
+ ); +} + +export default ApplicantInformation; diff --git a/src/features/admin/Grading/index.tsx b/src/features/admin/GradingView.tsx similarity index 69% rename from src/features/admin/Grading/index.tsx rename to src/features/admin/GradingView.tsx index d864b7d..d1b9759 100644 --- a/src/features/admin/Grading/index.tsx +++ b/src/features/admin/GradingView.tsx @@ -1,60 +1,48 @@ -import "./grading.css"; +import "./table.css"; import { ConnectedProps, connect } from "react-redux"; +import { getApplications, getGradingOrder } from "api/admin"; import { - OrderItem, selectApplicationsByGradingOrder, setApplications, updateGradingOrder, -} from "../adminSlice"; +} from "./adminSlice"; +import ApplicantInformation from "./ApplicantInformation"; +import { Application } from "types/grade"; import BootstrapTable from "react-bootstrap-table-next"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import Grade from "./Grade"; -import OpenPDF from "components/portal/OpenPDF"; -import RandomiseOrder from "./RandomiseOrder"; +import OpenGradingModalButton from "./OpenGradingModalButton"; +import OpenPDF from "components/OpenPDF"; +import RandomiseGradingOrder from "./RandomiseGradingOrder"; import React from "react"; import { RootState } from "store"; import Spinner from "react-bootstrap/Spinner"; -import axios from "axios"; +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() { 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); - this.setState({ loading: [this.state.loading[0], false] }); + getGradingOrder().then((res) => { + this.props.updateGradingOrder(res); + this.setState({ loading: false }); }); } } render() { - const loading = this.state.loading[0] || this.state.loading[1]; const dataWithIndex = this.props.applications.map((application, index) => ({ ...application, index, @@ -76,10 +64,10 @@ class Grading extends React.Component { { dataField: "id", text: "PDF", - formatter: (id: string, row: any) => ( + formatter: (id: string, row: Application) => ( downloadAndOpen(id)} > @@ -89,8 +77,8 @@ class Grading extends React.Component { dataField: "dummy_field", text: "Bedöm", isDummyField: true, - formatter: (id: string, row: any) => ( - ( + { }, ]; + const expandRow = { + renderer: (row: Application) => ( + + ), + showExpandColumn: true, + expandByColumnOnly: true, + className: "white", + }; + return (
@@ -106,9 +103,10 @@ class Grading extends React.Component { För att börja bedöma eller se nya ansökningar behöver du slumpa ordningen.

- +
{ data={dataWithIndex} columns={columns} noDataIndication={() => - this.state.loading[1] ? ( + this.state.loading ? ( - 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) { 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); }); } @@ -58,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/Grading/RandomiseOrder.tsx b/src/features/admin/RandomiseGradingOrder.tsx similarity index 84% rename from src/features/admin/Grading/RandomiseOrder.tsx rename to src/features/admin/RandomiseGradingOrder.tsx index 3f93ec6..8f2b4ef 100644 --- a/src/features/admin/Grading/RandomiseOrder.tsx +++ b/src/features/admin/RandomiseGradingOrder.tsx @@ -1,9 +1,9 @@ import { Button, Spinner } from "react-bootstrap"; import React, { useState } from "react"; -import axios from "axios"; +import { randomiseOrder } from "api/admin"; import { toast } from "react-toastify"; -import { updateGradingOrder } from "../adminSlice"; +import { updateGradingOrder } from "./adminSlice"; import { useDispatch } from "react-redux"; import { useTranslation } from "react-i18next"; @@ -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/admin/Statistics.tsx b/src/features/admin/Statistics.tsx index e2ac18d..49071c7 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,25 +42,23 @@ function NumericalTable({ answers, title, isNumeric }: NumericalTableProps) {
{grade.firstName + " " + grade.lastName} {grade.cv} {grade.coverLetter}
{grade.firstName + " " + grade.lastName} {grade.comment}
- + {Object.keys(answers.count).map((n, i) => ( - {console.log(t(n), n)} ))} - {isNumeric && - (["average"] as StatisticalValue[]).map((key) => ( - - - - - ))} + {answers.average && ( + + + + + )}
{isNumeric ? "Betyg" : "Svar"}{answers.average ? "Betyg" : "Svar"} Antal
{t(n)} {answers.count[n]}
{t(key)}{Math.round(answers[key] * 100) / 100}
{t("average")}{answers.average}
@@ -157,7 +66,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 ( @@ -174,30 +83,35 @@ function StatisticsPage(): React.ReactElement { ); return (
- - - - - - - + {data && ( + <> + {/* + + + + + + */} + + )}
); } diff --git a/src/features/admin/TopList/index.tsx b/src/features/admin/TopList.tsx similarity index 83% rename from src/features/admin/TopList/index.tsx rename to src/features/admin/TopList.tsx index c2d65b2..ef90508 100644 --- a/src/features/admin/TopList/index.tsx +++ b/src/features/admin/TopList.tsx @@ -1,27 +1,20 @@ -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"; +import { GradedApplication } from "types/grade"; import GradingData from "components/GradingData"; -import OpenPDF from "components/portal/OpenPDF"; +import OpenPDF from "components/OpenPDF"; import React from "react"; import { RootState } from "store"; import Spinner from "react-bootstrap/Spinner"; -import axios from "axios"; +import { downloadAndOpen } from "api/downloadPDF"; import { faFileDownload } from "@fortawesome/free-solid-svg-icons"; -import { useGrades } from "../adminHooks"; - -interface GradingDataRowProps { - id: string; -} +import { getApplications } from "api/admin"; +import { useGrades } from "./adminHooks"; -const GradingDataRow = ({ id }: GradingDataRowProps) => { - console.log("render grading"); +const GradingDataRow = ({ id }: Pick) => { const { data, loading } = useGrades(id); if (loading) return
Loading
; return ; @@ -38,8 +31,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 }); }); } @@ -84,7 +77,7 @@ class TopList extends React.Component { dataField: "id", text: "Visa", formatter: (id: string) => ( - + downloadAndOpen(id)}> ), @@ -103,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 2d0a9b2..558b468 100644 --- a/src/features/admin/adminHooks.ts +++ b/src/features/admin/adminHooks.ts @@ -1,58 +1,119 @@ +import { Admin, NewAdmin } from "types/user"; +import { + ApplicationGrade, + IndividualGrading, + IndividualGradingWithName, +} from "types/grade"; +import { Statistics, SurveyAnswers } from "types/survey"; +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"; import { useDispatch, useSelector } from "react-redux"; -import useAxios from "axios-hooks"; -import { useEffect } from "react"; +import average from "utils/average"; -interface UseGrades { - loading: boolean; - data: any; - error: any; -} +type UseGrades = ( + applicantId: string +) => UseApi & { + addMyGrade: (grades: ApplicationGrade) => Promise; +}; -export function useGrades(applicantId: string): UseGrades { - const admins = useAdmins(); - const [{ loading, data, error }] = useAxios( - `/application/${applicantId}/grade` +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) dispatch(setGrades({ grades: data, applicantId })); + if (data && Boolean(grades) === false) + 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; -} + return { loading, data: grades, error, addMyGrade }; +}; -interface AdminInfo { - id: string; - email: string; - firstName: string; - lastName: string; - type: "SUPER_ADMIN" | "ADMIN"; - verified: boolean; - created: string; +interface UseAdmins extends UseApi { + addAdmin: (admin: NewAdmin) => Promise; } -export function useAdmins(): UseGrades { - const [{ loading, data, error }] = useAxios({ +export function useAdmins(): UseAdmins { + const [{ loading, data, error }] = useApi({ url: "/admin", - params: { skip: 0, limit: 10 }, + 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); - 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 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) => { + Object.keys(answer).forEach((key) => { + if (typeof answer[key] === "string") statistics[key]; + }); + // 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 289a464..ce7e039 100644 --- a/src/features/admin/adminSlice.ts +++ b/src/features/admin/adminSlice.ts @@ -1,77 +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 Grade from "./Grading/Grade"; +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 = { @@ -86,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) ); @@ -94,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) + @@ -109,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; }); @@ -119,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 ); @@ -139,32 +92,28 @@ 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 | GradedApplication)[] => 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, lastName: state.admin.admins[grade.adminId]?.lastName, })); -export const selectMyGrading = ( - state: RootState, - id: string -): GradingData | undefined => { +export const selectMyGrading = (id: string) => ( + state: RootState +): IndividualGradingWithName | undefined => { const relevantGrades = state.admin.grades[id]; if (relevantGrades) { const myGrading = relevantGrades.find( @@ -189,7 +138,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/features/admin/Grading/grading.css b/src/features/admin/table.css similarity index 100% rename from src/features/admin/Grading/grading.css rename to src/features/admin/table.css diff --git a/src/features/auth/AuthenticatedLayer.tsx b/src/features/auth/AuthenticatedLayer.tsx index 2832e46..73e7327 100644 --- a/src/features/auth/AuthenticatedLayer.tsx +++ b/src/features/auth/AuthenticatedLayer.tsx @@ -1,12 +1,7 @@ import React, { useEffect } from "react"; -import { - authSuccess, - selectAuthenticated, - userInfoSuccess, -} from "features/auth/authSlice"; +import { selectAuthenticated, userInfoSuccess } from "features/auth/authSlice"; -import Axios from "axios"; -import { TokenStorage } from "utils/tokenInterceptor"; +import { getUser } from "api/user"; import { useDispatch } from "react-redux"; import { useSelector } from "react-redux"; @@ -20,10 +15,9 @@ 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/api.ts b/src/features/auth/api.ts index 87ac1de..6da9d2b 100644 --- a/src/features/auth/api.ts +++ b/src/features/auth/api.ts @@ -1,35 +1,19 @@ -import Axios, { AxiosResponse } from "axios"; -import { ServerTokenResponse, TokenStorage } from "utils/tokenInterceptor"; +import { authorizeWithEmailAndCode, authorizeWithToken } from "api/auth"; + +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/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/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/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/auth/login/LoginWithCodeRoute.tsx b/src/features/auth/login/LoginWithCodeRoute.tsx index 61c9ccc..fa3cfb4 100644 --- a/src/features/auth/login/LoginWithCodeRoute.tsx +++ b/src/features/auth/login/LoginWithCodeRoute.tsx @@ -11,8 +11,8 @@ 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" }); - else setErrors({ code: "fetch error" }); + if (err.params.Authorization) setErrors({ code: "Wrong code" }); + 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..a48e4c2 100644 --- a/src/features/auth/register/index.tsx +++ b/src/features/auth/register/index.tsx @@ -1,25 +1,27 @@ 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 hasApplicationClosed from "utils/hasApplicationClosed"; 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 +33,8 @@ const MaskedField = (props: MaskedFieldProps) => ( const Register: React.FC = ({ t }) => { const { push } = useHistory(); - const toastId = React.useRef(null); - const applicationHasClosed = - moment.utc().month(2).endOf("month").diff(Date.now()) < 0; + const showCode = useShowCode(); + const closed = hasApplicationClosed(); 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); }); }} > @@ -237,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 new file mode 100644 index 0000000..d6da4d7 --- /dev/null +++ b/src/features/files/Upload.tsx @@ -0,0 +1,112 @@ +import React, { useState } from "react"; + +import Upload from "components/Upload"; +import hasApplicationClosed from "utils/hasApplicationClosed"; +import { toast } from "react-toastify"; +import { useFiles } from "./filesHooks"; +import { useTranslation } from "react-i18next"; + +interface UploadHookProps { + accept?: string; + id: string; + applicantID?: string; + multiple?: number; + maxFileSize?: number; + alwaysAbleToUpload?: boolean; +} + +interface UploadingFileInfo { + uploading: boolean; + error?: string; + name: string; +} + +const UploadHook: React.FC = ({ + accept, + id, + maxFileSize = 5 * 10 ** 6, + multiple = 1, + alwaysAbleToUpload, + applicantID, +}) => { + const { t } = useTranslation(); + const { removeFile, data: files, addFile, loading, downloadFile } = useFiles( + applicantID, + id + ); + const [uploadingFiles, setUploadingFiles] = useState([]); + + const handleDelete = (fileID: string) => + removeFile(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 }]); + addFile(id, file, fileName) + .then(() => { + setUploadingFiles([]); + }) + .catch((err) => { + setUploadingFiles([ + { name: file.name, uploading: false, error: err.message }, + ]); + }); + } + }; + + const handleCancel = () => setUploadingFiles([]); + + const closed = hasApplicationClosed(); + const disabledUploading = (closed && !alwaysAbleToUpload) || loading; + const label = t(`chapters.${id}.upload.label`); + + return ( + <> + {files?.map((file) => ( + 1 || disabledUploading} + uploadLabel={t("Choose file")} + onDownload={() => downloadFile(file.id)} + onDelete={() => handleDelete(file.id)} + onChange={handleUpload} + /> + ))} + {uploadingFiles.map((file) => ( + + ))} + {(files?.length || 0) + uploadingFiles.length < multiple && ( + <> + + + )} + + ); +}; + +export default UploadHook; diff --git a/src/features/recommendation/index.tsx b/src/features/files/UploadRecommendationLetter.tsx similarity index 80% rename from src/features/recommendation/index.tsx rename to src/features/files/UploadRecommendationLetter.tsx index ef021f8..e2f1c18 100644 --- a/src/features/recommendation/index.tsx +++ b/src/features/files/UploadRecommendationLetter.tsx @@ -3,8 +3,8 @@ import React, { useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import CenterCard from "components/CenterCard"; -import Upload from "components/portal/Upload"; -import axios from "axios"; +import Upload from "components/Upload"; +import { uploadLetterOfRecommendation } from "api/recommendations"; import useAxios from "axios-hooks"; import { useParams } from "react-router-dom"; @@ -37,34 +37,46 @@ 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); + } + ); }} /> ); }; -const Recommendation = (): React.ReactElement => { - const { recommendationCode } = useParams<{ recommendationCode: string }>(); +interface UploadRecommendationLetterProps { + recommendationCode: string; +} - const [{ response, error, loading }] = useAxios( +export const UploadRecommendationLetter = ({ + recommendationCode, +}: UploadRecommendationLetterProps): React.ReactElement => { + const [{ response }] = useAxios( `/application/recommendation/${recommendationCode}` ); - const { t } = useTranslation(); + return ( + + ); +}; +const RecommendationCard = (): React.ReactElement => { + 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 +137,4 @@ const Recommendation = (): React.ReactElement => { ); }; -export default Recommendation; +export default RecommendationCard; diff --git a/src/features/files/filesHooks.ts b/src/features/files/filesHooks.ts new file mode 100644 index 0000000..ae22457 --- /dev/null +++ b/src/features/files/filesHooks.ts @@ -0,0 +1,100 @@ +import { + deleteFile, + downloadFile as downloadIndividualFile, + getFilesConfiguration, + uploadFile, +} from "api/files"; +import { + deleteFileSuccess, + replaceFile, + selectApplicantFilesLoaded, + selectFilesByIDAndApplicant, + 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 = ( + applicantID?: string, + type?: string +) => UseApi & { + removeFile: (fileID: string) => Promise; + addFile: ( + fileType: string, + file: File, + fileName: string, + replace?: boolean + ) => Promise; + downloadFile: (fileID: string) => Promise; +}; + +export const useFiles: UseFiles = (applicantID = "@me", type?: string) => { + 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; + + // if the function is not called with any file type, there are no files to get + const files = + type === undefined + ? undefined + : useSelector(selectFilesByIDAndApplicant(type, id)); + + // 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(() => { + type && dispatch(deleteFileSuccess([applicantID, type, fileID])); + }), + [dispatch, type] + ); + + // callback to upload a file and add it to the store + const addFile = useCallback( + (fileType, file, fileName, replace?: boolean) => + uploadFile(fileType, file, fileName).then((res) => { + // replace if nece + if (replace) dispatch(replaceFile(res)); + else dispatch(setFiles([res])); + }), + [dispatch, type] + ); + + const downloadFile = useCallback( + (fileID: string) => downloadIndividualFile(fileID, applicantID), + [applicantID] + ); + + return { + loading, + data: files, + removeFile, + addFile, + downloadFile, + }; +}; + +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 new file mode 100644 index 0000000..8ff4f61 --- /dev/null +++ b/src/features/files/filesSlice.ts @@ -0,0 +1,102 @@ +import { FileID, FileInfo } from "types/files"; +import { PayloadAction, createSlice } from "@reduxjs/toolkit"; + +import { RootState } from "store"; + +// Files are organized by their string, 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; +} + +export const initialState: FilesState = { + fileTypesByApplicants: {}, +}; + +const filesSlice = createSlice({ + name: "files", + 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 string + 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); + } // 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(state, action: PayloadAction<[FileID, string, FileID]>) { + const [applicantID, fileType, fileID] = action.payload; + 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 => { + // 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); +}; + +export const selectFilesByIDAndApplicant = ( + type: string, + 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 []; +}; + +export default filesSlice.reducer; diff --git a/src/features/footer/index.tsx b/src/features/footer/index.tsx deleted file mode 100644 index 5d0c3eb..0000000 --- a/src/features/footer/index.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; -import Nav from 'react-bootstrap/Nav'; -import { NavLink } from 'react-router-dom'; - -export default () => ( -
- -

Utvecklat av Digital Ungdom

-
-); diff --git a/src/features/portal/Chapters.tsx b/src/features/portal/Chapters.tsx new file mode 100644 index 0000000..f181a21 --- /dev/null +++ b/src/features/portal/Chapters.tsx @@ -0,0 +1,43 @@ +import { Chapter } from "types/chapters"; +import React from "react"; +import RecommendationChapter from "./RecommendationChapter"; +import Survey from "features/survey"; +import TranslatedChapter from "./TranslatedChapter"; +import Upload from "features/files/Upload"; + +export interface ChaptersProps { + chapters: Chapter[]; +} + +function CustomChapter(props: Chapter) { + switch (props.type) { + case "RECOMMENDATION_LETTER": + return ; + case "SURVEY": + return ; + case "FILES": + return ( + + ); + default: + return <>; + } +} + +function Chapters({ chapters }: ChaptersProps): React.ReactElement { + return ( + <> + {chapters.map((chapter) => ( + + + + ))} + + ); +} + +export default Chapters; diff --git a/src/features/portal/Chapters/index.tsx b/src/features/portal/Chapters/index.tsx deleted file mode 100644 index 70dd948..0000000 --- a/src/features/portal/Chapters/index.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { WithTranslation, withTranslation } from "react-i18next"; - -import Chapter from "components/portal/Chapter"; -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 portal from "config/portal.json"; - -interface ChaptersProps extends WithTranslation { - filesLoading?: boolean; - referencesLoading?: boolean; -} - -const Chapters: React.FC = ({ - t, - filesLoading, - referencesLoading, -}): React.ReactElement => ( - <> - {portal.chapters.map((chapter) => ( - - {chapter.upload && chapter.upload.multiple === undefined && ( - - )} - {chapter.upload?.multiple && ( - - )} - {chapter.contactPeople && } - {chapter.survey && } - - ))} - -); -export default withTranslation()(Chapters); diff --git a/src/features/portal/Delete.tsx b/src/features/portal/Delete.tsx index 41556ba..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 "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(); }); }} @@ -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 ddccce1..f6de55b 100644 --- a/src/features/portal/Download.tsx +++ b/src/features/portal/Download.tsx @@ -1,16 +1,15 @@ import { Button, Spinner } from "react-bootstrap"; import React, { useState } from "react"; -import Axios from "axios"; import CSS from "csstype"; -import FileSaver from "file-saver"; +import { downloadFullPDF } from "api/files"; import { useTranslation } from "react-i18next"; interface DownloadProps { style: CSS.Properties; } -const Download = ({ style }: DownloadProps) => { +const Download = ({ style }: DownloadProps): React.ReactElement => { const [downloading, setDownload] = useState(false); const { t } = useTranslation(); return ( @@ -18,15 +17,7 @@ const Download = ({ style }: DownloadProps) => { 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/Introduction.tsx b/src/features/portal/Introduction.tsx new file mode 100644 index 0000000..e4ba577 --- /dev/null +++ b/src/features/portal/Introduction.tsx @@ -0,0 +1,12 @@ +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/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/RecommendationChapter.tsx b/src/features/portal/RecommendationChapter.tsx new file mode 100644 index 0000000..1863948 --- /dev/null +++ b/src/features/portal/RecommendationChapter.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import Reference from "features/recommendations/Reference"; + +type RecommendationChapterProps = { + max?: number; +}; + +const RecommendationChapter = ({ + max = 3, +}: RecommendationChapterProps): React.ReactElement => { + const map = []; + for (let i = 0; i < max; i += 1) { + map[i] = ; + } + return <>{map}; +}; + +export default RecommendationChapter; diff --git a/src/features/portal/References/index.tsx b/src/features/portal/References/index.tsx deleted file mode 100644 index d9b901f..0000000 --- a/src/features/portal/References/index.tsx +++ /dev/null @@ -1,97 +0,0 @@ -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 "axios"; -import ContactPerson from "components/portal/ContactPerson"; -import { RootState } from "store"; -import moment from "moment"; -import { selectRecommendation } from "features/portal/portalSlice"; -import { toast } from "react-toastify"; - -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((state: RootState) => - selectRecommendation(state, recommendationIndex) - ); - const dispatch = useDispatch(); - const applicationHasClosed = - moment.utc().month(2).endOf("month").diff(Date.now()) < 0; - function handleSubmit(email: string) { - setLoading(true); - Axios.post( - `/application/@me/recommendation/${recommendationIndex}`, - { - email, - } - ) - .then((res) => { - setLoading(false); - dispatch(addPersonSuccess([res.data])); - if ( - res.data.code && - res.config.baseURL === "https://devapi.infrarays.digitalungdom.se" - ) - toast(, { - position: "bottom-center", - autoClose: false, - }); - }) - .catch(console.error); - } - return ( - - ); -}; - -interface ReferencesProps { - loading?: boolean; -} - -const References = ({ loading }: ReferencesProps) => { - const map = []; - for (let i = 0; i < 3; i += 1) { - map[i] = ( - - ); - } - return <>{map}; -}; - -export default References; diff --git a/src/features/portal/Survey/index.tsx b/src/features/portal/Survey/index.tsx deleted file mode 100644 index ea60683..0000000 --- a/src/features/portal/Survey/index.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import Survey, { SurveyAnswers } from "components/Survey"; -import { selectSurvey, setSurvey } from "../portalSlice"; -import { useDispatch, useSelector } from "react-redux"; - -import Axios from "axios"; -import React from "react"; -import moment from "moment"; -import useAxios from "axios-hooks"; - -function useSurvey(): [SurveyAnswers | undefined, boolean] { - const [{ data, loading }] = useAxios("/application/@me/survey"); - const dispatch = useDispatch(); - if (data) dispatch(setSurvey(data)); - const survey = useSelector(selectSurvey); - return [survey, loading]; -} - -const PortalSurvey = () => { - const [survey, loading] = useSurvey(); - const dispatch = useDispatch(); - if (loading) return
; - const applicationHasClosed = - moment.utc().month(2).endOf("month").diff(Date.now()) < 0; - return ( - { - return Axios.post("/application/@me/survey", newSurvey).then(() => { - dispatch(setSurvey(newSurvey)); - }); - }} - disabled={applicationHasClosed} - /> - ); -}; - -export default PortalSurvey; diff --git a/src/features/portal/TranslatedChapter.tsx b/src/features/portal/TranslatedChapter.tsx new file mode 100644 index 0000000..821e79d --- /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/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..a42dc9c 100644 --- a/src/features/portal/index.tsx +++ b/src/features/portal/index.tsx @@ -1,70 +1,31 @@ -import { ButtonGroup, ProgressBar } from "react-bootstrap"; -import { FileInfo, selectProgress, setFiles } from "./portalSlice"; -import React, { useEffect, useState } from "react"; -import { useDispatch, useSelector } from "react-redux"; - -import Alert from "react-bootstrap/Alert"; -import Axios from "axios"; +import ButtonGroup from "react-bootstrap/ButtonGroup"; import Center from "components/Center"; +import { Chapter } from "types/chapters"; import Chapters from "./Chapters"; import Delete from "./Delete"; import Download from "./Download"; +import Introduction from "./Introduction"; import Logo from "components/Logo"; import Logout from "./Logout"; -import ReactMarkdown from "react-markdown"; +import Progress from "./Progress"; +import React from "react"; import StyledPlate from "components/Plate"; -import { addPersonSuccess } from "features/portal/portalSlice"; -import { useTranslation } from "react-i18next"; - -const Hook = () => { - const dispatch = useDispatch(); - const [filesLoading, setFilesLoading] = useState(true); - const [referencesLoading, setReferencesLoading] = useState(true); - - useEffect(() => { - Axios.get("/application/@me/file") - .then((res) => { - dispatch(setFiles(res.data)); - setFilesLoading(false); - }) - .catch(console.error); - Axios.get("/application/@me/recommendation").then((res) => { - dispatch(addPersonSuccess(res.data)); - setReferencesLoading(false); - }); - }, []); - const progress = useSelector(selectProgress); +import config from "config/portal.json"; - const { t } = useTranslation(); +const chapters = config.chapters as Chapter[]; +const Portal = (): React.ReactElement => { return (
+ + +
-

{t("title")}

- - - {progress === 5 && ( - - {t("Application complete")} - - )} -
-
-
- -
- {progress === 5 && ( - {t("Application complete")} - )} + + +
@@ -77,4 +38,4 @@ const Hook = () => { ); }; -export default Hook; +export default Portal; 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 ea605b1..0000000 --- a/src/features/portal/portalSlice.ts +++ /dev/null @@ -1,152 +0,0 @@ -/* eslint-disable camelcase */ -/* eslint-disable no-param-reassign */ -import { PayloadAction, createSlice } from "@reduxjs/toolkit"; - -import { RootState } from "store"; -import { SurveyAnswers } from "components/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]; - }, - addPersonSuccess(state, action: PayloadAction) { - action.payload.forEach( - (recommendation) => - (state.recommendations[recommendation.index] = recommendation) - ); - }, - 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 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 => { - 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, - addPersonSuccess, - setSurvey, - clearPortal, - deleteFileSuccess, - replaceFile, -} = portalSlice.actions; - -export default portalSlice.reducer; 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 new file mode 100644 index 0000000..c684bf2 --- /dev/null +++ b/src/features/recommendations/recommendationHooks.ts @@ -0,0 +1,52 @@ +import { + NewRecommendationRequest, + RecommendationRequest, +} from "types/recommendations"; +import { + addPersonSuccess, + selectApplicantRecommendations, +} from "./recommendationsSlice"; +import useApi, { UseApi } from "hooks/useApi"; +import { useDispatch, useSelector } from "react-redux"; + +import { requestRecommendation } from "api/recommendations"; +import { useCallback } from "react"; + +interface UseRecommendations extends UseApi { + addReference: ( + request: NewRecommendationRequest + ) => Promise; +} + +export const useRecommendations = ( + index = -1, + applicantID = "@me" +): UseRecommendations => { + const dispatch = useDispatch(); + const [{ loading, data, error }] = useApi({ + url: `/application/${applicantID}/recommendation`, + }); + const applicantRecommendations = useSelector( + 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?.[index], + error, + addReference, + }; +}; diff --git a/src/features/recommendations/recommendationsSlice.ts b/src/features/recommendations/recommendationsSlice.ts new file mode 100644 index 0000000..634027d --- /dev/null +++ b/src/features/recommendations/recommendationsSlice.ts @@ -0,0 +1,56 @@ +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: "recommendations", + initialState, + reducers: { + addPersonSuccess(state, action: PayloadAction) { + action.payload.forEach((recommendation) => { + if (state[recommendation.applicantId]) + state[recommendation.applicantId][ + recommendation.index + ] = recommendation; + else { + state[recommendation.applicantId] = []; + state[recommendation.applicantId][ + recommendation.index + ] = recommendation; + } + }); + }, + clearRecommendations(state) { + Object.assign(state, initialState); + }, + }, +}); + +export const { + addPersonSuccess, + clearRecommendations, +} = 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..82f2a69 100644 --- a/src/features/router/index.tsx +++ b/src/features/router/index.tsx @@ -8,13 +8,15 @@ 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 Recommendation = lazy(() => import("features/recommendation")); +const NoMatch = lazy(() => import("components/NoMatch")); +const Recommendation = lazy( + () => import("features/files/UploadRecommendationLetter") +); const LoginWithCodeRoute = lazy( () => 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/features/survey/index.tsx b/src/features/survey/index.tsx new file mode 100644 index 0000000..6147e5d --- /dev/null +++ b/src/features/survey/index.tsx @@ -0,0 +1,23 @@ +import CustomSurvey from "components/CustomSurvey"; +import { CustomSurveyQuestion } from "types/survey"; +import React from "react"; +import hasApplicationClosed from "utils/hasApplicationClosed"; +import { useSurvey } from "./surveyHooks"; + +const PortalSurvey = (props: { + config: CustomSurveyQuestion[]; +}): React.ReactElement => { + const { data, loading, updateSurvey } = useSurvey(); + if (loading) return
; + const closed = hasApplicationClosed(); + return ( + + ); +}; + +export default PortalSurvey; diff --git a/src/features/survey/surveyHooks.ts b/src/features/survey/surveyHooks.ts new file mode 100644 index 0000000..1a27dd9 --- /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/features/survey/surveySlice.ts b/src/features/survey/surveySlice.ts new file mode 100644 index 0000000..d132e05 --- /dev/null +++ b/src/features/survey/surveySlice.ts @@ -0,0 +1,34 @@ +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) { + if (action.payload.applicantId) + state[action.payload.applicantId] = action.payload; + }, + clearSurvey(state) { + Object.assign(state, initialState); + }, + }, +}); + +export const selectSurvey = (userID?: string) => ( + state: RootState +): SurveyAnswers | undefined => { + const id = userID || state.auth.user?.id; + if (!id) return; + return state.survey[id]; +}; + +export const { setSurvey, clearSurvey } = surveySlice.actions; + +export default surveySlice.reducer; diff --git a/src/hooks/useApi.ts b/src/hooks/useApi.ts new file mode 100644 index 0000000..0dde5b7 --- /dev/null +++ b/src/hooks/useApi.ts @@ -0,0 +1,15 @@ +import { api as axios } from "api/axios"; +import { makeUseAxios } from "axios-hooks"; + +export const useApi = makeUseAxios({ + axios, +}); + +// 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/i18n.ts b/src/i18n.ts index 423b06a..c505da3 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -1,26 +1,25 @@ -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 "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: { ...en, - ...portalEn - } + ...portalEn, + }, }, sv: { translation: { ...sv, - ...portalSv - } - } + ...portalSv, + }, + }, }; i18n @@ -28,22 +27,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/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/resources/locales/en.json b/src/resources/locales/en.json index 9b07e25..ad4ce90 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", @@ -100,5 +100,6 @@ "Application has closed": "Application period has closed", "Couldn't upload": "Couldn't upload file", "Only PDF": "The file must be a PDF", - "average": "Average" + "average": "Average", + "Uploading": "Uploading" } diff --git a/src/resources/locales/portal_en.json b/src/resources/locales/portal_en.json deleted file mode 100644 index 1bd3eaa..0000000 --- a/src/resources/locales/portal_en.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "title": "Application for Rays 2021", - "introduction": "Thank you for your interest in applying to Rays! We will be reading all applications and respond to you as we can. Below you can find the information we want you to send. On the **March 31st at 23:59** you will no longer be able to edit your application and it will automatically be sent to Rays. For your application to be sent you must have uplaoded all files and filled in the survey. Until this date you can update any part by simply uploading a new file again. Your old file will be replaced by the new one. All files must be in PDF-format and the specific requirements for word count can be located in each part. The file limit is 5 MB per part.\n\nWe who arrange Rays wish you the very best of luck and look forward to reading your application! [For more information please check the website!](http://raysforexcellence.se/ansok/)", - "COVER_LETTER": { - "title": "Personal letter", - "subtitle": "Maximum 600 words", - "description": "We who arrange Rays want to get to know you applicants as well as possible. In your personal letter, we want you to tell us about your interests and why you are applying to Rays. We want to hear about where your passion for science comes from and how your previous experiences have shaped you.", - "upload": { - "label": "Upload cover letter" - } - }, - "CV": { - "title": "CV", - "subtitle": "Maximum 2 pages", - "description": "Apart from basic information, we recommend you to include descriptions of competitions you have partaken in (both scientific and athletic), awards won at school or in other contexts, experience of voluntary work and holding positions of trust.", - "upload": { - "label": "Upload CV" - } - }, - "ESSAY": { - "title": "Essays", - "subtitle": "Maximum 300 words on each essay", - "description": "1. Name two or three subjects in science, technology or mathematics that interest you especially, and tell us why you like about them. You may be very specific if you want. We will use your answer when matching students to mentors.\n2. Choose and answer **one** of the following questions:\n * Tell us about something you have done, which you think demonstrates your potential to become a future leader within the natural sciences, technology or mathematics.\n * Tell us about something or someone who inspires you. How have/has they/it influenced you, your goals, your dreams for the future and how you perceive the surroundings?\n * Describe how you work or have worked to develop one of your personal qualities and how that has helped you or is helping you?\n * Tell us about a challenge you have solved / want to solve. It might be an intellectual challenge, a research question, or an ethical dilemma – something that you care about, no matter the scope. Explain its importance to you and what challenges you have faced or would have to face and how you dealt with or would deal with these.\n\n**You need to have the answer to both questions in the same PDF.**", - "upload": { - "label": "Upload essays" - } - }, - "GRADES": { - "title": "Grades", - "subtitle": "", - "description": "Please scan your grades for all completed high school courses and attach them to your application. The document should be available from the school office and should be **signed by the headmaster or your responsible teacher**.", - "upload": { - "label": "Upload grades" - } - }, - "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." - }, - "RECOMMENDATION_LETTER": { - "title": "Letter of recommendation", - "subtitle": "", - "description": "Teachers, coaches and others could address questions such as how their student handles challenges or responsibility, and why the student has the potential of a future leader within research. Letters of Recommendation should be composed, signed and sent by the teacher/coach. [More information can be found here.](https://raysforexcellence.se/rekommendationsbev)\n\nBy submitting an email below, a link will be sent to the recipient who can upload their letter of recommendation. You will be able to resend and change the email as long as the recipient has not uploaded their letter. As soon as the recipient has uploaded the letter, it is locked to your application. \n\n**You should preferably submit at least 1 and max 3 letters of recommendation.**" - }, - "APPENDIX": { - "title": "Appendix", - "subtitle": "", - "description": "Below you can add up to five appendix files. They need to be PDFs and each file can be a maximum of 5 MB. **This is not necessary but has been requested by some people.**", - "upload": { - "label": "Upload appendices" - } - }, - "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/resources/locales/portal_sv.json b/src/resources/locales/portal_sv.json deleted file mode 100644 index 5212b92..0000000 --- a/src/resources/locales/portal_sv.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "title": "Ansökan för Rays 2021", - "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. Filstorleken Ă€r 5 MB per del.\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/)", - "COVER_LETTER": { - "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.", - "upload": { - "label": "Ladda upp personligt brev" - } - }, - "CV": { - "title": "CV", - "subtitle": "Max 2 sidor", - "description": "Förutom grundlĂ€ggande information rekommenderas ditt CV innehĂ„lla en beskrivning av exempelvis deltagande i sĂ„vĂ€l naturvetenskapliga som idrottsliga tĂ€vlingar, utmĂ€rkelser i skolan eller andra sammanhang, samt ideellt arbete och förtroendeuppdrag.", - "upload": { - "label": "Ladda upp CV" - } - }, - "ESSAY": { - "title": "EssĂ€svar", - "subtitle": "Max 300 ord pĂ„ vardera", - "description": "1. Ange tvĂ„ eller tre naturvetenskapliga, tekniska, eller matematiska Ă€mnen du tycker om och berĂ€tta varför. Du fĂ„r gĂ€rna vara specifik. Vi kommer anpassa ditt forskningsprojekt till de intressen du beskriver i denna frĂ„ga.\n2. VĂ€lj och besvara **en** av nedanstĂ„ende frĂ„gor:\n * BerĂ€tta om nĂ„got du gjort som du anser demonstrerar din potential att bli en ledande forskare inom naturvetenskap, teknik och/eller matematik.\n * BerĂ€tta om nĂ„got eller nĂ„gon som inspirerar dig. Hur har det/den/de pĂ„verkat dig, dina mĂ„l, dina drömmar om framtiden och hur du uppfattar din omgivning?\n * Beskriv hur du jobbar/har jobbat för att utveckla nĂ„gon av dina egenskaper och hur det har hjĂ€lpt/hjĂ€lper dig?\n * Beskriv ett problem du har löst eller en utmaning du vill lösa. Det kan vara en intellektuell utmaning, en forskningsfrĂ„ga, ett etiskt dilemma – nĂ„got som du bryr dig om, oavsett omfattningen. Förklara dess betydelse för dig och vilka utmaningar du stĂ€llts/skulle stĂ€llas inför och hur du löste/skulle lösa detta problem.\n\n**Du ska svara pĂ„ bĂ„da frĂ„gor i en och samma pdf.**", - "upload": { - "label": "Ladda upp essĂ€svar" - } - }, - "GRADES": { - "title": "Betyg", - "subtitle": "", - "description": "Scanna in och bifoga slutbetyg för de gymnasiekurser du avslutat. Detta gĂ„r att fĂ„ ifrĂ„n skolans expedition och ska vara **signerat av rektor eller ansvarig lĂ€rare**.", - "upload": { - "label": "Ladda upp betyg" - } - }, - "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." - }, - "RECOMMENDATION_LETTER": { - "title": "Rekommendationsbrev", - "subtitle": "", - "description": "Rekommendationsbrev frĂ„n lĂ€rare, trĂ€nare eller liknande. Exempel pĂ„ frĂ„gor som kan behandlas i en rekommendation Ă€r hur eleven hanterar utmaningar och ansvar, och varför eleven har potential att bli en framtida ledare inom forskning. Rekommendationsbrev skall komponeras, signeras och skickas av lĂ€raren, trĂ€naren eller liknande. [Mer info hittas hĂ€r.](https://raysforexcellence.se/rekommendationsbev)\n\nGenom att skriva in en e-post nedan kommer en lĂ€nk att skickas till mottagaren som kan ladda upp sitt rekommendationsbrev. Du kommer att kunna skicka om och Ă€ndra emailet sĂ„ lĂ€nge mottagaren inte har laddat upp sitt brev. SĂ„ fort mottagaren har laddat upp brevet Ă€r den lĂ„st till din ansökan.\n\n**Du bör helst skicka in minst 1 och max 3 rekommendationsbrev.**" - }, - "APPENDIX": { - "title": "Bilagor", - "subtitle": "", - "description": "HĂ€r kan du ladda upp maximalt fem bilagor. Filerna behöver vara i PDF och varje fil kan maximalt vara 5 MB stor. **Detta Ă€r inte nödvĂ€ndigt men har efterfrĂ„gats av nĂ„gra.**", - "upload": { - "label": "Ladda upp bilagor" - } - }, - "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)." -} diff --git a/src/resources/locales/sv.json b/src/resources/locales/sv.json index 543ae7d..c990220 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", @@ -101,5 +101,6 @@ "Application has closed": "Ansökningsperioden har stĂ€ngt", "Couldn't upload": "Kunde inte ladda upp fil", "Only PDF": "Filen mĂ„ste vara en PDF", - "average": "MedelvĂ€rde" + "average": "MedelvĂ€rde", + "Uploading": "Laddar upp" } 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 @@ - - - - - - - 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); + }); } } 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"; diff --git a/src/store.ts b/src/store.ts index 4aed434..6726edf 100644 --- a/src/store.ts +++ b/src/store.ts @@ -16,8 +16,10 @@ import { import admin from "features/admin/adminSlice"; import auth from "features/auth/authSlice"; -import portal from "features/portal/portalSlice"; +import files from "features/files/filesSlice"; +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", @@ -27,8 +29,10 @@ const persistConfig = { const rootReducer = combineReducers({ auth, - portal, admin, + files, + recommendations, + survey, }); const persistedReducer = persistReducer(persistConfig, rootReducer); diff --git a/src/types/chapters.ts b/src/types/chapters.ts new file mode 100644 index 0000000..8246557 --- /dev/null +++ b/src/types/chapters.ts @@ -0,0 +1,24 @@ +import { CustomSurveyQuestion } from "./survey"; + +export type Chapter = FileChapter | SurveyChapter | ReferenceChapter; + +export type FileChapter = { + id: string; + type: "FILES"; + upload: { + multiple: number; + accept: ".pdf"; + }; +}; + +export type SurveyChapter = { + id: string; + type: "SURVEY"; + questions: CustomSurveyQuestion[]; +}; + +export type ReferenceChapter = { + id: string; + type: "RECOMMENDATION_LETTER"; + max: 3; +}; 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; +}; diff --git a/src/types/grade.ts b/src/types/grade.ts new file mode 100644 index 0000000..66f3fc4 --- /dev/null +++ b/src/types/grade.ts @@ -0,0 +1,50 @@ +import { Admin, Applicant } from "./user"; + +export type Application = Applicant & + Partial & { + city: string; + school: string; + done?: boolean; + }; + +export type NumericalGradeField = + | "cv" + | "coverLetter" + | "essays" + | "grades" + | "recommendations" + | "overall"; + +export type ApplicationGrade = { + comment: string; + cv: number; + coverLetter: number; + essays: number; + grades: number; + recommendations: number; + overall: number; +}; + +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; +} diff --git a/src/types/recommendations.ts b/src/types/recommendations.ts new file mode 100644 index 0000000..27dd3d3 --- /dev/null +++ b/src/types/recommendations.ts @@ -0,0 +1,18 @@ +export interface RecommendationRequest extends NewRecommendationRequest { + applicantId: string; + lastSent: string; + received: null | string; + id: string; + code?: string; +} + +export type NewRecommendationRequest = { + email: string; + index: number; +}; + +export interface RecommendationFile extends RecommendationRequest { + applicantId: string; + fileId: null | string; + fileName?: string; +} diff --git a/src/types/survey.ts b/src/types/survey.ts new file mode 100644 index 0000000..fdf859e --- /dev/null +++ b/src/types/survey.ts @@ -0,0 +1,64 @@ +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: number; +// applicationProcess: number; +// improvement: string; +// informant: string; +// applicantId?: string; +// } + +export type SurveyAnswers = Record & { + applicantId?: string; +}; + +export interface Statistics { + // [keyof SurveyAnswer ] + [s: string]: + | NumericalStatistic + | string[] + | { count: Record }; + // applicationProcess: NumericalStatistic; + // applicationPortal: NumericalStatistic; + // improvement: string[]; + // informant: string[]; + // city: string[]; + // school: string[]; + // gender: { count: Record }; +} + +export type CustomSurveyQuestion = + | SurveyTextQuestion + | SurveyRangeQuestion + | SurveySelectQuestion; + +export type SurveyTextQuestion = { + type: "TEXT"; + maxLength: number; + id: string; +}; + +export type SurveyRangeQuestion = { + type: "RANGE"; + range: [number, number]; + id: string; +}; + +export type SurveySelectQuestion = { + type: "SELECT"; + options: string[]; + id: string; +}; + +export type CustomSurveyAnswer = number | string | undefined; 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/types/user.ts b/src/types/user.ts new file mode 100644 index 0000000..a1eb5bf --- /dev/null +++ b/src/types/user.ts @@ -0,0 +1,25 @@ +export type User = { + firstName: string; + lastName: string; + email: string; + id: string; + created: string; + verified: boolean; + type?: UserTypes; +}; + +export type UserTypes = Applicant["type"] & Admin["type"]; + +export interface Applicant extends Omit { + birthdate: string; + finnish: boolean; + type: "APPLICANT"; +} + +export type ServerUserFields = "id" | "created" | "verified"; + +export type NewAdmin = Omit; + +export interface Admin extends Omit { + type: "ADMIN" | "SUPER_ADMIN"; +} 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/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/hasApplicationClosed.ts b/src/utils/hasApplicationClosed.ts new file mode 100644 index 0000000..1e060a1 --- /dev/null +++ b/src/utils/hasApplicationClosed.ts @@ -0,0 +1,5 @@ +import { deadline } from "config/portal.json"; + +const hasApplicationClosed = (): boolean => deadline < Date.now(); + +export default hasApplicationClosed; 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); +} 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..6cdf16c 100644 --- a/src/utils/tokenInterceptor.ts +++ b/src/utils/tokenInterceptor.ts @@ -1,18 +1,14 @@ import { authFail, authSuccess } from "features/auth/authSlice"; -import axios from "axios"; -import { clearPortal } from "features/portal/portalSlice"; +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"; 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"; @@ -29,26 +25,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; } @@ -107,7 +101,9 @@ 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()); + store.dispatch(clearFiles()); + store.dispatch(clearRecommendations()); } private static getRefreshToken(): string | null {