Add ability to fill student data

This commit is contained in:
Kacper Donat 2020-10-03 19:50:12 +02:00
parent ff2e9c8b82
commit 411603e3a1
25 changed files with 382 additions and 58 deletions

View File

@ -11,3 +11,9 @@ export async function current(): Promise<Student> {
return studentDtoTransfer.transform(dto);
}
export async function update(student: Student): Promise<Student> {
const dto = studentDtoTransfer.reverseTransform(student);
const response = await axios.put(CURRENT_STUDENT_ENDPOINT, dto);
return student;
}

View File

@ -1,13 +1,16 @@
import { axios } from "@/api/index";
import { query, route } from "@/routing";
const LOGIN_ENDPOINT = "/access/login"
const LOGIN_ENDPOINT = "/access/login";
const DEV_LOGIN_ENDPOINT = "/dev/login";
const CLIENT_ID = process.env.LOGIN_CLIENT_ID || "PraktykiClientId";
const AUTHORIZE_URL = process.env.AUTHORIZE || "https://logowanie.pg.edu.pl/oauth2.0/authorize";
export async function login(code: string): Promise<string> {
const response = await axios.get<string>(LOGIN_ENDPOINT, { params: { code }});
export async function login(code?: string): Promise<string> {
const response = code
? await axios.get<string>(LOGIN_ENDPOINT, { params: { code }})
: await axios.get<string>(DEV_LOGIN_ENDPOINT);
return response.data;
}

View File

@ -6,6 +6,7 @@ import classNames from "classnames";
import { useVerticalSpacing } from "@/styles";
import moment from "moment";
import { Label, Section } from "@/components/section";
import { StudentPreview } from "@/pages/user/profile";
export type ProposalPreviewProps = {
proposal: Internship;
@ -19,12 +20,7 @@ export const ProposalPreview = ({ proposal }: ProposalPreviewProps) => {
return <div className={ classNames("proposal", classes.root) }>
<div>
<Typography className="proposal__primary">{ proposal.intern.name } { proposal.intern.surname }</Typography>
<Typography className="proposal__secondary">
{ t('internship.intern.semester', { semester: proposal.intern.semester }) }
{ ", " }
{ t('internship.intern.album', { album: proposal.intern.albumNumber }) }
</Typography>
<StudentPreview student={ proposal.intern } />
</div>
<Section>

View File

@ -23,6 +23,6 @@ export function getMissingStudentData(student: Student): (keyof Student)[] {
!!student.email || "email",
!!student.albumNumber || "albumNumber",
!!student.semester || "semester",
!!student.course || "course",
// !!student.course || "course",
].filter(x => x !== true) as (keyof Student)[];
}

133
src/forms/user.tsx Normal file
View File

@ -0,0 +1,133 @@
import { Student } from "@/data";
import { Transformer } from "@/serialization";
import React, { useMemo } from "react";
import { Field, Formik, useFormikContext } from "formik";
import api from "@/api";
import { Button, Grid, Typography } from "@material-ui/core";
import { TextField as TextFieldFormik } from "formik-material-ui";
import { useTranslation } from "react-i18next";
import { Actions } from "@/components";
import { Nullable } from "@/helpers";
import * as Yup from "yup";
import { StudentActions, useDispatch } from "@/state/actions";
interface StudentFormValues {
firstName: string;
lastName: string;
email: string;
albumNumber: number | "";
semester: number | "";
}
type StudentFormProps = {
student: Student;
}
const studentToFormValuesTransformer: Transformer<Nullable<Student>, StudentFormValues, { current: Student }> = {
transform(subject: Nullable<Student>, context: { current: Student }): StudentFormValues {
return {
firstName: subject.name || "",
lastName: subject.surname || "",
albumNumber: subject.albumNumber || "",
semester: subject.semester || "",
email: subject.email || "",
};
},
reverseTransform(subject: StudentFormValues, { current }: { current: Student }): Nullable<Student> {
return {
...current,
name: subject.firstName,
surname: subject.lastName,
albumNumber: subject.albumNumber ? subject.albumNumber : null,
semester: subject.semester ? subject.semester : null,
email: subject.email,
};
},
}
export const StudentForm = ({ student }: StudentFormProps) => {
const { t } = useTranslation();
const dispatch = useDispatch();
const validationSchema = useMemo(() => Yup.object<StudentFormValues>({
semester: Yup.number().required().min(1).max(10),
albumNumber: Yup.number().required(),
email: Yup.string().required(),
firstName: Yup.string().required(),
lastName: Yup.string().required(),
}), []);
const initialValues: StudentFormValues = useMemo(
() => studentToFormValuesTransformer.transform(student, { current: student }),
[ student ]
)
const handleFormSubmit = async (values: StudentFormValues) => {
const update = studentToFormValuesTransformer.reverseTransform(values, { current: student }) as Student;
const updated = await api.student.update(update);
dispatch({
type: StudentActions.Set,
student: updated,
})
}
const InnerForm = () => {
const { handleSubmit } = useFormikContext();
return <form onSubmit={ handleSubmit }>
<Typography variant="subtitle1">{ t("forms.student.sections.personal") }</Typography>
<Grid container>
<Grid item md={ 6 }>
<Field component={ TextFieldFormik }
name="firstName"
label={ t("forms.student.fields.first-name") }
fullWidth
/>
</Grid>
<Grid item md={ 6 }>
<Field component={ TextFieldFormik }
name="lastName"
label={ t("forms.student.fields.last-name") }
fullWidth
/>
</Grid>
<Grid item>
<Field component={ TextFieldFormik }
name="email"
label={ t("forms.student.fields.email") }
fullWidth
/>
</Grid>
</Grid>
<Typography variant="subtitle1">{ t("forms.student.sections.studies")}</Typography>
<Grid container>
<Grid item md={ 6 }>
<Field component={ TextFieldFormik }
name="albumNumber"
label={ t("forms.student.fields.album-number") }
fullWidth
/>
</Grid>
<Grid item md={ 6 }>
<Field component={ TextFieldFormik }
name="semester"
label={ t("forms.student.fields.semester") }
fullWidth
/>
</Grid>
</Grid>
<Actions>
<Button variant="contained" type="submit" color="primary">{ t("save") }</Button>
</Actions>
</form>
}
return <Formik initialValues={ initialValues } onSubmit={ handleFormSubmit } validationSchema={ validationSchema }>
<InnerForm />
</Formik>
}
export default StudentForm;

View File

@ -1,3 +1,4 @@
export * from "./useProxyState"
export * from "./useUpdateEffect"
export * from "./useAsync"
export * from "./state"

18
src/hooks/state.ts Normal file
View File

@ -0,0 +1,18 @@
import { useSelector } from "react-redux";
import { AppState } from "@/state/reducer";
import { Edition, getEditionDeadlines } from "@/data/edition";
import { editionSerializationTransformer } from "@/serialization";
import { Student } from "@/data";
export const useCurrentStudent = () => useSelector<AppState, Student | null>(
state => state.student
)
export const useCurrentEdition = () => useSelector<AppState, Edition | null>(
state => state.edition && editionSerializationTransformer.reverseTransform(state.edition)
)
export const useDeadlines = () => {
const edition = useCurrentEdition() as Edition;
return getEditionDeadlines(edition);
}

View File

@ -9,7 +9,7 @@ export const isReadyMiddleware: Middleware<any, any> = next => isLoggedInMiddlew
const ready = useSelector(isReady);
if (ready) {
return next();
return <>{ next() }</>;
}
return <Redirect to={ route("edition_pick") } />;
@ -19,8 +19,8 @@ export const isLoggedInMiddleware: Middleware<any, any> = next => {
const user = useSelector<AppState>(state => state.user) as UserState;
if (user.loggedIn) {
return next();
return <>{ next() }</>;
}
return <Redirect to={ route("login") } />;
return <Redirect to={ route("user_login") } />;
}

View File

@ -21,7 +21,7 @@ export const Page = ({ title, children, ...props }: PageProps) => {
</Box>
}
Page.Header = ({ children, maxWidth = false, ...props }: PageHeaderProps) =>
Page.Header = ({ children, maxWidth = undefined, ...props }: PageHeaderProps) =>
<section {...props} className={classNames("page__header", props.className)}>
<Container maxWidth={ maxWidth }>
{ children }

View File

@ -1,51 +1,34 @@
import React, { useEffect, useMemo } from "react";
import React from "react";
import { Page } from "@/pages/base";
import { Button, Container, Stepper, Typography } from "@material-ui/core";
import { Link as RouterLink, Redirect } from "react-router-dom";
import { Container, Stepper, Typography } from "@material-ui/core";
import { Redirect } from "react-router-dom";
import { route } from "@/routing";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { AppState } from "@/state/reducer";
import { getMissingStudentData, Student } from "@/data";
import { Deadlines, Edition, getEditionDeadlines } from "@/data/edition";
import { Student } from "@/data";
import { Step } from "@/components";
import { ProposalStep } from "@/pages/steps/proposal";
import { PlanStep } from "@/pages/steps/plan";
import { InsuranceState } from "@/state/reducer/insurance";
import { InsuranceStep } from "@/pages/steps/insurance";
import api from "@/api";
import { StudentStep } from "@/pages/steps/student";
import { useDeadlines } from "@/hooks";
export const MainPage = () => {
const { t } = useTranslation();
const student = useSelector<AppState, Student | null>(state => state.student);
const deadlines = useSelector<AppState, Deadlines>(state => getEditionDeadlines(state.edition as Edition)); // edition cannot be null at this point
const deadlines = useDeadlines();
const insurance = useSelector<AppState, InsuranceState>(root => root.insurance);
const missingStudentData = useMemo(() => student ? getMissingStudentData(student) : [], [student]);
useEffect(() => void api.edition.available())
if (!student) {
return <Redirect to={ route("user_login") }/>;
}
function *getSteps() {
yield <Step label={ t('steps.personal-data.header') } completed={ missingStudentData.length === 0 } until={ deadlines.personalData } key="personal-data">
{ missingStudentData.length > 0 && <>
<p>{ t('steps.personal-data.info') }</p>
<ul>
{ missingStudentData.map(field => <li key={ field }>{ t(`student.${ field }`) }</li>) }
</ul>
<Button to={ route("internship_proposal") } variant="contained" color="primary" component={ RouterLink }>
{ t('steps.personal-data.form') }
</Button>
</> }
</Step>;
yield <StudentStep key="student"/>;
yield <ProposalStep key="proposal"/>;
yield <PlanStep key="plan"/>;

View File

@ -4,16 +4,17 @@ import { InsuranceState } from "@/state/reducer/insurance";
import { Actions, Step } from "@/components";
import { useTranslation } from "react-i18next";
import React from "react";
import { Edition, getEditionDeadlines } from "@/data/edition";
import { Moment } from "moment";
import { ContactAction } from "@/pages/steps/common";
import { useDeadlines } from "@/hooks";
import { StepProps } from "@material-ui/core";
export const InsuranceStep = () => {
export const InsuranceStep = (props: StepProps) => {
const insurance = useSelector<AppState, InsuranceState>(root => root.insurance);
const deadline = useSelector<AppState, Moment | undefined>(state => getEditionDeadlines(state.edition as Edition).insurance); // edition cannot be null at this point
const deadline = useDeadlines().insurance;
const { t } = useTranslation();
return <Step label={ t("steps.insurance.header") } until={ deadline } completed={ insurance.signed } active={ !insurance.signed }>
return <Step { ...props } label={ t("steps.insurance.header") } until={ deadline } completed={ insurance.signed } active={ !insurance.signed }>
<p>{ t(`steps.insurance.instructions`) }</p>
<Actions>
<ContactAction />

View File

@ -9,9 +9,9 @@ import { Link as RouterLink } from "react-router-dom";
import { Actions, Step } from "@/components";
import React, { HTMLProps } from "react";
import { Alert, AlertTitle } from "@material-ui/lab";
import { Deadlines, Edition, getEditionDeadlines } from "@/data/edition";
import { ContactAction, Status } from "@/pages/steps/common";
import { Description as DescriptionIcon } from "@material-ui/icons";
import { useDeadlines } from "@/hooks";
const PlanActions = () => {
const status = useSelector<AppState, SubmissionStatus>(state => getSubmissionStatus(state.plan));
@ -74,7 +74,7 @@ export const PlanStep = (props: StepProps) => {
const submission = useSelector<AppState, SubmissionState>(state => state.plan);
const status = getSubmissionStatus(submission);
const deadlines = useSelector<AppState, Deadlines>(state => getEditionDeadlines(state.edition as Edition)); // edition cannot be null at this point
const deadlines = useDeadlines();
const { sent, declined, comment } = submission;

View File

@ -6,12 +6,12 @@ import React, { HTMLProps } from "react";
import { InternshipProposalState } from "@/state/reducer/proposal";
import { Alert, AlertTitle } from "@material-ui/lab";
import { Box, Button, ButtonProps, StepProps } from "@material-ui/core";
import { Deadlines, Edition, getEditionDeadlines } from "@/data/edition";
import { Actions, Step } from "@/components";
import { route } from "@/routing";
import { Link as RouterLink } from "react-router-dom";
import { ClipboardEditOutline, FileFind } from "mdi-material-ui/index";
import { ContactAction, Status } from "@/pages/steps/common";
import { useDeadlines } from "@/hooks";
const ProposalActions = () => {
const status = useSelector<AppState, SubmissionStatus>(state => getSubmissionStatus(state.proposal));
@ -69,7 +69,7 @@ export const ProposalStep = (props: StepProps) => {
const submission = useSelector<AppState, SubmissionState>(state => state.proposal);
const status = useSelector<AppState, SubmissionStatus>(state => getSubmissionStatus(state.proposal));
const deadlines = useSelector<AppState, Deadlines>(state => getEditionDeadlines(state.edition as Edition)); // edition cannot be null at this point
const deadlines = useDeadlines();
const { sent, declined, comment } = submission;

View File

@ -0,0 +1,39 @@
import { Button, StepProps } from "@material-ui/core";
import { route } from "@/routing";
import { Link as RouterLink } from "react-router-dom";
import { Actions, Step } from "@/components";
import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { getMissingStudentData, Student } from "@/data";
import { useSelector } from "react-redux";
import { AppState } from "@/state/reducer";
import { useDeadlines } from "@/hooks";
import { AccountDetails } from "mdi-material-ui";
export const StudentStep = (props: StepProps) => {
const { t } = useTranslation();
const student = useSelector<AppState, Student | null>(state => state.student);
const missingStudentData = useMemo(() => student ? getMissingStudentData(student) : [], [student]);
const deadlines = useDeadlines();
return <Step {...props} label={ t('steps.personal-data.header') } completed={ missingStudentData.length === 0 } until={ deadlines.personalData }>
{ missingStudentData.length > 0 ? <>
<p>{ t('steps.personal-data.info') }</p>
<ul>
{ missingStudentData.map(field => <li key={ field }>{ t(`student.${ field }`) }</li>) }
</ul>
<Button to={ route("user_fill") } variant="contained" color="primary" component={ RouterLink }>
{ t('steps.personal-data.actions.form') }
</Button>
</> : <>
<p>{ t('steps.personal-data.all-filled') }</p>
<Actions>
<Button to={ route("user_profile") } variant="outlined" color="primary" component={ RouterLink } startIcon={ <AccountDetails /> }>
{ t('steps.personal-data.actions.info') }
</Button>
</Actions>
</> }
</Step>
}

31
src/pages/user/fill.tsx Normal file
View File

@ -0,0 +1,31 @@
import { useSelector } from "react-redux";
import { AppState } from "@/state/reducer";
import React from "react";
import { Page } from "@/pages/base";
import { useTranslation } from "react-i18next";
import { Container, Link, Typography } from "@material-ui/core";
import StudentForm from "@/forms/user";
import { Student } from "@/data";
import { Link as RouterLink } from "react-router-dom";
import { route } from "@/routing";
export const UserFillPage = () => {
const student = useSelector<AppState>(state => state.student) as Student;
const { t } = useTranslation();
return <Page>
<Page.Header maxWidth="md">
<Page.Breadcrumbs>
<Link component={ RouterLink } to={ route("home") }>{ t("pages.my-internship.header") }</Link>
<Typography color="textPrimary">{ t("pages.user-fill.title") }</Typography>
</Page.Breadcrumbs>
<Page.Title>{ t("pages.user-fill.title") }</Page.Title>
</Page.Header>
<Container>
<StudentForm student={ student } />
</Container>
</Page>
}
export default UserFillPage;

View File

@ -11,7 +11,7 @@ import api from "@/api";
import { UserActions } from "@/state/actions/user";
import { getAuthorizeUrl } from "@/api/user";
const authorizeUser = (code: string) => async (dispatch: Dispatch<Action>, getState: () => AppState): Promise<void> => {
const authorizeUser = (code?: string) => async (dispatch: Dispatch<Action>, getState: () => AppState): Promise<void> => {
const token = await api.user.login(code);
dispatch({
@ -34,7 +34,7 @@ export const UserLoginPage = () => {
const query = new URLSearchParams(useLocation().search);
const handleSampleLogin = async () => {
await dispatch(authorizeUser("test"));
await dispatch(authorizeUser());
history.push(route("home"));
}

View File

@ -0,0 +1,58 @@
import React from "react";
import { Page } from "@/pages/base";
import { useTranslation } from "react-i18next";
import { useCurrentStudent } from "@/hooks";
import { Box, Button, Container, Link, Paper, Typography } from "@material-ui/core";
import { Student } from "@/data";
import { Link as RouterLink } from "react-router-dom";
import { route } from "@/routing";
import { Actions } from "@/components";
import { useVerticalSpacing } from "@/styles";
type StudentPreviewProps = {
student: Student;
}
export const StudentPreview = ({ student }: StudentPreviewProps) => {
const { t } = useTranslation();
return <>
<Typography className="proposal__primary">{ student.name } { student.surname }</Typography>
<Typography className="proposal__secondary">
{ t('internship.intern.semester', { semester: student.semester }) }
{ ", " }
{ t('internship.intern.album', { album: student.albumNumber }) }
</Typography>
</>;
}
export const UserProfilePage = () => {
const { t } = useTranslation();
const student = useCurrentStudent() as Student;
const spacing = useVerticalSpacing(3);
return <Page>
<Page.Header>
<Page.Breadcrumbs>
<Link component={ RouterLink } to={ route("home") }>{ t("pages.my-internship.header") }</Link>
<Typography color="textPrimary">{ t("pages.user-profile.title") }</Typography>
</Page.Breadcrumbs>
<Page.Title>{ t('pages.user-profile.title') }</Page.Title>
</Page.Header>
<Container className={ spacing.root }>
<Paper>
<Box p={2}>
<StudentPreview student={ student } />
</Box>
</Paper>
<Actions>
<Button variant="contained" component={ RouterLink } to={ route("home") }>
{ t('go-back') }
</Button>
</Actions>
</Container>
</Page>
}
export default UserProfilePage;

View File

@ -8,6 +8,8 @@ import { UserLoginPage } from "@/pages/user/login";
import { RegisterEditionPage } from "@/pages/edition/register";
import PickEditionPage from "@/pages/edition/pick";
import { isLoggedInMiddleware, isReadyMiddleware } from "@/middleware";
import UserFillPage from "@/pages/user/fill";
import UserProfilePage from "@/pages/user/profile";
type Route = {
name?: string;
@ -30,7 +32,7 @@ export function processMiddlewares<TArgs extends any[]>(middleware: Middleware<a
}
export const routes: Route[] = [
{ name: "home", path: "/", exact: true, content: () => <MainPage/>, middlewares: [ isLoggedInMiddleware ] },
{ name: "home", path: "/", exact: true, content: () => <MainPage/>, middlewares: [ isReadyMiddleware ] },
// edition
{ name: "edition_register", path: "/edition/register", exact: true, content: () => <RegisterEditionPage/>, middlewares: [ isLoggedInMiddleware ] },
@ -43,6 +45,8 @@ export const routes: Route[] = [
// user
{ name: "user_login", path: "/user/login", content: () => <UserLoginPage/> },
{ name: "user_fill", path: "/user/data", content: () => <UserFillPage/>, middlewares: [ isLoggedInMiddleware ] },
{ name: "user_profile", path: "/user/profile", content: () => <UserProfilePage/>, middlewares: [ isLoggedInMiddleware ] },
// fallback route for 404 pages
{ name: "fallback", path: "*", content: () => <FallbackPage/> }

View File

@ -0,0 +1,31 @@
import { Serializable, SerializationTransformer } from "@/serialization/types";
import { Edition } from "@/data/edition";
import { momentSerializationTransformer } from "@/serialization/moment";
import { Moment } from "moment";
export const editionSerializationTransformer: SerializationTransformer<Edition> = {
transform(subject: Edition, context?: unknown): Serializable<Edition> {
return {
course: subject.course,
minimumInternshipHours: subject.minimumInternshipHours,
maximumInternshipHours: subject.maximumInternshipHours,
proposalDeadline: momentSerializationTransformer.transform(subject.proposalDeadline),
reportingEnd: momentSerializationTransformer.transform(subject.reportingEnd),
reportingStart: momentSerializationTransformer.transform(subject.reportingStart),
startDate: momentSerializationTransformer.transform(subject.startDate),
endDate: momentSerializationTransformer.transform(subject.endDate),
}
},
reverseTransform(subject: Serializable<Edition>, context?: unknown): Edition {
return {
course: subject.course,
minimumInternshipHours: subject.minimumInternshipHours,
maximumInternshipHours: subject.maximumInternshipHours,
proposalDeadline: momentSerializationTransformer.reverseTransform(subject.proposalDeadline) as Moment,
reportingEnd: momentSerializationTransformer.reverseTransform(subject.reportingEnd) as Moment,
reportingStart: momentSerializationTransformer.reverseTransform(subject.reportingStart) as Moment,
startDate: momentSerializationTransformer.reverseTransform(subject.startDate) as Moment,
endDate: momentSerializationTransformer.reverseTransform(subject.endDate) as Moment,
}
},
}

View File

@ -1,3 +1,4 @@
export * from "./internship"
export * from "./moment"
export * from "./types"
export * from "./edition"

View File

@ -1,14 +1,15 @@
import { Edition } from "@/data/edition";
import { EditionAction, EditionActions } from "@/state/actions/edition";
import { editionSerializationTransformer, Serializable } from "@/serialization";
export type EditionState = Edition | null;
export type EditionState = Serializable<Edition> | null;
const initialEditionState: EditionState = null;
const editionReducer = (state: EditionState = initialEditionState, action: EditionAction): EditionState => {
switch (action.type) {
case EditionActions.Set:
return action.edition;
return editionSerializationTransformer.transform(action.edition);
}
return state;

View File

@ -10,7 +10,7 @@ const store = createStore(
{
key: 'state',
storage: sessionStorage,
blacklist: ['edition']
blacklist: []
},
rootReducer
),

View File

@ -11,7 +11,7 @@ left: jeszcze {{ left, humanize }}
confirm: zatwierdź
go-back: wstecz
save: zapisz
make-changes: wprowadź zmiany
review: podgląd
fix-errors: popraw uwagi
@ -39,8 +39,22 @@ pages:
my-editions: "Moje praktyki"
pick: "wybierz"
register: "Zapisz się do edycji praktyk"
user-fill:
title: "Uzupełnij swoje dane"
user-profile:
title: "Moje dane"
forms:
student:
fields:
first-name: Imię
last-name: Nazwisko
email: Kontaktowy adres e-mail
album-number: Numer albumu
semester: Aktualny semestr studiów
sections:
personal: "Dane osobowe"
studies: "Dane kierunkowe"
internship:
fields:
start-date: Data rozpoczęcia praktyki
@ -127,11 +141,15 @@ internship:
steps:
personal-data:
header: "Uzupełnienie informacji"
header: "Uzupełnienie danych"
info: >
Twój profil jest niekompletny. W celu kontynuacji praktyki musisz uzupełnić informacje podane poniżej. Jeżeli masz
problem z uzupełnieniem tych informacji - skontaktuj się z pełnomocnikiem ds. praktyk dla Twojego kierunku.
form: "Uzupełnij dane"
all-filled: >
Wypełniłeś wszystkie wymagane informacje o sobie.
actions:
form: "Uzupełnij dane"
info: $t(pages.user-profile.title)
internship-proposal:
header: "Zgłoszenie praktyki"
info:

View File

@ -59,7 +59,7 @@ const config = {
port: parseInt(process.env.APP_PORT || "3000"),
proxy: {
"/api": {
target: "http://system-praktyk-front.localhost:8080/",
target: "https://system-praktyk.stg.kadet.net/api/",
changeOrigin: true,
pathRewrite: {
"^/api": ''