Allow overriding of state for testing purposes.

This commit is contained in:
Kacper Donat 2020-11-11 15:04:59 +01:00
parent 0ff80a454d
commit c13d880baa
12 changed files with 130 additions and 43 deletions

View File

@ -6,6 +6,7 @@ import { InternshipTypeDTO, internshipTypeDtoTransformer } from "@/api/dto/type"
import { Moment } from "moment-timezone"; import { Moment } from "moment-timezone";
import { sampleStudent } from "@/provider/dummy"; import { sampleStudent } from "@/provider/dummy";
import { UploadType } from "@/api/upload"; import { UploadType } from "@/api/upload";
import { ProgramEntryDTO, programEntryDtoTransformer } from "@/api/dto/edition";
export enum SubmissionState { export enum SubmissionState {
Draft = "Draft", Draft = "Draft",
@ -48,6 +49,7 @@ export interface InternshipRegistrationDTO extends Identifiable {
company: Company, company: Company,
branchAddress: Office, branchAddress: Office,
declaredHours: number, declaredHours: number,
subjects: { subject: ProgramEntryDTO }[],
} }
export interface InternshipDocument extends Identifiable { export interface InternshipDocument extends Identifiable {
@ -99,7 +101,7 @@ export const internshipRegistrationDtoTransformer: OneWayTransformer<InternshipR
hours: dto.declaredHours, hours: dto.declaredHours,
isAccepted: dto.state === SubmissionState.Accepted, isAccepted: dto.state === SubmissionState.Accepted,
lengthInWeeks: 0, lengthInWeeks: 0,
program: [], program: dto.subjects.map(subject => programEntryDtoTransformer.transform(subject.subject)),
intern: sampleStudent, // fixme intern: sampleStudent, // fixme
}; };
} }

View File

@ -1,14 +1,43 @@
import { InternshipInfoDTO, InternshipRegistrationUpdate } from "@/api/dto/internship-registration"; import { InternshipInfoDTO, InternshipRegistrationUpdate, SubmissionState } from "@/api/dto/internship-registration";
import { axios } from "@/api/index"; import { axios } from "@/api/index";
import { Nullable } from "@/helpers"; import { Nullable } from "@/helpers";
const INTERNSHIP_REGISTRATION_ENDPOINT = '/internshipRegistration'; const INTERNSHIP_REGISTRATION_ENDPOINT = '/internshipRegistration';
const INTERNSHIP_ENDPOINT = '/internship'; const INTERNSHIP_ENDPOINT = '/internship';
export async function update(internship: Nullable<InternshipRegistrationUpdate>): Promise<boolean> { export type ValidationMessage = {
await axios.put(INTERNSHIP_REGISTRATION_ENDPOINT, internship); key: string;
parameters: { [name: string]: string },
}
return true; export class ValidationError extends Error {
public readonly messages: ValidationMessage[];
constructor(messages: ValidationMessage[], message: string = "There were validation errors.") {
super(message);
Object.setPrototypeOf(this, ValidationError.prototype);
this.messages = messages;
}
}
interface UpdateResponse {
status: SubmissionState;
errors?: string[];
}
export async function update(internship: Nullable<InternshipRegistrationUpdate>): Promise<SubmissionState> {
const response = (await axios.put<UpdateResponse>(INTERNSHIP_REGISTRATION_ENDPOINT, internship)).data;
if (response.status == SubmissionState.Draft) {
throw new ValidationError(
response.errors?.map(
msg => ({ key: msg, parameters: {} })
) || []
);
}
return response.status;
} }
export async function get(): Promise<InternshipInfoDTO> { export async function get(): Promise<InternshipInfoDTO> {

View File

@ -88,7 +88,7 @@ function App() {
<nav className="header__bottom"> <nav className="header__bottom">
<ul className="header__menu header__menu--main"> <ul className="header__menu header__menu--main">
<li><Link to={ route("home") }>{ t("pages.my-internship.header") }</Link></li> <li><Link to={ route("home") }>{ t("pages.my-internship.header") }</Link></li>
<li><Link to="/regulamin">Regulamin</Link></li> <li><Link to="/regulations">Regulamin</Link></li>
</ul> </ul>
</nav> </nav>
</div> </div>

View File

@ -1,12 +1,13 @@
import { Internship } from "@/data"; import { Internship } from "@/data";
import React from "react"; import React from "react";
import { Typography } from "@material-ui/core"; import { List, Typography, ListItem, ListItemIcon, ListItemText } from "@material-ui/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import classNames from "classnames"; import classNames from "classnames";
import { useVerticalSpacing } from "@/styles"; import { useVerticalSpacing } from "@/styles";
import moment from "moment-timezone"; import moment from "moment-timezone";
import { Label, Section } from "@/components/section"; import { Label, Section } from "@/components/section";
import { StudentPreview } from "@/pages/user/profile"; import { StudentPreview } from "@/pages/user/profile";
import { Check, StickerCheck } from "mdi-material-ui";
export type ProposalPreviewProps = { export type ProposalPreviewProps = {
proposal: Internship; proposal: Internship;
@ -42,6 +43,16 @@ export const ProposalPreview = ({ proposal }: ProposalPreviewProps) => {
<Typography className="proposal__primary">{ proposal.type.label.pl }</Typography> <Typography className="proposal__primary">{ proposal.type.label.pl }</Typography>
</Section> </Section>
<Section>
<Label>{ t('internship.sections.program') }</Label>
<List>
{ proposal.program.map(subject => <ListItem key={ subject.id }>
<ListItemIcon><StickerCheck /></ListItemIcon>
<ListItemText>{ subject.description }</ListItemText>
</ListItem>) }
</List>
</Section>
<Section> <Section>
<Label>{ t('internship.sections.duration') }</Label> <Label>{ t('internship.sections.duration') }</Label>
<Typography className="proposal__primary"> <Typography className="proposal__primary">

View File

@ -1,16 +1,16 @@
import React, { HTMLProps, useEffect, useMemo, useState } from "react"; import React, { HTMLProps, useEffect, useMemo, useRef, useState } from "react";
import { import {
Button, Button,
Checkbox,
Dialog, Dialog,
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogContentText, DialogContentText,
FormControlLabel,
FormGroup,
Grid, Grid,
TextField, TextField,
Typography, Typography
FormGroup,
FormControlLabel,
Checkbox
} from "@material-ui/core"; } from "@material-ui/core";
import { KeyboardDatePicker as DatePicker } from "@material-ui/pickers"; import { KeyboardDatePicker as DatePicker } from "@material-ui/pickers";
import { CompanyForm } from "@/forms/company"; import { CompanyForm } from "@/forms/company";
@ -18,11 +18,11 @@ import { StudentForm } from "@/forms/student";
import { sampleStudent } from "@/provider/dummy/student"; import { sampleStudent } from "@/provider/dummy/student";
import { Company, Internship, InternshipProgramEntry, InternshipType, Office, Student } from "@/data"; import { Company, Internship, InternshipProgramEntry, InternshipType, Office, Student } from "@/data";
import { Nullable } from "@/helpers"; import { Nullable } from "@/helpers";
import moment, { Moment } from "moment-timezone"; import { Moment } from "moment-timezone";
import { computeWorkingHours } from "@/utils/date"; import { computeWorkingHours } from "@/utils/date";
import { Autocomplete } from "@material-ui/lab"; import { Alert, AlertTitle, Autocomplete } from "@material-ui/lab";
import { emptyInternship } from "@/provider/dummy/internship"; import { emptyInternship } from "@/provider/dummy/internship";
import { useDispatch } from "@/state/actions"; import { InternshipProposalActions, useDispatch } from "@/state/actions";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { AppState } from "@/state/reducer"; import { AppState } from "@/state/reducer";
@ -38,7 +38,7 @@ import { useCurrentEdition, useCurrentStudent, useInternshipTypes, useUpdateEffe
import { internshipRegistrationUpdateTransformer } from "@/api/dto/internship-registration"; import { internshipRegistrationUpdateTransformer } from "@/api/dto/internship-registration";
import api from "@/api"; import api from "@/api";
import FormLabel from "@material-ui/core/FormLabel"; import FormLabel from "@material-ui/core/FormLabel";
import { CheckBox } from "@material-ui/icons"; import { ValidationError, ValidationMessage } from "@/api/internship";
export type InternshipFormValues = { export type InternshipFormValues = {
startDate: Moment | null; startDate: Moment | null;
@ -145,7 +145,7 @@ const InternshipProgramForm = () => {
{ possibleProgramEntries.map( { possibleProgramEntries.map(
entry => <FormControlLabel entry => <FormControlLabel
control={ <Checkbox /> } control={ <Checkbox /> }
checked={ selectedProgramEntries.includes(entry) } checked={ selectedProgramEntries.find(cur => entry.id == cur.id) !== undefined }
onChange={ handleProgramEntryChange(entry) } onChange={ handleProgramEntryChange(entry) }
label={ entry.description } label={ entry.description }
key={ entry.id } key={ entry.id }
@ -182,17 +182,21 @@ const InternshipDurationForm = () => {
return ( return (
<Grid container> <Grid container>
<Grid item md={ 6 }> <Grid item md={ 6 }>
<DatePicker value={ startDate } onChange={ value => setFieldValue("startDate", value) } <DatePicker value={ startDate }
format="DD MMMM yyyy" onChange={ value => setFieldValue("startDate", value) }
format="DD.MM.yyyy"
disableToolbar fullWidth disableToolbar fullWidth
variant="inline" label={ t("forms.internship.fields.start-date") } variant="inline"
label={ t("forms.internship.fields.start-date") }
/> />
</Grid> </Grid>
<Grid item md={ 6 }> <Grid item md={ 6 }>
<DatePicker value={ endDate } onChange={ value => setFieldValue("endDate", value) } <DatePicker value={ endDate }
format="DD MMMM yyyy" onChange={ value => setFieldValue("endDate", value) }
format="DD.MM.yyyy"
disableToolbar fullWidth disableToolbar fullWidth
variant="inline" label={ t("forms.internship.fields.end-date") } variant="inline"
label={ t("forms.internship.fields.end-date") }
minDate={ startDate } minDate={ startDate }
/> />
</Grid> </Grid>
@ -287,7 +291,12 @@ const converter: Transformer<Nullable<Internship>, InternshipFormValues, Interns
} }
export const InternshipForm: React.FunctionComponent = () => { export const InternshipForm: React.FunctionComponent = () => {
const student = useCurrentStudent(); const student = useCurrentStudent();
const history = useHistory();
const root = useRef<HTMLElement>(null);
const dispatch = useDispatch();
const [errors, setErrors] = useState<ValidationMessage[]>([]);
const initialInternship = useSelector<AppState, Nullable<Internship>>(state => getInternshipProposal(state.proposal) || { const initialInternship = useSelector<AppState, Nullable<Internship>>(state => getInternshipProposal(state.proposal) || {
...emptyInternship, ...emptyInternship,
@ -336,17 +345,25 @@ export const InternshipForm: React.FunctionComponent = () => {
const values = converter.transform(initialInternship); const values = converter.transform(initialInternship);
const handleSubmit = (values: InternshipFormValues) => { const handleSubmit = async (values: InternshipFormValues) => {
setConfirmDialogOpen(false); setConfirmDialogOpen(false);
const internship = converter.reverseTransform(values, { internship: initialInternship as Internship }); const internship = converter.reverseTransform(values, { internship: initialInternship as Internship });
const update = internshipRegistrationUpdateTransformer.transform(internship); const update = internshipRegistrationUpdateTransformer.transform(internship);
console.log(update); try {
await api.internship.update(update);
dispatch({ type: InternshipProposalActions.Send });
api.internship.update(update); history.push(route("home"))
} catch (error) {
// history.push(route("home")) if (error instanceof ValidationError) {
setErrors(error.messages);
root.current?.scrollIntoView({ behavior: "smooth" })
} else {
throw error;
}
}
} }
const InnerForm = () => { const InnerForm = () => {
@ -366,10 +383,16 @@ export const InternshipForm: React.FunctionComponent = () => {
setConfirmDialogOpen(false); setConfirmDialogOpen(false);
} }
return <Form> return <Form ref={ root as any }>
{ errors.length > 0 && <Alert severity="warning">
<AlertTitle>{ t('internship.validation.has-errors') }</AlertTitle>
<ul style={{ paddingLeft: 0 }}>
{ errors.map(message => <li key={ message.key }>{ t(`internship.validation.${message.key}`, message.parameters) }</li>) }
</ul>
</Alert> }
<Typography variant="h3" className="section-header">{ t('internship.sections.intern-info') }</Typography> <Typography variant="h3" className="section-header">{ t('internship.sections.intern-info') }</Typography>
<StudentForm /> <StudentForm />
<Typography variant="h3" className="section-header">{ t('internship.sections.kind' )}</Typography> <Typography variant="h3" className="section-header">{ t('internship.sections.kind') }</Typography>
<InternshipProgramForm /> <InternshipProgramForm />
<Typography variant="h3" className="section-header">{ t('internship.sections.duration') }</Typography> <Typography variant="h3" className="section-header">{ t('internship.sections.duration') }</Typography>
<InternshipDurationForm /> <InternshipDurationForm />

View File

@ -5,6 +5,8 @@ import React, { useMemo } from "react";
import { route } from "@/routing"; import { route } from "@/routing";
import { useAsync } from "@/hooks"; import { useAsync } from "@/hooks";
import api from "@/api"; import api from "@/api";
import { Loading } from "@/components/loading";
import { useSpacing } from "@/styles";
export const FallbackPage = () => { export const FallbackPage = () => {
const location = useLocation(); const location = useLocation();
@ -13,23 +15,23 @@ export const FallbackPage = () => {
const { isLoading, value, error } = useAsync(promise); const { isLoading, value, error } = useAsync(promise);
console.log({ isLoading, value, error, location });
if (isLoading) { if (isLoading) {
return <CircularProgress /> return <div style={{ marginTop: "2rem" }}><Loading size="8rem"/></div>
} }
if (error) { if (error) {
return <Page title="Strona nie została znaleziona"> return <Page title="Strona nie została znaleziona">
<Container> <Container>
<Typography variant="h1">404</Typography> <Box my={4}>
<Typography variant="h2">Strona nie została znaleziona</Typography> <Typography variant="h1">404</Typography>
<Typography variant="h2">Strona nie została znaleziona</Typography>
<Box my={ 4 }> <Box my={ 4 }>
<Divider variant="fullWidth"/> <Divider variant="fullWidth"/>
</Box>
<Button to={ route("home") } color="primary" component={ RouterLink }>strona główna</Button>
</Box> </Box>
<Button to={ route("home") } color="primary" component={ RouterLink }>strona główna</Button>
</Container> </Container>
</Page> </Page>
} }

View File

@ -156,7 +156,7 @@ export const InternshipProposalPreviewPage = () => {
<DialogTitle>{ t("internship.accept.title") }</DialogTitle> <DialogTitle>{ t("internship.accept.title") }</DialogTitle>
<DialogContent className={ classes.root }> <DialogContent className={ classes.root }>
<Typography variant="body1">{ t("internship.accept.info") }</Typography> <Typography variant="body1">{ t("internship.accept.info") }</Typography>
<TextField multiline value={ comment } onChange={ ev => setComment(ev.target.value) } fullWidth label={ t("comments") }/> <TextField multiline value={ comment } onChange={ ev => setComment(ev.target.value) } fullWidth label={ t("comments") } rows={3}/>
<DialogActions> <DialogActions>
<Button onClick={ handleAcceptModalClose }> <Button onClick={ handleAcceptModalClose }>

View File

@ -17,7 +17,6 @@ export enum InternshipProposalActions {
} }
export interface SendProposalAction extends SendSubmissionAction<InternshipProposalActions.Send> { export interface SendProposalAction extends SendSubmissionAction<InternshipProposalActions.Send> {
internship: Internship;
} }
export interface ReceiveProposalApproveAction extends ReceiveSubmissionApproveAction<InternshipProposalActions.Approve> { export interface ReceiveProposalApproveAction extends ReceiveSubmissionApproveAction<InternshipProposalActions.Approve> {

View File

@ -10,6 +10,7 @@ import {
import { Reducer } from "react"; import { Reducer } from "react";
import { SubmissionAction } from "@/state/actions/submission"; import { SubmissionAction } from "@/state/actions/submission";
import { InternshipDocument, SubmissionState as ApiSubmissionState } from "@/api/dto/internship-registration"; import { InternshipDocument, SubmissionState as ApiSubmissionState } from "@/api/dto/internship-registration";
import { Api } from "mdi-material-ui";
export type InternshipPlanState = SubmissionState & MayRequireDeanApproval & { export type InternshipPlanState = SubmissionState & MayRequireDeanApproval & {
document: Serializable<InternshipDocument> | null; document: Serializable<InternshipDocument> | null;
@ -40,9 +41,14 @@ const internshipPlanReducer = (state: InternshipPlanState = defaultInternshipPla
document: action.document, document: action.document,
} }
case InternshipPlanActions.Receive: case InternshipPlanActions.Receive:
if (state.overwritten) {
return state;
}
return { return {
...state, ...state,
accepted: action.state === ApiSubmissionState.Accepted, accepted: action.state === ApiSubmissionState.Accepted,
declined: action.state === ApiSubmissionState.Rejected,
sent: [ sent: [
ApiSubmissionState.Accepted, ApiSubmissionState.Accepted,
ApiSubmissionState.Rejected, ApiSubmissionState.Rejected,

View File

@ -42,12 +42,16 @@ const internshipProposalReducer = (state: InternshipProposalState = defaultInter
case InternshipProposalActions.Send: case InternshipProposalActions.Send:
return { return {
...state, ...state,
proposal: internshipSerializationTransformer.transform(action.internship),
} }
case InternshipProposalActions.Receive: case InternshipProposalActions.Receive:
if (state.overwritten) {
return state;
}
return { return {
...state, ...state,
accepted: action.state === ApiSubmissionState.Accepted, accepted: action.state === ApiSubmissionState.Accepted,
declined: action.state === ApiSubmissionState.Rejected,
sent: [ sent: [
ApiSubmissionState.Accepted, ApiSubmissionState.Accepted,
ApiSubmissionState.Rejected, ApiSubmissionState.Rejected,

View File

@ -12,6 +12,7 @@ export type SubmissionState = {
sentOn: string | null; sentOn: string | null;
declined: boolean; declined: boolean;
comment: string | null; comment: string | null;
overwritten: boolean;
} }
export type MayRequireDeanApproval = { export type MayRequireDeanApproval = {
@ -24,6 +25,7 @@ export const defaultSubmissionState: SubmissionState = {
sentOn: null, sentOn: null,
declined: false, declined: false,
comment: null, comment: null,
overwritten: false,
} }
export const defaultDeanApprovalsState: MayRequireDeanApproval = { export const defaultDeanApprovalsState: MayRequireDeanApproval = {
@ -56,6 +58,7 @@ export function createSubmissionReducer<TState, TActionType, TAction extends Act
accepted: true, accepted: true,
declined: false, declined: false,
comment: (action as ReceiveSubmissionApproveAction<any>).comment, comment: (action as ReceiveSubmissionApproveAction<any>).comment,
overwritten: true,
} }
case SubmissionAction.Decline: case SubmissionAction.Decline:
return { return {
@ -63,6 +66,7 @@ export function createSubmissionReducer<TState, TActionType, TAction extends Act
accepted: false, accepted: false,
declined: true, declined: true,
comment: (action as ReceiveSubmissionDeclineAction<any>).comment, comment: (action as ReceiveSubmissionDeclineAction<any>).comment,
overwritten: true,
} }
case SubmissionAction.Send: case SubmissionAction.Send:
return { return {
@ -72,6 +76,7 @@ export function createSubmissionReducer<TState, TActionType, TAction extends Act
accepted: false, accepted: false,
declined: false, declined: false,
comment: null, comment: null,
overwritten: false,
} }
default: default:
return state; return state;

View File

@ -114,6 +114,11 @@ submission:
draft: "wersja robocza" draft: "wersja robocza"
internship: internship:
validation:
has-errors: "W formularzu zostały znalezione błędy"
error:
declared_hours:
empty: "Brak zadeklarowanej długości praktyki."
intern: intern:
semester: semestr {{ semester, roman }} semester: semestr {{ semester, roman }}
album: "numer albumu {{ album }}" album: "numer albumu {{ album }}"
@ -135,6 +140,7 @@ internship:
place: "Miejsce odbywania praktyki" place: "Miejsce odbywania praktyki"
kind: "Rodzaj i program praktyki" kind: "Rodzaj i program praktyki"
mentor: "Zakładowy opiekun praktyki" mentor: "Zakładowy opiekun praktyki"
program: "Realizowane punkty programu praktyki"
discard: discard:
title: "Odrzuć zgłoszenie praktyki" title: "Odrzuć zgłoszenie praktyki"
info: "Poniższa informacja zostanie przekazana praktykantowi w celu poprawy zgłoszenia." info: "Poniższa informacja zostanie przekazana praktykantowi w celu poprawy zgłoszenia."