diff --git a/src/data/company.ts b/src/data/company.ts index 5e6f2db..c799e1b 100644 --- a/src/data/company.ts +++ b/src/data/company.ts @@ -12,10 +12,10 @@ export interface Company extends Identifiable { name: string; url?: string; nip: string; - offices: BranchOffice[]; + offices: Office[]; } -export interface BranchOffice extends Identifiable { +export interface Office extends Identifiable { address: Address; } @@ -34,7 +34,7 @@ export const emptyAddress: Address = { building: "" } -export const emptyBranchOffice: BranchOffice = { +export const emptyBranchOffice: Office = { address: emptyAddress, } diff --git a/src/data/edition.ts b/src/data/edition.ts index 7fc5438..0de2052 100644 --- a/src/data/edition.ts +++ b/src/data/edition.ts @@ -4,6 +4,8 @@ export type Edition = { startDate: Moment; endDate: Moment; proposalDeadline: Moment; + minimumInternshipHours: number; + maximumInternshipHours?: number; } export type Deadlines = { diff --git a/src/data/internship.ts b/src/data/internship.ts index ace8c57..4cd82ea 100644 --- a/src/data/internship.ts +++ b/src/data/internship.ts @@ -1,7 +1,7 @@ import { Moment } from "moment"; import { Identifiable } from "./common"; import { Student } from "@/data/student"; -import { BranchOffice, Company } from "@/data/company"; +import { Company, Office } from "@/data/company"; export enum InternshipType { FreeInternship = "FreeInternship", @@ -64,7 +64,7 @@ export interface Internship extends Identifiable { hours: number; mentor: Mentor; company: Company; - office: BranchOffice; + office: Office; } export interface Plan extends Identifiable { diff --git a/src/forms/company.tsx b/src/forms/company.tsx index 4cfd2b8..74fbfdb 100644 --- a/src/forms/company.tsx +++ b/src/forms/company.tsx @@ -1,23 +1,13 @@ import React, { HTMLProps, useMemo } from "react"; -import { BranchOffice, Company, emptyAddress, emptyBranchOffice, emptyCompany, formatAddress, Mentor } from "@/data"; +import { Company, formatAddress, Office } from "@/data"; import { sampleCompanies } from "@/provider/dummy"; import { Autocomplete } from "@material-ui/lab"; import { Grid, TextField, Typography } from "@material-ui/core"; -import { BoundProperty, formFieldProps } from "./helpers"; -import { InternshipFormSectionProps } from "@/forms/internship"; -import { emptyMentor } from "@/provider/dummy/internship"; -import { useProxyState } from "@/hooks"; +import { InternshipFormValues } from "@/forms/internship"; import { useTranslation } from "react-i18next"; -import { Field } from "formik"; +import { Field, useFormikContext } from "formik"; import { TextField as TextFieldFormik } from "formik-material-ui" -export type CompanyFormProps = {} & InternshipFormSectionProps; - -export type BranchOfficeProps = { - disabled?: boolean; - offices?: BranchOffice[]; -} & BoundProperty - export const CompanyItem = ({ company, ...props }: { company: Company } & HTMLProps) => (
{ company.name }
@@ -25,43 +15,65 @@ export const CompanyItem = ({ company, ...props }: { company: Company } & HTMLPr
) -export const OfficeItem = ({ office, ...props }: { office: BranchOffice } & HTMLProps) => ( +export const OfficeItem = ({ office, ...props }: { office: Office } & HTMLProps) => (
{ office.address.city }
{ formatAddress(office.address) }
) -export const BranchForm: React.FC = ({ value: office, onChange: setOffice, offices = [], disabled = false }) => { - const canEdit = useMemo(() => !office.id && !disabled, [office.id, disabled]); - const fieldProps = formFieldProps(office.address, address => setOffice({ ...office, address })) +export const BranchForm: React.FC = () => { + const { values, errors, setValues, touched, setFieldTouched } = useFormikContext(); const { t } = useTranslation(); - const handleCityChange = (event: any, value: BranchOffice | string | null) => { + const disabled = useMemo(() => !values.companyName, [values.companyName]); + const offices = useMemo(() => values.company?.offices || [], [values.company]); + const canEdit = useMemo(() => !values.office && !disabled, [values.office, disabled]); + + const handleCityChange = (event: any, value: Office | string | null) => { if (typeof value === "string") { - setOffice({ - ...emptyBranchOffice, - address: { - ...emptyAddress, - city: value, - } - }); + setValues({ + ...values, + office: null, + city: value, + }, true); } else if (typeof value === "object" && value !== null) { - setOffice(value); - } else { - setOffice(emptyBranchOffice); + const office = value as Office; + + setValues({ + ...values, + office, + city: office.address.city, + country: office.address.country, + street: office.address.street, + building: office.address.building, + postalCode: office.address.postalCode, + }, true) + } else { // null + setValues({ + ...values, + office: null, + city: "", + country: "", + street: "", + building: "", + postalCode: "", + }, true) } } const handleCityInput = (event: any, value: string) => { - const base = office.id ? emptyBranchOffice : office; - setOffice({ - ...base, - address: { - ...base.address, - city: value, - } - }) + setValues( { + ...values, + office: null, + ...(values.office ? { + country: "", + street: "", + building: "", + postalCode: "", + } : { }), + city: value, + }, true); } return ( @@ -72,25 +84,34 @@ export const BranchForm: React.FC = ({ value: office, onChang disabled={ disabled } getOptionLabel={ office => typeof office == "string" ? office : office.address.city } renderOption={ office => } - renderInput={ props => } + renderInput={ + props => + + } onChange={ handleCityChange } onInputChange={ handleCityInput } - inputValue={ office.address.city } - value={ office.id ? office : null } + onBlur={ ev => setFieldTouched("city", true) } + inputValue={ values.city } + value={ values.office ? values.office : null } freeSolo /> - + - + - + - + @@ -102,42 +123,50 @@ export const MentorForm = () => { return ( - - + + - - + + - - + + - - + + ); } -export const CompanyForm: React.FunctionComponent = ({ internship, onChange }) => { - const [company, setCompany] = useProxyState(internship.company || emptyCompany, company => onChange({ ...internship, company })); - const [mentor, setMentor] = useProxyState(internship.mentor || emptyMentor, mentor => onChange({ ...internship, mentor })); - const [office, setOffice] = useProxyState(internship.office || emptyBranchOffice, office => onChange({ ...internship, office })); +export const CompanyForm: React.FunctionComponent = () => { + const { values, setValues, errors, touched, setFieldTouched } = useFormikContext(); const { t } = useTranslation(); - const canEdit = useMemo(() => !company.id, [company.id]); - - const fieldProps = formFieldProps(company, setCompany) + const canEdit = useMemo(() => !values.company, [values.company]); const handleCompanyChange = (event: any, value: Company | string | null) => { + setFieldTouched("companyName", true); + if (typeof value === "string") { - setCompany({ - ...emptyCompany, - name: value, - }); + setValues({ + ...values, + company: null, + companyName: value + }, true) } else if (typeof value === "object" && value !== null) { - setCompany(value); + setValues({ + ...values, + company: value as Company, + companyName: value.name, + companyNip: value.nip, + }, true) } else { - setCompany(emptyCompany); + setValues({ + ...values, + company: null, + companyName: "", + }, true); } } @@ -146,24 +175,22 @@ export const CompanyForm: React.FunctionComponent = ({ interns option.name } + getOptionLabel={ option => typeof option === "string" ? option : option.name } renderOption={ company => } - renderInput={ props => } - onChange={ handleCompanyChange } value={ company } + renderInput={ props => } + onChange={ handleCompanyChange } value={ values.company || values.companyName } freeSolo /> - + - {/**/} - {/* */} - {/**/} { t("internship.mentor") } - + { t("internship.office") } - + ) } diff --git a/src/forms/internship.tsx b/src/forms/internship.tsx index 7b487eb..7f0b103 100644 --- a/src/forms/internship.tsx +++ b/src/forms/internship.tsx @@ -1,15 +1,14 @@ -import React, { HTMLProps, useEffect, useMemo, useState } from "react"; +import React, { HTMLProps, useMemo, useState } from "react"; import { Button, Dialog, DialogActions, DialogContent, DialogContentText, Grid, TextField, Typography } from "@material-ui/core"; import { KeyboardDatePicker as DatePicker } from "@material-ui/pickers"; import { CompanyForm } from "@/forms/company"; import { StudentForm } from "@/forms/student"; import { sampleStudent } from "@/provider/dummy/student"; -import { BranchOffice, Company, Internship, InternshipType, internshipTypeLabels, Student } from "@/data"; +import { Company, Internship, InternshipType, internshipTypeLabels, Office, Student } from "@/data"; import { Nullable } from "@/helpers"; import moment, { Moment } from "moment"; import { computeWorkingHours } from "@/utils/date"; import { Autocomplete } from "@material-ui/lab"; -import { formFieldProps } from "@/forms/helpers"; import { emptyInternship } from "@/provider/dummy/internship"; import { InternshipProposalActions, useDispatch } from "@/state/actions"; import { useTranslation } from "react-i18next"; @@ -17,28 +16,26 @@ import { useSelector } from "react-redux"; import { AppState } from "@/state/reducer"; import { Link as RouterLink, useHistory } from "react-router-dom"; import { route } from "@/routing"; -import { useProxyState } from "@/hooks"; import { getInternshipProposal } from "@/state/reducer/proposal"; import { Actions } from "@/components"; -import { Form, Formik } from "formik"; +import { Field, Form, Formik, useFormikContext } from "formik"; import * as Yup from "yup"; +import { Transformer } from "@/serialization"; +import { TextField as TextFieldFormik } from "formik-material-ui" +import { Edition } from "@/data/edition"; +import { useUpdateEffect } from "@/hooks"; -export type InternshipFormProps = {} - -export type InternshipFormSectionProps = { - internship: Nullable, - onChange: (internship: Nullable) => void, -} - -export type InternshipFormState = { +export type InternshipFormValues = { startDate: Moment | null; endDate: Moment | null; - hours: number | null; + hours: number | ""; + workingHours: number; companyName: string; companyNip: string; city: string; postalCode: string; country: string; + street: string; building: string; mentorFirstName: string; mentorLastName: string; @@ -49,19 +46,20 @@ export type InternshipFormState = { // relations kind: InternshipType | null; company: Company | null; - office: BranchOffice | null; - student: Student | null; + office: Office | null; + student: Student; } -const emptyInternshipValues: InternshipFormState = { +const emptyInternshipValues: InternshipFormValues = { building: "", city: "", company: null, companyName: "", companyNip: "", country: "", + street: "", endDate: null, - hours: null, + hours: "", kind: null, kindOther: "", mentorEmail: "", @@ -71,7 +69,8 @@ const emptyInternshipValues: InternshipFormState = { office: null, postalCode: "", startDate: null, - student: null + student: sampleStudent, + workingHours: 40, } export const InternshipTypeItem = ({ type, ...props }: { type: InternshipType } & HTMLProps) => { @@ -85,89 +84,94 @@ export const InternshipTypeItem = ({ type, ...props }: { type: InternshipType } ) } -const InternshipProgramForm = ({ internship, onChange }: InternshipFormSectionProps) => { - const fieldProps = formFieldProps(internship, onChange); - +const InternshipProgramForm = () => { const { t } = useTranslation(); + const { values, handleBlur, setFieldValue, errors } = useFormikContext(); return ( - } + } getOptionLabel={ (option: InternshipType) => internshipTypeLabels[option].label } renderOption={ (option: InternshipType) => } options={ Object.values(InternshipType) as InternshipType[] } disableClearable - { ...fieldProps("type", (event, value) => value) as any } + value={ values.kind || undefined } + onChange={ (_, value) => setFieldValue("kind", value) } + onBlur={ handleBlur } /> - { internship.type === InternshipType.Other && } + { + values.kind === InternshipType.Other && + + } - {/**/ } - {/* */ } - {/* Realizowane punkty programu praktyk (minimum 3)*/ } - {/* { course.possibleProgramEntries.map(entry => {*/ } - {/* return (*/ } - {/* }*/ } - {/* />*/ } - {/* )*/ } - {/* }) }*/ } - {/* */ } - {/**/ } ) } -const InternshipDurationForm = ({ internship, onChange }: InternshipFormSectionProps) => { +const InternshipDurationForm = () => { const { t } = useTranslation(); - - const [startDate, setStartDate] = useProxyState(internship.startDate, value => onChange({ ...internship, startDate: value })); - const [endDate, setEndDate] = useProxyState(internship.endDate, value => onChange({ ...internship, endDate: value })); + const { + values: { startDate, endDate, workingHours }, + errors, + touched, + setFieldTouched, + setFieldValue + } = useFormikContext(); const [overrideHours, setHoursOverride] = useState(null) - const [workingHours, setWorkingHours] = useState(40) const computedHours = useMemo(() => startDate && endDate && computeWorkingHours(startDate, endDate, workingHours / 5), [startDate, endDate, workingHours]); const hours = useMemo(() => overrideHours !== null ? overrideHours : computedHours || null, [overrideHours, computedHours]); - const weeks = useMemo(() => hours !== null ? Math.floor(hours / 40) : null, [hours]); + const weeks = useMemo(() => hours !== null ? Math.floor(hours / workingHours) : null, [ hours ]); - useEffect(() => onChange({ ...internship, hours }), [hours]) + useUpdateEffect(() => { + setFieldTouched("hours", true); + setFieldValue("hours", hours, true); + }, [ hours ]); return ( - setFieldValue("startDate", value) } format="DD MMMM yyyy" - clearable disableToolbar fullWidth + disableToolbar fullWidth variant="inline" label={ t("forms.internship.fields.start-date") } minDate={ moment() } /> - setFieldValue("endDate", value) } format="DD MMMM yyyy" - clearable disableToolbar fullWidth + disableToolbar fullWidth variant="inline" label={ t("forms.internship.fields.end-date") } minDate={ startDate || moment() } /> - setWorkingHours(parseInt(ev.target.value) || 0) } - helperText={ t("forms.internship.help.working-hours") } + - setHoursOverride(parseInt(ev.target.value) || 0) } + setHoursOverride(parseInt(ev.target.value) || 0) } /> @@ -175,36 +179,86 @@ const InternshipDurationForm = ({ internship, onChange }: InternshipFormSectionP ); } -export const InternshipForm: React.FunctionComponent = props => { - const initialInternshipState = useSelector>(state => getInternshipProposal(state.proposal) || { +type InternshipConverterContext = { + internship: Internship, +} + +const converter: Transformer, InternshipFormValues, InternshipConverterContext> = { + transform(internship: Nullable): InternshipFormValues { + return { + student: internship.intern as Student, + kind: internship.type, + kindOther: "", + startDate: internship.startDate, + endDate: internship.endDate, + hours: internship.hours || "", + building: internship.office?.address?.building || "", + office: internship.office, + city: internship.office?.address?.city || "", + postalCode: internship.office?.address?.postalCode || "", + street: internship.office?.address?.street || "", + country: internship.office?.address?.country || "", + company: internship.company, + companyName: internship.company?.name || "", + companyNip: internship.company?.nip || "", + mentorEmail: internship.mentor?.email || "", + mentorFirstName: internship.mentor?.name || "", + mentorLastName: internship.mentor?.surname || "", + mentorPhone: internship.mentor?.phone || "", + workingHours: 40, + } + }, + reverseTransform(form: InternshipFormValues, context: InternshipConverterContext): Nullable { + return { + ...context.internship, + startDate: form.startDate as Moment, + endDate: form.endDate as Moment, + office: form.office || { + address: { + street: form.street, + postalCode: form.postalCode, + country: form.country, + city: form.city, + building: form.building, + } + }, + mentor: { + surname: form.mentorLastName, + name: form.mentorFirstName, + email: form.mentorEmail, + phone: form.mentorPhone, + }, + company: form.company || { + name: form.companyName, + nip: form.companyNip, + offices: [], + }, + hours: form.hours as number, + type: form.kind as InternshipType, + } + } +} + +export const InternshipForm: React.FunctionComponent = () => { + const initialInternship = useSelector>(state => getInternshipProposal(state.proposal) || { ...emptyInternship, + office: null, + company: null, + mentor: null, intern: sampleStudent }); - const [internship, setInternship] = useState>(initialInternshipState) + const edition = useSelector(state => state.edition as Edition); + const { t } = useTranslation(); + const dispatch = useDispatch(); const history = useHistory(); const [confirmDialogOpen, setConfirmDialogOpen] = useState(false); - const handleSubmit = () => { - setConfirmDialogOpen(false); - - dispatch({ type: InternshipProposalActions.Send, internship: internship as Internship }); - history.push(route("home")) - } - - const handleSubmitConfirmation = () => { - setConfirmDialogOpen(true); - } - - const handleCancel = () => { - setConfirmDialogOpen(false); - } - - const validationSchema = Yup.object>({ + const validationSchema = Yup.object>({ mentorFirstName: Yup.string().required(t("validation.required")), mentorLastName: Yup.string().required(t("validation.required")), mentorEmail: Yup.string() @@ -214,39 +268,94 @@ export const InternshipForm: React.FunctionComponent = prop .required(t("validation.required")) .matches(/^[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\./0-9]*$/, t("validation.phone")), hours: Yup.number() - .min(40, t("validation.internship.minimum-hours")) // todo: take it from edition + .min(edition.minimumInternshipHours, t("validation.internship.minimum-hours", { hours: edition.minimumInternshipHours })), + companyName: Yup.string().when("company", { + is: null, + then: Yup.string().required(t("validation.required")) + }), + companyNip: Yup.string().when("company", { + is: null, + then: Yup.string() + .required(t("validation.required")) + }), + street: Yup.string().required(t("validation.required")), + country: Yup.string().required(t("validation.required")), + city: Yup.string().required(t("validation.required")), + postalCode: Yup.string().required(t("validation.required")), + building: Yup.string().required(t("validation.required")), + kindOther: Yup.string().when("kind", { + is: (values: InternshipFormValues) => values?.kind !== InternshipType.Other, + then: Yup.string().required(t("validation.required")) + }) }) + const values = converter.transform(initialInternship); + + const handleSubmit = (values: InternshipFormValues) => { + setConfirmDialogOpen(false); + + dispatch({ + type: InternshipProposalActions.Send, + internship: converter.reverseTransform(values, { + internship: initialInternship as Internship, + }) as Internship + }); + + history.push(route("home")) + } + + const InnerForm = () => { + const { submitForm, validateForm } = useFormikContext(); + + const handleSubmitConfirmation = async () => { + const errors = await validateForm(); + + if (Object.keys(errors).length == 0) { + setConfirmDialogOpen(true); + } + } + + const handleCancel = () => { + setConfirmDialogOpen(false); + } + + return
+ { t('internship.sections.intern-info') } + + { t('internship.sections.kind' )} + + { t('internship.sections.duration') } + + { t('internship.sections.place') } + + + + + + + + + + { t('forms.internship.send-confirmation') } + + + + + + + + } + return ( - console.log(values) } - validationSchema={ validationSchema } validateOnChange={ false } validateOnBlur={ true }> - { formik =>
- { t('internship.sections.intern-info') } - - { t('internship.sections.kind' )} - - { t('internship.sections.duration') } - - { t('internship.sections.place') } - - - - - - - - - - { t('forms.internship.send-confirmation') } - - - - - - - } + + ) } diff --git a/src/forms/student.tsx b/src/forms/student.tsx index db1ec6e..1c8ee26 100644 --- a/src/forms/student.tsx +++ b/src/forms/student.tsx @@ -1,16 +1,15 @@ -import { Course, Student } from "@/data"; +import { Course } from "@/data"; import { Button, Grid, TextField } from "@material-ui/core"; import { Alert, Autocomplete } from "@material-ui/lab"; import React from "react"; import { sampleCourse } from "@/provider/dummy/student"; import { useTranslation } from "react-i18next"; +import { useFormikContext } from "formik"; +import { InternshipFormValues } from "@/forms/internship"; -type StudentFormProps = { - student: Student -} - -export const StudentForm = ({ student }: StudentFormProps) => { +export const StudentForm = () => { const { t } = useTranslation(); + const { values: { student } } = useFormikContext(); return <> diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 283d0e1..9e7f847 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1 +1,2 @@ export * from "./useProxyState" +export * from "./useUpdateEffect" diff --git a/src/hooks/useUpdateEffect.ts b/src/hooks/useUpdateEffect.ts new file mode 100644 index 0000000..939b1c0 --- /dev/null +++ b/src/hooks/useUpdateEffect.ts @@ -0,0 +1,15 @@ +import { DependencyList, EffectCallback, useEffect, useRef } from "react"; + +export const useUpdateEffect = (effect: EffectCallback, dependencies: DependencyList) => { + const flag = useRef(false); + + useEffect(() => { + if (flag.current) { + effect(); + } else { + flag.current = true; + } + }, dependencies) +} + +export default useUpdateEffect; diff --git a/src/provider/dummy/edition.ts b/src/provider/dummy/edition.ts index 290a3ac..e53bd4e 100644 --- a/src/provider/dummy/edition.ts +++ b/src/provider/dummy/edition.ts @@ -4,5 +4,6 @@ import moment from "moment"; export const sampleEdition: Edition = { startDate: moment("2020-07-01"), endDate: moment("2020-09-30"), - proposalDeadline: moment("2020-07-31") + proposalDeadline: moment("2020-07-31"), + minimumInternshipHours: 40, } diff --git a/src/serialization/types.ts b/src/serialization/types.ts index 94e443d..b7528c6 100644 --- a/src/serialization/types.ts +++ b/src/serialization/types.ts @@ -10,9 +10,9 @@ type Simplify = string | T extends Object ? Serializable : any; export type Serializable = { [K in keyof T]: Simplify } -export type Transformer = { - transform(subject: TFrom): TResult; - reverseTransform(subject: TResult): TFrom; +export type Transformer = { + transform(subject: TFrom, context?: TContext): TResult; + reverseTransform(subject: TResult, context?: TContext): TFrom; } export type SerializationTransformer> = Transformer diff --git a/translations/pl.yaml b/translations/pl.yaml index 8fc6115..9bc83eb 100644 --- a/translations/pl.yaml +++ b/translations/pl.yaml @@ -163,5 +163,7 @@ validation: required: "To pole jest wymagane" email: "Wprowadź poprawny adres e-mail" phone: "Wprowadź poprawny numer telefonu" + internship: + minimum-hours: "Minimalna liczba godzin wynosi {{ hours }}" contact-coordinator: "Skontaktuj się z koordynatorem"