{applicationGrades.map((grade) => (
-
+
{grade.firstName + " " + grade.lastName} |
{grade.cv} |
{grade.coverLetter} |
@@ -40,7 +47,7 @@ const GradingData = ({ applicationGrades = [] }) => (
{applicationGrades.map(
(grade) =>
Boolean(grade.comment) && (
-
+
{grade.firstName + " " + grade.lastName} |
{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 = () => (
-
-);
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 = () => (
-
- 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 (
-
- );
-}
-
-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) {
- {isNumeric ? "Betyg" : "Svar"} |
+ {answers.average ? "Betyg" : "Svar"} |
Antal |
{Object.keys(answers.count).map((n, i) => (
- {console.log(t(n), n)}
{t(n)} |
{answers.count[n]} |
))}
- {isNumeric &&
- (["average"] as StatisticalValue[]).map((key) => (
-
- {t(key)} |
- {Math.round(answers[key] * 100) / 100} |
-
- ))}
+ {answers.average && (
+
+ {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 () => (
-
-);
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