From ee070bc62ca14f350a37791044404231773c4002 Mon Sep 17 00:00:00 2001 From: Kacper Donat <kadet1090@gmail.com> Date: Tue, 11 Aug 2020 21:02:06 +0200 Subject: [PATCH 1/5] Add accept and discard actions for proposal --- src/components/actions.tsx | 2 +- src/components/proposalPreview.tsx | 13 ++-------- src/i18n.ts | 9 +++++-- src/pages/internship/proposal.tsx | 39 +++++++++++++++++++++++++++--- translations/pl.yaml | 4 ++- 5 files changed, 49 insertions(+), 18 deletions(-) diff --git a/src/components/actions.tsx b/src/components/actions.tsx index 34357c3..b2e090e 100644 --- a/src/components/actions.tsx +++ b/src/components/actions.tsx @@ -2,7 +2,7 @@ import React, { HTMLProps } from "react"; import { useHorizontalSpacing } from "@/styles"; export const Actions = (props: HTMLProps<HTMLDivElement>) => { - const classes = useHorizontalSpacing(1); + const classes = useHorizontalSpacing(2); return <div className={ classes.root } { ...props }/> } diff --git a/src/components/proposalPreview.tsx b/src/components/proposalPreview.tsx index d0b8870..f57798f 100644 --- a/src/components/proposalPreview.tsx +++ b/src/components/proposalPreview.tsx @@ -1,12 +1,9 @@ import { Internship, internshipTypeLabels } from "@/data"; import React from "react"; -import { Button, Paper, PaperProps, Typography, TypographyProps } from "@material-ui/core"; +import { Paper, PaperProps, Typography, TypographyProps } from "@material-ui/core"; import { useTranslation } from "react-i18next"; import { createStyles, makeStyles } from "@material-ui/core/styles"; import classNames from "classnames"; -import { Link as RouterLink } from "react-router-dom"; -import { route } from "@/routing"; -import { Actions } from "@/components/actions"; import { useVerticalSpacing } from "@/styles"; import moment from "moment"; @@ -74,7 +71,7 @@ export const ProposalPreview = ({ proposal }: ProposalPreviewProps) => { { t('internship.date-range', { start: proposal.startDate, end: proposal.endDate }) } </Typography> <Typography className="proposal__secondary"> - { t('internship.duration', { duration }) } + { t('internship.duration', { duration, count: Math.floor(duration.asWeeks()) }) } { ", " } { t('internship.hours', { hours: proposal.hours }) } </Typography> @@ -85,11 +82,5 @@ export const ProposalPreview = ({ proposal }: ProposalPreviewProps) => { <Typography className="proposal__primary">{ proposal.mentor.name } { proposal.mentor.surname }</Typography> <Typography className="proposal__secondary">{ proposal.mentor.email }, { proposal.mentor.phone }</Typography> </Section> - - <Actions> - <Button component={ RouterLink } to={ route("home") } variant="contained" color="primary"> - { t('go-back') } - </Button> - </Actions> </div> } diff --git a/src/i18n.ts b/src/i18n.ts index c01bc57..84cc091 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -4,7 +4,7 @@ import I18nextBrowserLanguageDetector from "i18next-browser-languagedetector"; import "moment/locale/pl" import "moment/locale/en-gb" -import moment, { isDuration, isMoment } from "moment"; +import moment, { isDuration, isMoment, unitOfTime } from "moment"; import { convertToRoman } from "@/utils/numbers"; const resources = { @@ -22,6 +22,7 @@ i18n .init({ resources, fallbackLng: "pl", + compatibilityJSON: "v3", interpolation: { escapeValue: false, format: (value, format, lng) => { @@ -34,7 +35,11 @@ i18n } if (isDuration(value)) { - return value.locale(lng || "pl").humanize(); + if (format === "humanize") { + return value.locale(lng || "pl").humanize(); + } else { + return Math.floor(value.locale(lng || "pl").as(format as unitOfTime.Base)); + } } return value; diff --git a/src/pages/internship/proposal.tsx b/src/pages/internship/proposal.tsx index 09b8ca8..3e92aea 100644 --- a/src/pages/internship/proposal.tsx +++ b/src/pages/internship/proposal.tsx @@ -1,6 +1,6 @@ import { Page } from "@/pages/base"; -import { Container, Link, Typography } from "@material-ui/core"; -import { Link as RouterLink } from "react-router-dom"; +import { Button, Container, Link, Typography } from "@material-ui/core"; +import { Link as RouterLink, useHistory } from "react-router-dom"; import { route } from "@/routing"; import { InternshipForm } from "@/forms/internship"; import React from "react"; @@ -11,6 +11,10 @@ import { useSelector } from "react-redux"; import { Internship } from "@/data"; import { AppState } from "@/state/reducer"; import { internshipSerializationTransformer } from "@/serialization"; +import { Actions } from "@/components"; +import { InternshipProposalActions, useDispatch } from "@/state/actions"; +import { StickerCheckOutline, StickerRemoveOutline } from "mdi-material-ui/index"; +import { useVerticalSpacing } from "@/styles"; export const InternshipProposalFormPage = () => { return <Page title="Zgłoszenie praktyki"> @@ -32,6 +36,21 @@ export const InternshipProposalPreviewPage = () => { const { t } = useTranslation(); const proposal = useSelector<AppState, Internship | null>(state => state.proposal.proposal && internshipSerializationTransformer.reverseTransform(state.proposal.proposal)); + const dispatch = useDispatch(); + const history = useHistory(); + + const handleAccept = () => { + dispatch({ type: InternshipProposalActions.Approve, comment: null }); + history.push(route("home")); + } + + const handleDiscard = () => { + dispatch({ type: InternshipProposalActions.Decline, comment: "Well..." }); + history.push(route("home")); + } + + const classes = useVerticalSpacing(3); + return <Page title={ t("") }> <Page.Header maxWidth="md"> <Page.Breadcrumbs> @@ -40,9 +59,23 @@ export const InternshipProposalPreviewPage = () => { </Page.Breadcrumbs> <Page.Title>Moje zgłoszenie</Page.Title> </Page.Header> - <Container maxWidth={ "md" }> + <Container maxWidth={ "md" } className={ classes.root }> <ProposalComment /> { proposal && <ProposalPreview proposal={ proposal } /> } + + <Actions> + <Button component={ RouterLink } to={ route("home") } variant="contained" color="primary"> + { t('go-back') } + </Button> + + <Button onClick={ handleAccept } color="primary" startIcon={ <StickerCheckOutline /> }> + { t('accept') } + </Button> + + <Button onClick={ handleDiscard } color="secondary" startIcon={ <StickerRemoveOutline /> }> + { t('discard') } + </Button> + </Actions> </Container> </Page> } diff --git a/translations/pl.yaml b/translations/pl.yaml index c52c43d..1f35c9d 100644 --- a/translations/pl.yaml +++ b/translations/pl.yaml @@ -57,7 +57,9 @@ internship: semester: semestr {{ semester, roman }} album: "numer albumu {{ album }}" date-range: "{{ start, DD MMMM YYYY }} - {{ end, DD MMMM YYYY }}" - duration: "{{ duration, humanize }}" + duration_2: "{{ duration, weeks }} tygodni" + duration_0: "{{ duration, weeks }} tydzień" + duration_1: "{{ count }} tygodnie" hours: "{{ hours }} godzin" office: "Oddział / adres" address: -- 2.45.2 From a75e6957ea727731c187acef362f282b38c01fbf Mon Sep 17 00:00:00 2001 From: Kacper Donat <kadet1090@gmail.com> Date: Wed, 12 Aug 2020 20:43:08 +0200 Subject: [PATCH 2/5] Add possibility to make comments to proposal --- package.json | 1 + src/components/actions.tsx | 2 +- src/components/proposalPreview.tsx | 1 - src/pages/internship/proposal.tsx | 107 ++++++++++++++++++++++++++--- src/ui/theme.ts | 3 + translations/pl.yaml | 11 ++- yarn.lock | 37 ++++++++++ 7 files changed, 150 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 1379ef1..4b1d642 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "css-loader": "3.4.2", "date-holidays": "^1.5.3", "file-loader": "4.3.0", + "formik": "^2.1.5", "html-webpack-plugin": "4.0.0-beta.11", "i18next": "^19.6.0", "i18next-browser-languagedetector": "^5.0.0", diff --git a/src/components/actions.tsx b/src/components/actions.tsx index b2e090e..79ce5e2 100644 --- a/src/components/actions.tsx +++ b/src/components/actions.tsx @@ -4,5 +4,5 @@ import { useHorizontalSpacing } from "@/styles"; export const Actions = (props: HTMLProps<HTMLDivElement>) => { const classes = useHorizontalSpacing(2); - return <div className={ classes.root } { ...props }/> + return <div className={ classes.root } { ...props } style={{ display: "flex", alignItems: "center" }}/> } diff --git a/src/components/proposalPreview.tsx b/src/components/proposalPreview.tsx index f57798f..1b8a46a 100644 --- a/src/components/proposalPreview.tsx +++ b/src/components/proposalPreview.tsx @@ -33,7 +33,6 @@ export const ProposalPreview = ({ proposal }: ProposalPreviewProps) => { const { t } = useTranslation(); const classes = useVerticalSpacing(3); - const duration = moment.duration(proposal.endDate.diff(proposal.startDate)); return <div className={ classNames("proposal", classes.root) }> diff --git a/src/pages/internship/proposal.tsx b/src/pages/internship/proposal.tsx index 3e92aea..f9767bd 100644 --- a/src/pages/internship/proposal.tsx +++ b/src/pages/internship/proposal.tsx @@ -1,9 +1,22 @@ import { Page } from "@/pages/base"; -import { Button, Container, Link, Typography } from "@material-ui/core"; +import { + Button, + ButtonGroup, + Container, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Link, + Menu, + MenuItem, + TextField, + Typography +} from "@material-ui/core"; import { Link as RouterLink, useHistory } from "react-router-dom"; import { route } from "@/routing"; import { InternshipForm } from "@/forms/internship"; -import React from "react"; +import React, { useState } from "react"; import { ProposalComment } from "@/pages/steps/proposal"; import { useTranslation } from "react-i18next"; import { ProposalPreview } from "@/components/proposalPreview"; @@ -13,7 +26,7 @@ import { AppState } from "@/state/reducer"; import { internshipSerializationTransformer } from "@/serialization"; import { Actions } from "@/components"; import { InternshipProposalActions, useDispatch } from "@/state/actions"; -import { StickerCheckOutline, StickerRemoveOutline } from "mdi-material-ui/index"; +import { MenuDown, StickerCheckOutline, StickerRemoveOutline } from "mdi-material-ui/index"; import { useVerticalSpacing } from "@/styles"; export const InternshipProposalFormPage = () => { @@ -39,13 +52,49 @@ export const InternshipProposalPreviewPage = () => { const dispatch = useDispatch(); const history = useHistory(); + const [isDiscardModalOpen, setDiscardModelOpen] = useState<boolean>(false); + const [isAcceptModalOpen, setAcceptModelOpen] = useState<boolean>(false); + + const [comment, setComment] = useState<string>(""); + const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null); + const handleAccept = () => { - dispatch({ type: InternshipProposalActions.Approve, comment: null }); + dispatch({ type: InternshipProposalActions.Approve, comment }); history.push(route("home")); } const handleDiscard = () => { - dispatch({ type: InternshipProposalActions.Decline, comment: "Well..." }); + dispatch({ type: InternshipProposalActions.Decline, comment }); + history.push(route("home")); + } + + const handleAcceptModalClose = () => { + setAcceptModelOpen(false); + } + + const handleDiscardModalClose = () => { + setDiscardModelOpen(false); + } + + const handleDiscardAction = () => { + setDiscardModelOpen(true); + } + + const handleAcceptMenuOpen = (ev: React.MouseEvent<HTMLElement>) => { + setMenuAnchor(ev.currentTarget); + } + + const handleAcceptMenuClose = () => { + setMenuAnchor(null); + } + + const handleAcceptWithComment = () => { + setAcceptModelOpen(true); + setMenuAnchor(null); + } + + const handleAcceptWithoutComment = () => { + dispatch({ type: InternshipProposalActions.Approve, comment: null }); history.push(route("home")); } @@ -68,15 +117,55 @@ export const InternshipProposalPreviewPage = () => { { t('go-back') } </Button> - <Button onClick={ handleAccept } color="primary" startIcon={ <StickerCheckOutline /> }> - { t('accept') } - </Button> + <ButtonGroup color="primary" variant="contained"> + <Button onClick={ handleAcceptWithoutComment } startIcon={ <StickerCheckOutline /> }> + { t('accept-without-comments') } + </Button> + <Button size="small" onClick={ handleAcceptMenuOpen }><MenuDown /></Button> + </ButtonGroup> - <Button onClick={ handleDiscard } color="secondary" startIcon={ <StickerRemoveOutline /> }> + <Menu open={ !!menuAnchor } anchorEl={ menuAnchor } onClose={ handleAcceptMenuClose }> + <MenuItem onClick={ handleAcceptWithoutComment }>{ t("accept-without-comments") }</MenuItem> + <MenuItem onClick={ handleAcceptWithComment }>{ t("accept-with-comments") }</MenuItem> + </Menu> + + <Button onClick={ handleDiscardAction } color="secondary" startIcon={ <StickerRemoveOutline /> }> { t('discard') } </Button> </Actions> </Container> + <Dialog open={ isDiscardModalOpen } onClose={ handleDiscardModalClose } maxWidth="md"> + <DialogTitle>{ t("internship.discard.title") }</DialogTitle> + <DialogContent className={ classes.root }> + <Typography variant="body1">{ t("internship.discard.info") }</Typography> + <TextField multiline value={ comment } onChange={ ev => setComment(ev.target.value) } fullWidth label={ t("comments") } rows={3}/> + + <DialogActions> + <Button onClick={ handleDiscardModalClose }> + { t('cancel') } + </Button> + <Button onClick={ handleDiscard } color="primary" variant="contained"> + { t('confirm') } + </Button> + </DialogActions> + </DialogContent> + </Dialog> + <Dialog open={ isAcceptModalOpen } onClose={ handleAcceptModalClose } maxWidth="md"> + <DialogTitle>{ t("internship.accept.title") }</DialogTitle> + <DialogContent className={ classes.root }> + <Typography variant="body1">{ t("internship.accept.info") }</Typography> + <TextField multiline value={ comment } onChange={ ev => setComment(ev.target.value) } fullWidth label={ t("comments") }/> + + <DialogActions> + <Button onClick={ handleAcceptModalClose }> + { t('cancel') } + </Button> + <Button onClick={ handleAccept } color="primary" variant="contained"> + { t('confirm') } + </Button> + </DialogActions> + </DialogContent> + </Dialog> </Page> } diff --git a/src/ui/theme.ts b/src/ui/theme.ts index 8f770f0..0cb8e31 100644 --- a/src/ui/theme.ts +++ b/src/ui/theme.ts @@ -8,6 +8,9 @@ export const studentTheme = responsiveFontSizes(createMuiTheme({ }, MuiContainer: { maxWidth: "md" + }, + MuiTextField: { + variant: "outlined" } }, palette: { diff --git a/translations/pl.yaml b/translations/pl.yaml index 1f35c9d..6547b33 100644 --- a/translations/pl.yaml +++ b/translations/pl.yaml @@ -20,6 +20,10 @@ comments: Zgłoszone uwagi send-again: wyślij ponownie cancel: anuluj +accept: zaakceptuj +accept-with-comments: zaakceptuj z uwagami +accept-without-comments: zaakceptuj bez uwag +discard: zgłoś uwagi dropzone: "Przeciągnij i upuść plik bądź kliknij, aby wybrać" @@ -71,7 +75,12 @@ internship: place: "Miejsce odbywania praktyki" kind: "Rodzaj i program praktyki" mentor: "Zakładowy opiekun praktyki" - + discard: + title: "Odrzuć zgłoszenie praktyki" + info: "Poniższa informacja zostanie przekazana praktykantowi w celu poprawy zgłoszenia." + accept: + title: "Zaakceptuj zgłoszenie praktyki" + info: "Poniższa informacja zostanie przekazana praktykantowi." steps: personal-data: diff --git a/yarn.lock b/yarn.lock index 697aea8..4ed4f49 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3170,6 +3170,11 @@ deep-equal@^1.0.1: object-keys "^1.1.1" regexp.prototype.flags "^1.2.0" +deepmerge@^2.1.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-2.2.1.tgz#5d3ff22a01c00f645405a2fbc17d0778a1801170" + integrity sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA== + default-gateway@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/default-gateway/-/default-gateway-4.2.0.tgz#167104c7500c2115f6dd69b0a536bb8ed720552b" @@ -3954,6 +3959,20 @@ form-data@~2.3.2: combined-stream "^1.0.6" mime-types "^2.1.12" +formik@^2.1.5: + version "2.1.5" + resolved "https://registry.yarnpkg.com/formik/-/formik-2.1.5.tgz#de5bbbe35543fa6d049fe96b8ee329d6cd6892b8" + integrity sha512-bWpo3PiqVDYslvrRjTq0Isrm0mFXHiO33D8MS6t6dWcqSFGeYF52nlpCM2xwOJ6tRVRznDkL+zz/iHPL4LDuvQ== + dependencies: + deepmerge "^2.1.1" + hoist-non-react-statics "^3.3.0" + lodash "^4.17.14" + lodash-es "^4.17.14" + react-fast-compare "^2.0.1" + scheduler "^0.18.0" + tiny-warning "^1.0.2" + tslib "^1.10.0" + forwarded@~0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84" @@ -5364,6 +5383,11 @@ locate-path@^5.0.0: dependencies: p-locate "^4.1.0" +lodash-es@^4.17.14: + version "4.17.15" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.15.tgz#21bd96839354412f23d7a10340e5eac6ee455d78" + integrity sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ== + lodash._reinterpolate@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" @@ -7400,6 +7424,11 @@ react-error-overlay@^6.0.7: resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.7.tgz#1dcfb459ab671d53f660a991513cb2f0a0553108" integrity sha512-TAv1KJFh3RhqxNvhzxj6LeT5NWklP6rDr2a0jaTfsZ5wSZWHOGeqQyejUp3xxLfPt2UpyJEcVQB/zyPcmonNFA== +react-fast-compare@^2.0.1: + version "2.0.4" + resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" + integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== + react-i18next@^11.7.0: version "11.7.0" resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-11.7.0.tgz#f27c4c237a274e007a48ac1210db83e33719908b" @@ -7888,6 +7917,14 @@ sax@~1.2.4: resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== +scheduler@^0.18.0: + version "0.18.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.18.0.tgz#5901ad6659bc1d8f3fdaf36eb7a67b0d6746b1c4" + integrity sha512-agTSHR1Nbfi6ulI0kYNK0203joW2Y5W4po4l+v03tOoiJKpTBbxpNhWDvqc/4IcOw+KLmSiQLTasZ4cab2/UWQ== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + scheduler@^0.19.1: version "0.19.1" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.19.1.tgz#4f3e2ed2c1a7d65681f4c854fa8c5a1ccb40f196" -- 2.45.2 From c6c66492459db477f86c2da877c44c0598f0a5dc Mon Sep 17 00:00:00 2001 From: Kacper Donat <kadet1090@gmail.com> Date: Fri, 14 Aug 2020 15:01:34 +0200 Subject: [PATCH 3/5] Add missing translations --- src/components/proposalPreview.tsx | 2 +- src/forms/company.tsx | 30 ++++++----- src/forms/internship.tsx | 64 ++++++++-------------- src/forms/student.tsx | 63 +++++++++++----------- src/pages/internship/plan.tsx | 2 +- src/pages/internship/proposal.tsx | 10 ++-- src/pages/main.tsx | 2 +- src/ui/theme.ts | 2 +- translations/en.yaml | 85 +++++++++++++++++++++++++++++- translations/pl.yaml | 36 +++++++++++-- 10 files changed, 196 insertions(+), 100 deletions(-) diff --git a/src/components/proposalPreview.tsx b/src/components/proposalPreview.tsx index 1b8a46a..096779e 100644 --- a/src/components/proposalPreview.tsx +++ b/src/components/proposalPreview.tsx @@ -72,7 +72,7 @@ export const ProposalPreview = ({ proposal }: ProposalPreviewProps) => { <Typography className="proposal__secondary"> { t('internship.duration', { duration, count: Math.floor(duration.asWeeks()) }) } { ", " } - { t('internship.hours', { hours: proposal.hours }) } + { t('internship.hours', { hours: proposal.hours, count: proposal.hours }) } </Typography> </Section> diff --git a/src/forms/company.tsx b/src/forms/company.tsx index a728935..d18ddcc 100644 --- a/src/forms/company.tsx +++ b/src/forms/company.tsx @@ -7,6 +7,7 @@ import { BoundProperty, formFieldProps } from "./helpers"; import { InternshipFormSectionProps } from "@/forms/internship"; import { emptyMentor } from "@/provider/dummy/internship"; import { useProxyState } from "@/hooks"; +import { useTranslation } from "react-i18next"; export type CompanyFormProps = {} & InternshipFormSectionProps; @@ -32,6 +33,7 @@ export const OfficeItem = ({ office, ...props }: { office: BranchOffice } & HTML export const BranchForm: React.FC<BranchOfficeProps> = ({ 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 })) + const { t } = useTranslation(); const handleCityChange = (event: any, value: BranchOffice | string | null) => { if (typeof value === "string") { @@ -68,7 +70,7 @@ export const BranchForm: React.FC<BranchOfficeProps> = ({ value: office, onChang disabled={ disabled } getOptionLabel={ office => typeof office == "string" ? office : office.address.city } renderOption={ office => <OfficeItem office={ office }/> } - renderInput={ props => <TextField { ...props } label={ "Miasto" } fullWidth/> } + renderInput={ props => <TextField { ...props } label={ t("forms.internship.fields.city") } fullWidth/> } onChange={ handleCityChange } onInputChange={ handleCityInput } inputValue={ office.address.city } @@ -77,16 +79,16 @@ export const BranchForm: React.FC<BranchOfficeProps> = ({ value: office, onChang /> </Grid> <Grid item md={ 2 }> - <TextField label={ "Kod pocztowy" } fullWidth disabled={ !canEdit } { ...fieldProps("postalCode") }/> + <TextField label={ t("forms.internship.fields.postal-code") } fullWidth disabled={ !canEdit } { ...fieldProps("postalCode") }/> </Grid> <Grid item md={ 3 }> - <TextField label={ "Kraj" } fullWidth disabled={ !canEdit } { ...fieldProps("country") }/> + <TextField label={ t("forms.internship.fields.country") } fullWidth disabled={ !canEdit } { ...fieldProps("country") }/> </Grid> <Grid item md={ 10 }> - <TextField label={ "Ulica" } fullWidth disabled={ !canEdit } { ...fieldProps("street") }/> + <TextField label={ t("forms.internship.fields.street") } fullWidth disabled={ !canEdit } { ...fieldProps("street") }/> </Grid> <Grid item md={ 2 }> - <TextField label={ "Nr Budynku" } fullWidth disabled={ !canEdit } { ...fieldProps("building") }/> + <TextField label={ t("forms.internship.fields.building") } fullWidth disabled={ !canEdit } { ...fieldProps("building") }/> </Grid> </Grid> </div> @@ -95,21 +97,22 @@ export const BranchForm: React.FC<BranchOfficeProps> = ({ value: office, onChang export const MentorForm = ({ mentor, onMentorChange }: BoundProperty<Mentor, 'onMentorChange', 'mentor'>) => { const fieldProps = formFieldProps(mentor, onMentorChange) + const { t } = useTranslation(); return ( <> <Grid container> <Grid item md={6}> - <TextField label="Imię" fullWidth { ...fieldProps("name") }/> + <TextField label={ t("forms.internship.fields.first-name") } fullWidth { ...fieldProps("name") }/> </Grid> <Grid item md={6}> - <TextField label="Nazwisko" value={ mentor.surname } fullWidth { ...fieldProps("surname") }/> + <TextField label={ t("forms.internship.fields.last-name") } value={ mentor.surname } fullWidth { ...fieldProps("surname") }/> </Grid> <Grid item md={8}> - <TextField label="E-mail" value={ mentor.email } fullWidth { ...fieldProps("email") }/> + <TextField label={ t("forms.internship.fields.e-mail") } value={ mentor.email } fullWidth { ...fieldProps("email") }/> </Grid> <Grid item md={4}> - <TextField label="Nr telefonu" value={ mentor.phone } fullWidth { ...fieldProps("phone") }/> + <TextField label={ t("forms.internship.fields.phone" )} value={ mentor.phone } fullWidth { ...fieldProps("phone") }/> </Grid> </Grid> </> @@ -120,6 +123,7 @@ export const CompanyForm: React.FunctionComponent<CompanyFormProps> = ({ interns const [company, setCompany] = useProxyState<Company>(internship.company || emptyCompany, company => onChange({ ...internship, company })); const [mentor, setMentor] = useProxyState<Mentor>(internship.mentor || emptyMentor, mentor => onChange({ ...internship, mentor })); const [office, setOffice] = useProxyState<BranchOffice>(internship.office || emptyBranchOffice, office => onChange({ ...internship, office })); + const { t } = useTranslation(); const canEdit = useMemo(() => !company.id, [company.id]); @@ -145,21 +149,21 @@ export const CompanyForm: React.FunctionComponent<CompanyFormProps> = ({ interns <Autocomplete options={ sampleCompanies } getOptionLabel={ option => option.name } renderOption={ company => <CompanyItem company={ company }/> } - renderInput={ props => <TextField { ...props } label={ "Nazwa firmy" } fullWidth/> } + renderInput={ props => <TextField { ...props } label={ t("forms.internship.fields.company-name") } fullWidth/> } onChange={ handleCompanyChange } value={ company } freeSolo /> </Grid> <Grid item md={ 4 }> - <TextField label={ "NIP" } fullWidth { ...fieldProps("nip") } disabled={ !canEdit }/> + <TextField label={ t("forms.internship.fields.nip") } fullWidth { ...fieldProps("nip") } disabled={ !canEdit }/> </Grid> {/*<Grid item md={ 8 }>*/} {/* <TextField label={ "Url" } fullWidth { ...fieldProps("url") } disabled={ !canEdit }/>*/} {/*</Grid>*/} </Grid> - <Typography variant="subtitle1" className="subsection-header">Zakładowy opiekun praktyki</Typography> + <Typography variant="subtitle1" className="subsection-header">{ t("internship.mentor") }</Typography> <MentorForm mentor={ mentor } onMentorChange={ setMentor }/> - <Typography variant="subtitle1" className="subsection-header">Oddział</Typography> + <Typography variant="subtitle1" className="subsection-header">{ t("internship.office") }</Typography> <BranchForm value={ office } onChange={ setOffice } offices={ company.offices } /> </> ) diff --git a/src/forms/internship.tsx b/src/forms/internship.tsx index f4d2324..a6c5e3a 100644 --- a/src/forms/internship.tsx +++ b/src/forms/internship.tsx @@ -1,23 +1,10 @@ import React, { HTMLProps, useEffect, useMemo, useState } from "react"; -import { - Button, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - FormControl, - FormHelperText, - Grid, - Input, - InputLabel, - TextField, - Typography -} from "@material-ui/core"; +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 { Course, Internship, InternshipType, internshipTypeLabels } from "@/data"; +import { Internship, InternshipType, internshipTypeLabels } from "@/data"; import { Nullable } from "@/helpers"; import moment, { Moment } from "moment"; import { computeWorkingHours } from "@/utils/date"; @@ -55,12 +42,12 @@ export const InternshipTypeItem = ({ type, ...props }: { type: InternshipType } const InternshipProgramForm = ({ internship, onChange }: InternshipFormSectionProps) => { const fieldProps = formFieldProps(internship, onChange); - const course = internship.intern?.course as Course; + const { t } = useTranslation(); return ( <Grid container> <Grid item md={ 4 }> - <Autocomplete renderInput={ props => <TextField { ...props } label="Rodzaj praktyki/umowy" fullWidth/> } + <Autocomplete renderInput={ props => <TextField { ...props } label={ t("forms.internship.fields.kind") } fullWidth/> } getOptionLabel={ (option: InternshipType) => internshipTypeLabels[option].label } renderOption={ (option: InternshipType) => <InternshipTypeItem type={ option }/> } options={ Object.values(InternshipType) as InternshipType[] } @@ -69,7 +56,7 @@ const InternshipProgramForm = ({ internship, onChange }: InternshipFormSectionPr /> </Grid> <Grid item md={ 8 }> - { internship.type === InternshipType.Other && <TextField label={ "Inny - Wprowadź" } fullWidth/> } + { internship.type === InternshipType.Other && <TextField label={ t("forms.internship.fields.kind") } fullWidth/> } </Grid> {/*<Grid item>*/ } {/* <FormGroup>*/ } @@ -88,6 +75,8 @@ const InternshipProgramForm = ({ internship, onChange }: InternshipFormSectionPr } const InternshipDurationForm = ({ internship, onChange }: InternshipFormSectionProps) => { + const { t } = useTranslation(); + const [startDate, setStartDate] = useProxyState<Moment | null>(internship.startDate, value => onChange({ ...internship, startDate: value })); const [endDate, setEndDate] = useProxyState<Moment | null>(internship.endDate, value => onChange({ ...internship, endDate: value })); @@ -107,45 +96,34 @@ const InternshipDurationForm = ({ internship, onChange }: InternshipFormSectionP <DatePicker value={ startDate } onChange={ setStartDate } format="DD MMMM yyyy" clearable disableToolbar fullWidth - variant="inline" label={ "Data rozpoczęcia praktyki" } + variant="inline" label={ t("forms.internship.fields.start-date") } + minDate={ moment() } /> </Grid> <Grid item md={ 6 }> <DatePicker value={ endDate } onChange={ setEndDate } format="DD MMMM yyyy" clearable disableToolbar fullWidth - variant="inline" label={ "Data zakończenia praktyki" } + variant="inline" label={ t("forms.internship.fields.end-date") } minDate={ startDate || moment() } /> </Grid> <Grid item md={ 4 }> - <FormControl fullWidth> - <InputLabel>Wymiar etatu</InputLabel> - <Input value={ workingHours } - onChange={ ev => setWorkingHours(parseInt(ev.target.value) || 0) } - fullWidth - /> - <FormHelperText>Liczba godzin w tygodniu roboczym</FormHelperText> - </FormControl> + <TextField fullWidth label={ t("forms.internship.fields.working-hours") } + value={ workingHours } onChange={ ev => setWorkingHours(parseInt(ev.target.value) || 0) } + helperText={ t("forms.internship.help.working-hours") } + /> </Grid> <Grid item md={ 4 }> - <FormControl fullWidth> - <InputLabel>Łączna liczba godzin</InputLabel> - <Input value={ hours || "" } - onChange={ ev => setHoursOverride(parseInt(ev.target.value) || 0) } - fullWidth - /> - </FormControl> + <TextField fullWidth label={ t("forms.internship.fields.total-hours") } + value={ hours } onChange={ ev => setHoursOverride(parseInt(ev.target.value) || 0) } + /> </Grid> <Grid item md={ 4 }> - <FormControl fullWidth> - <InputLabel>Liczba tygodni</InputLabel> - <Input value={ weeks || "" } - disabled - fullWidth - /> - <FormHelperText>Wyliczona automatycznie</FormHelperText> - </FormControl> + <TextField fullWidth label={ t("forms.internship.fields.weeks") } + value={ weeks } disabled + helperText={ t("forms.internship.help.weeks") } + /> </Grid> </Grid> ); diff --git a/src/forms/student.tsx b/src/forms/student.tsx index da722e8..db1ec6e 100644 --- a/src/forms/student.tsx +++ b/src/forms/student.tsx @@ -3,42 +3,43 @@ 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"; type StudentFormProps = { student: Student } export const StudentForm = ({ student }: StudentFormProps) => { - return ( - <> - <Grid container> - <Grid item md={4}> - <TextField label="Imię" value={ student.name } disabled fullWidth/> - </Grid> - <Grid item md={4}> - <TextField label="Nazwisko" value={ student.surname } disabled fullWidth/> - </Grid> - <Grid item md={4}> - <TextField label="Nr Indeksu" value={ student.albumNumber } disabled fullWidth/> - </Grid> - <Grid item md={9}> - <Autocomplete - getOptionLabel={ (course: Course) => course.name } - renderInput={ props => <TextField { ...props } label={ "Kierunek" } fullWidth/> } - options={[ sampleCourse ]} - value={ student.course } - disabled - /> - </Grid> - <Grid item md={3}> - <TextField label="Semestr" value={ student.semester } disabled fullWidth/> - </Grid> - <Grid item> - <Alert severity="warning" action={ <Button color="inherit" size="small">skontaktuj się z opiekunem</Button> }> - Powyższe dane nie są poprawne? - </Alert> - </Grid> + const { t } = useTranslation(); + + return <> + <Grid container> + <Grid item md={4}> + <TextField label={ t("forms.internship.fields.first-name") } value={ student.name } disabled fullWidth/> </Grid> - </> - ); + <Grid item md={4}> + <TextField label={ t("forms.internship.fields.last-name") } value={ student.surname } disabled fullWidth/> + </Grid> + <Grid item md={4}> + <TextField label={ t("forms.internship.fields.album") } value={ student.albumNumber } disabled fullWidth/> + </Grid> + <Grid item md={9}> + <Autocomplete + getOptionLabel={ (course: Course) => course.name } + renderInput={ props => <TextField { ...props } label={ t("forms.internship.fields.course") } fullWidth/> } + options={[ sampleCourse ]} + value={ student.course } + disabled + /> + </Grid> + <Grid item md={3}> + <TextField label={ t("forms.internship.fields.semester") } value={ student.semester } disabled fullWidth/> + </Grid> + <Grid item> + <Alert severity="warning" action={ <Button color="inherit" size="small">skontaktuj się z opiekunem</Button> }> + Powyższe dane nie są poprawne? + </Alert> + </Grid> + </Grid> + </>; } diff --git a/src/pages/internship/plan.tsx b/src/pages/internship/plan.tsx index e09a214..5706817 100644 --- a/src/pages/internship/plan.tsx +++ b/src/pages/internship/plan.tsx @@ -12,7 +12,7 @@ export const SubmitPlanPage = () => { return <Page title={ t("steps.plan.submit") }> <Page.Header maxWidth="md"> <Page.Breadcrumbs> - <Link component={ RouterLink } to={ route("home") }>{ t('sections.my-internship.header') }</Link> + <Link component={ RouterLink } to={ route("home") }>{ t('pages.my-internship.header') }</Link> <Typography color="textPrimary">{ t("steps.plan.submit") }</Typography> </Page.Breadcrumbs> <Page.Title>{ t("steps.plan.submit") }</Page.Title> diff --git a/src/pages/internship/proposal.tsx b/src/pages/internship/proposal.tsx index f9767bd..939998b 100644 --- a/src/pages/internship/proposal.tsx +++ b/src/pages/internship/proposal.tsx @@ -30,13 +30,15 @@ import { MenuDown, StickerCheckOutline, StickerRemoveOutline } from "mdi-materia import { useVerticalSpacing } from "@/styles"; export const InternshipProposalFormPage = () => { - return <Page title="Zgłoszenie praktyki"> + const { t } = useTranslation(); + + return <Page title={ t("pages.proposal-form.header") }> <Page.Header maxWidth="md"> <Page.Breadcrumbs> - <Link component={ RouterLink } to={ route("home") }>Moja praktyka</Link> - <Typography color="textPrimary">Zgłoszenie praktyki</Typography> + <Link component={ RouterLink } to={ route("home") }>{ t("pages.my-internship.header") }</Link> + <Typography color="textPrimary">{ t("pages.proposal-form.header") }</Typography> </Page.Breadcrumbs> - <Page.Title>Zgłoszenie praktyki</Page.Title> + <Page.Title>{ t("pages.proposal-form.header") }</Page.Title> </Page.Header> <Container maxWidth={ "md" }> <ProposalComment /> diff --git a/src/pages/main.tsx b/src/pages/main.tsx index 5d2da88..f03e9b3 100644 --- a/src/pages/main.tsx +++ b/src/pages/main.tsx @@ -25,7 +25,7 @@ export const MainPage = () => { return <Page my={ 6 }> <Container> - <Typography variant="h2">{ t("sections.my-internship.header") }</Typography> + <Typography variant="h2">{ t("pages.my-internship.header") }</Typography> <Stepper orientation="vertical" nonLinear> <Step label={ t('steps.personal-data.header') } completed={ missingStudentData.length === 0 } until={ deadlines.personalData }> { missingStudentData.length > 0 && <> diff --git a/src/ui/theme.ts b/src/ui/theme.ts index 0cb8e31..cf65aad 100644 --- a/src/ui/theme.ts +++ b/src/ui/theme.ts @@ -10,7 +10,7 @@ export const studentTheme = responsiveFontSizes(createMuiTheme({ maxWidth: "md" }, MuiTextField: { - variant: "outlined" + variant: "outlined", } }, palette: { diff --git a/translations/en.yaml b/translations/en.yaml index 902dbbd..1fc2920 100644 --- a/translations/en.yaml +++ b/translations/en.yaml @@ -11,6 +11,41 @@ left: '{{ left, humanize }} left' dropzone: "Drag and drop a file here or click to choose" +forms: + internship: + fields: + start-date: Internship start date + end-date: Internship end date + working-hours: Working time + total-hours: Total hours + weeks: Total weeks + first-name: First name + last-name: Last name + album: Album number + course: Course + semester: Semester + kind: Contract type + kind-other: Other - please fill + company-name: Company name + nip: NIP + e-mail: e-mail address + phone: Phone number + city: City + postal-code: Postal code + country: Country + street: Street + building: Building + help: + weeks: Calculated automatically + working-hours: Total working hours in working week + send-confirmation: > + Po wysłaniu zgłoszenia nie będzie możliwości jego zmiany do czasu zweryfikowania go przez pełnomocnika ds. Twojego + kierunku. Czy na pewno chcesz wysłać zgłoszenie praktyki w tej formie? + plan: + instructions: > + Wypełnij i zeskanuj Indywidualny program Praktyk a następnie wyślij go z pomocą tego formularza. <więcej informacji> + dropzone-help: Skan dokumentu w formacie PDF + student: name: first name surname: last name @@ -19,7 +54,41 @@ student: email: e-mail albumNumber: album number -sections: +internship: + intern: + semester: semesetr {{ semester, roman }} + album: "album number {{ album }}" + date-range: "{{ start, DD MMMM YYYY }} - {{ end, DD MMMM YYYY }}" + duration: "{{ duration, weeks }} week" + duration_plural: "{{ duration, weeks }} weeks" + hours: "{{ hours }} hour" + hours_plural: "{{ hours }} hours" + office: "Office / Address" + mentor: "Internship mentor" + address: + city: "{{ city }}, {{ country }}" + street: "{{ postalCode }}, {{ street }} {{ building }}" + sections: + intern-info: "Intern personal data" + duration: "Internship duration" + place: "Internship place" + kind: "Contract and programme" + mentor: "Internship mentor" + discard: + title: "Discard internship proposal" + info: "This comments will be presented to student in order to fix errors." + accept: + title: "Accept internship proposal" + info: "This comments will be presented to student." + +submission: + status: + awaiting: "sent, awaiting verification" + accepted: "accepted" + declined: "needs correction" + draft: "draft" + +pages: my-internship: header: "My internship" @@ -29,10 +98,22 @@ steps: info: > Your profile is incomplete. In order to continue your internship you have to supply information given below. In case of problem with providing those information - please contact with your internship coordinator of your course. + form: "Add missing data" internship-proposal: header: "Internship proposal" form: "Internship proposal form" - info: "" + info: + draft: > + Przed podjęciem praktyki należy ją zgłosić. (TODO) + awaiting: > + Twoje zgłoszenie musi zostać zweryfikowane i zatwierdzone. Po weryfikacji zostaniesz poinformowany o + akceptacji bądź konieczności wprowadzenia zmian. + accepted: > + Twoje zgłoszenie zostało zweryfikowane i zaakceptowane. + declined: > + Twoje zgłoszenie zostało zweryfikowane i odrzucone. Popraw zgłoszone uwagi i wyślij zgłoszenie ponownie. W razie + pytań możesz również skontaktować się z pełnomocnikiem ds. praktyk Twojego kierunku. + action: "Send internship proposal" plan: header: "Individual Internship Plan" info: "" diff --git a/translations/pl.yaml b/translations/pl.yaml index 6547b33..a82d788 100644 --- a/translations/pl.yaml +++ b/translations/pl.yaml @@ -27,12 +27,39 @@ discard: zgłoś uwagi dropzone: "Przeciągnij i upuść plik bądź kliknij, aby wybrać" -sections: +pages: my-internship: header: "Moja praktyka" + proposal-form: + header: "Propose internship" forms: internship: + fields: + start-date: Data rozpoczęcia praktyki + end-date: Data zakończenia praktyki + working-hours: Wymiar etatu + total-hours: Łączna liczba godzin + weeks: Liczba tygodni + first-name: Imię + last-name: Nazwisko + album: Numer albumu + course: Kierunek + semester: Semestr + kind: Rodzaj praktyki/umowy + kind-other: Inny - wprowadź + company-name: Nazwa firmy + nip: NIP + e-mail: Kontaktowy adres e-mail + phone: Numer telefonu + city: Miasto + postal-code: Kod pocztowy + country: Kraj + street: Ulica + building: Nr budynku + help: + weeks: Wartość wyliczana automatycznie + working-hours: Liczba godzin w tygodniu roboczym send-confirmation: > Po wysłaniu zgłoszenia nie będzie możliwości jego zmiany do czasu zweryfikowania go przez pełnomocnika ds. Twojego kierunku. Czy na pewno chcesz wysłać zgłoszenie praktyki w tej formie? @@ -63,9 +90,12 @@ internship: date-range: "{{ start, DD MMMM YYYY }} - {{ end, DD MMMM YYYY }}" duration_2: "{{ duration, weeks }} tygodni" duration_0: "{{ duration, weeks }} tydzień" - duration_1: "{{ count }} tygodnie" - hours: "{{ hours }} godzin" + duration_1: "{{ duration, weeks }} tygodnie" + hours_2: "{{ hours }} godzin" + hours_0: "{{ hours }} godzina" + hours_1: "{{ hours }} godziny" office: "Oddział / adres" + mentor: "Zakładowy opiekun praktyki" address: city: "{{ city }}, {{ country }}" street: "{{ postalCode }}, {{ street }} {{ building }}" -- 2.45.2 From 99e12f76813fd7770afc395f17a18b78b4c711a6 Mon Sep 17 00:00:00 2001 From: Kacper Donat <kadet1090@gmail.com> Date: Fri, 14 Aug 2020 15:51:27 +0200 Subject: [PATCH 4/5] Add mentor validation --- package.json | 5 +- src/data/common.ts | 4 +- src/forms/company.tsx | 35 ++++++------ src/forms/internship.tsx | 114 ++++++++++++++++++++++++++++++--------- translations/pl.yaml | 5 ++ yarn.lock | 52 +++++++++++++++++- 6 files changed, 168 insertions(+), 47 deletions(-) diff --git a/package.json b/package.json index 4b1d642..c5eba09 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@types/react-router-dom": "^5.1.5", "@types/redux": "^3.6.0", "@types/redux-persist": "^4.3.1", + "@types/yup": "^0.29.4", "@typescript-eslint/eslint-plugin": "^2.10.0", "@typescript-eslint/parser": "^2.10.0", "babel-core": "^6.26.3", @@ -30,6 +31,7 @@ "date-holidays": "^1.5.3", "file-loader": "4.3.0", "formik": "^2.1.5", + "formik-material-ui": "^3.0.0-alpha.0", "html-webpack-plugin": "4.0.0-beta.11", "i18next": "^19.6.0", "i18next-browser-languagedetector": "^5.0.0", @@ -63,7 +65,8 @@ "webpack-cli": "^3.3.11", "webpack-dev-server": "3.10.3", "workbox-webpack-plugin": "4.3.1", - "yaml-loader": "^0.6.0" + "yaml-loader": "^0.6.0", + "yup": "^0.29.3" }, "scripts": { "serve": "webpack-dev-server --mode development", diff --git a/src/data/common.ts b/src/data/common.ts index 62671b8..5a735c4 100644 --- a/src/data/common.ts +++ b/src/data/common.ts @@ -1,3 +1,5 @@ +export type Identifier = string; + export interface Identifiable { - id?: string + id?: Identifier } diff --git a/src/forms/company.tsx b/src/forms/company.tsx index d18ddcc..4cfd2b8 100644 --- a/src/forms/company.tsx +++ b/src/forms/company.tsx @@ -8,6 +8,8 @@ import { InternshipFormSectionProps } from "@/forms/internship"; import { emptyMentor } from "@/provider/dummy/internship"; import { useProxyState } from "@/hooks"; import { useTranslation } from "react-i18next"; +import { Field } from "formik"; +import { TextField as TextFieldFormik } from "formik-material-ui" export type CompanyFormProps = {} & InternshipFormSectionProps; @@ -95,27 +97,24 @@ export const BranchForm: React.FC<BranchOfficeProps> = ({ value: office, onChang ) } -export const MentorForm = ({ mentor, onMentorChange }: BoundProperty<Mentor, 'onMentorChange', 'mentor'>) => { - const fieldProps = formFieldProps(mentor, onMentorChange) +export const MentorForm = () => { const { t } = useTranslation(); return ( - <> - <Grid container> - <Grid item md={6}> - <TextField label={ t("forms.internship.fields.first-name") } fullWidth { ...fieldProps("name") }/> - </Grid> - <Grid item md={6}> - <TextField label={ t("forms.internship.fields.last-name") } value={ mentor.surname } fullWidth { ...fieldProps("surname") }/> - </Grid> - <Grid item md={8}> - <TextField label={ t("forms.internship.fields.e-mail") } value={ mentor.email } fullWidth { ...fieldProps("email") }/> - </Grid> - <Grid item md={4}> - <TextField label={ t("forms.internship.fields.phone" )} value={ mentor.phone } fullWidth { ...fieldProps("phone") }/> - </Grid> + <Grid container> + <Grid item md={6}> + <Field name="mentorFirstName" label={ t("forms.internship.fields.first-name") } fullWidth component={ TextFieldFormik } /> </Grid> - </> + <Grid item md={6}> + <Field name="mentorLastName" label={ t("forms.internship.fields.last-name") } fullWidth component={ TextFieldFormik } /> + </Grid> + <Grid item md={8}> + <Field name="mentorEmail" label={ t("forms.internship.fields.e-mail") } fullWidth component={ TextFieldFormik } /> + </Grid> + <Grid item md={4}> + <Field name="mentorPhone" label={ t("forms.internship.fields.phone") } fullWidth component={ TextFieldFormik } /> + </Grid> + </Grid> ); } @@ -162,7 +161,7 @@ export const CompanyForm: React.FunctionComponent<CompanyFormProps> = ({ interns {/*</Grid>*/} </Grid> <Typography variant="subtitle1" className="subsection-header">{ t("internship.mentor") }</Typography> - <MentorForm mentor={ mentor } onMentorChange={ setMentor }/> + <MentorForm /> <Typography variant="subtitle1" className="subsection-header">{ t("internship.office") }</Typography> <BranchForm value={ office } onChange={ setOffice } offices={ company.offices } /> </> diff --git a/src/forms/internship.tsx b/src/forms/internship.tsx index a6c5e3a..7b487eb 100644 --- a/src/forms/internship.tsx +++ b/src/forms/internship.tsx @@ -4,7 +4,7 @@ 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 { Internship, InternshipType, internshipTypeLabels } from "@/data"; +import { BranchOffice, Company, Internship, InternshipType, internshipTypeLabels, Student } from "@/data"; import { Nullable } from "@/helpers"; import moment, { Moment } from "moment"; import { computeWorkingHours } from "@/utils/date"; @@ -20,6 +20,8 @@ import { route } from "@/routing"; import { useProxyState } from "@/hooks"; import { getInternshipProposal } from "@/state/reducer/proposal"; import { Actions } from "@/components"; +import { Form, Formik } from "formik"; +import * as Yup from "yup"; export type InternshipFormProps = {} @@ -28,6 +30,50 @@ export type InternshipFormSectionProps = { onChange: (internship: Nullable<Internship>) => void, } +export type InternshipFormState = { + startDate: Moment | null; + endDate: Moment | null; + hours: number | null; + companyName: string; + companyNip: string; + city: string; + postalCode: string; + country: string; + building: string; + mentorFirstName: string; + mentorLastName: string; + mentorEmail: string; + mentorPhone: string; + kindOther: string | null; + + // relations + kind: InternshipType | null; + company: Company | null; + office: BranchOffice | null; + student: Student | null; +} + +const emptyInternshipValues: InternshipFormState = { + building: "", + city: "", + company: null, + companyName: "", + companyNip: "", + country: "", + endDate: null, + hours: null, + kind: null, + kindOther: "", + mentorEmail: "", + mentorFirstName: "", + mentorLastName: "", + mentorPhone: "", + office: null, + postalCode: "", + startDate: null, + student: null +} + export const InternshipTypeItem = ({ type, ...props }: { type: InternshipType } & HTMLProps<any>) => { const info = internshipTypeLabels[type]; @@ -158,34 +204,50 @@ export const InternshipForm: React.FunctionComponent<InternshipFormProps> = prop setConfirmDialogOpen(false); } + const validationSchema = Yup.object<Partial<InternshipFormState>>({ + mentorFirstName: Yup.string().required(t("validation.required")), + mentorLastName: Yup.string().required(t("validation.required")), + mentorEmail: Yup.string() + .required(t("validation.required")) + .email(t("validation.email")), + mentorPhone: Yup.string() + .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 + }) + return ( - <div className="internship-form"> - <Typography variant="h3" className="section-header">{ t('internship.sections.intern-info') }</Typography> - <StudentForm student={ sampleStudent }/> - <Typography variant="h3" className="section-header">{ t('internship.sections.kind' )}</Typography> - <InternshipProgramForm internship={ internship } onChange={ setInternship }/> - <Typography variant="h3" className="section-header">{ t('internship.sections.duration') }</Typography> - <InternshipDurationForm internship={ internship } onChange={ setInternship }/> - <Typography variant="h3" className="section-header">{ t('internship.sections.place') }</Typography> - <CompanyForm internship={ internship } onChange={ setInternship }/> - <Actions> - <Button variant="contained" color="primary" onClick={ handleSubmitConfirmation }>{ t("confirm") }</Button> + <Formik initialValues={ emptyInternshipValues } onSubmit={ values => console.log(values) } + validationSchema={ validationSchema } validateOnChange={ false } validateOnBlur={ true }> + { formik => <Form> + <Typography variant="h3" className="section-header">{ t('internship.sections.intern-info') }</Typography> + <StudentForm student={ sampleStudent }/> + <Typography variant="h3" className="section-header">{ t('internship.sections.kind' )}</Typography> + <InternshipProgramForm internship={ internship } onChange={ setInternship }/> + <Typography variant="h3" className="section-header">{ t('internship.sections.duration') }</Typography> + <InternshipDurationForm internship={ internship } onChange={ setInternship }/> + <Typography variant="h3" className="section-header">{ t('internship.sections.place') }</Typography> + <CompanyForm internship={ internship } onChange={ setInternship }/> + <Actions> + <Button variant="contained" color="primary" onClick={ () => formik.validateForm(formik.values) }>{ t("confirm") }</Button> - <Button component={ RouterLink } to={ route("home") }> - { t('go-back') } - </Button> - </Actions> + <Button component={ RouterLink } to={ route("home") }> + { t('go-back') } + </Button> + </Actions> - <Dialog open={ confirmDialogOpen } onClose={ handleCancel }> - <DialogContent> - <DialogContentText>{ t('forms.internship.send-confirmation') }</DialogContentText> - </DialogContent> - <DialogActions> - <Button onClick={ handleCancel }>{ t('cancel') }</Button> - <Button color="primary" autoFocus onClick={ handleSubmit }>{ t('confirm') }</Button> - </DialogActions> - </Dialog> - </div> + <Dialog open={ confirmDialogOpen } onClose={ handleCancel }> + <DialogContent> + <DialogContentText>{ t('forms.internship.send-confirmation') }</DialogContentText> + </DialogContent> + <DialogActions> + <Button onClick={ handleCancel }>{ t('cancel') }</Button> + <Button color="primary" autoFocus onClick={ formik.submitForm }>{ t('confirm') }</Button> + </DialogActions> + </Dialog> + </Form> } + </Formik> ) } diff --git a/translations/pl.yaml b/translations/pl.yaml index a82d788..8fc6115 100644 --- a/translations/pl.yaml +++ b/translations/pl.yaml @@ -159,4 +159,9 @@ steps: instructions: > papierki do podpisania... +validation: + required: "To pole jest wymagane" + email: "Wprowadź poprawny adres e-mail" + phone: "Wprowadź poprawny numer telefonu" + contact-coordinator: "Skontaktuj się z koordynatorem" diff --git a/yarn.lock b/yarn.lock index 4ed4f49..634fa14 100644 --- a/yarn.lock +++ b/yarn.lock @@ -933,6 +933,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.10.5": + version "7.11.2" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736" + integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.10.1", "@babel/template@^7.8.6": version "7.10.1" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.10.1.tgz#e167154a94cb5f14b28dc58f5356d2162f539811" @@ -1311,6 +1318,11 @@ "@types/webpack-sources" "*" source-map "^0.6.0" +"@types/yup@^0.29.4": + version "0.29.4" + resolved "https://registry.yarnpkg.com/@types/yup/-/yup-0.29.4.tgz#f7b1f9978180d5155663c1cd0ecdc41a72c23d81" + integrity sha512-OQ7gZRQb7eSbGu5h57tbK67sgX8UH5wbuqPORTFBG7qiBtOkEf1dXAr0QULyHIeRwaGLPYxPXiQru+40ClR6ng== + "@typescript-eslint/eslint-plugin@^2.10.0": version "2.34.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.34.0.tgz#6f8ce8a46c7dea4a6f1d171d2bb8fbae6dac2be9" @@ -3919,6 +3931,11 @@ flush-write-stream@^1.0.0: inherits "^2.0.3" readable-stream "^2.3.6" +fn-name@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/fn-name/-/fn-name-3.0.0.tgz#0596707f635929634d791f452309ab41558e3c5c" + integrity sha512-eNMNr5exLoavuAMhIUVsOKF79SWd/zG104ef6sxBTSw+cZc6BXdQXDvYcGvp0VbxVVSp1XDUNoz7mg1xMtSznA== + follow-redirects@^1.0.0: version "1.11.0" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.11.0.tgz#afa14f08ba12a52963140fe43212658897bc0ecb" @@ -3959,6 +3976,11 @@ form-data@~2.3.2: combined-stream "^1.0.6" mime-types "^2.1.12" +formik-material-ui@^3.0.0-alpha.0: + version "3.0.0-alpha.0" + resolved "https://registry.yarnpkg.com/formik-material-ui/-/formik-material-ui-3.0.0-alpha.0.tgz#4020b5cbd9e431406fb275a317cdce95ad398545" + integrity sha512-N9JcSngi4nWaKN67sN1M3ILXgz0fLCdoMhHHecrZC3NeR+C5lWWAUuAcjGZWNj+z87Qt7NW8VXlxSnGxGus8Uw== + formik@^2.1.5: version "2.1.5" resolved "https://registry.yarnpkg.com/formik/-/formik-2.1.5.tgz#de5bbbe35543fa6d049fe96b8ee329d6cd6892b8" @@ -5383,7 +5405,7 @@ locate-path@^5.0.0: dependencies: p-locate "^4.1.0" -lodash-es@^4.17.14: +lodash-es@^4.17.11, lodash-es@^4.17.14: version "4.17.15" resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.15.tgz#21bd96839354412f23d7a10340e5eac6ee455d78" integrity sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ== @@ -7216,6 +7238,11 @@ prop-types@^15.6.2, prop-types@^15.7.2: object-assign "^4.1.1" react-is "^16.8.1" +property-expr@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.2.tgz#fff2a43919135553a3bc2fdd94bdb841965b2330" + integrity sha512-bc/5ggaYZxNkFKj374aLbEDqVADdYaLcFo8XBkishUWbaAdjlphaBFns9TvRA2pUseVL/wMFmui9X3IdNDU37g== + proxy-addr@~2.0.5: version "2.0.6" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.6.tgz#fdc2336505447d3f2f2c638ed272caf614bbb2bf" @@ -8591,6 +8618,11 @@ symbol-observable@^1.2.0: resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== +synchronous-promise@^2.0.13: + version "2.0.13" + resolved "https://registry.yarnpkg.com/synchronous-promise/-/synchronous-promise-2.0.13.tgz#9d8c165ddee69c5a6542862b405bc50095926702" + integrity sha512-R9N6uDkVsghHePKh1TEqbnLddO2IY25OcsksyFp/qBe7XYd0PVbKEWxhcdMhpLzE1I6skj5l4aEZ3CRxcbArlA== + tapable@^1.0.0, tapable@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2" @@ -8733,6 +8765,11 @@ toidentifier@1.0.0: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw== +toposort@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330" + integrity sha1-riF2gXXRVZ1IvvNUILL0li8JwzA= + tough-cookie@~2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" @@ -9506,3 +9543,16 @@ yargs@^13.3.2: which-module "^2.0.0" y18n "^4.0.0" yargs-parser "^13.1.2" + +yup@^0.29.3: + version "0.29.3" + resolved "https://registry.yarnpkg.com/yup/-/yup-0.29.3.tgz#69a30fd3f1c19f5d9e31b1cf1c2b851ce8045fea" + integrity sha512-RNUGiZ/sQ37CkhzKFoedkeMfJM0vNQyaz+wRZJzxdKE7VfDeVKH8bb4rr7XhRLbHJz5hSjoDNwMEIaKhuMZ8gQ== + dependencies: + "@babel/runtime" "^7.10.5" + fn-name "~3.0.0" + lodash "^4.17.15" + lodash-es "^4.17.11" + property-expr "^2.0.2" + synchronous-promise "^2.0.13" + toposort "^2.0.2" -- 2.45.2 From 1857785c84200d53aa0233f313867aab09143278 Mon Sep 17 00:00:00 2001 From: Kacper Donat <kadet1090@gmail.com> Date: Mon, 17 Aug 2020 23:19:56 +0200 Subject: [PATCH 5/5] Add validation based on formik and Yup --- src/data/company.ts | 6 +- src/data/edition.ts | 2 + src/data/internship.ts | 4 +- src/forms/company.tsx | 173 +++++++++++-------- src/forms/internship.tsx | 315 +++++++++++++++++++++++----------- src/forms/student.tsx | 11 +- src/hooks/index.ts | 1 + src/hooks/useUpdateEffect.ts | 15 ++ src/provider/dummy/edition.ts | 3 +- src/serialization/types.ts | 6 +- translations/pl.yaml | 2 + 11 files changed, 347 insertions(+), 191 deletions(-) create mode 100644 src/hooks/useUpdateEffect.ts 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<BranchOffice, "onChange", "value"> - export const CompanyItem = ({ company, ...props }: { company: Company } & HTMLProps<any>) => ( <div className="company-item" { ...props }> <div>{ company.name }</div> @@ -25,43 +15,65 @@ export const CompanyItem = ({ company, ...props }: { company: Company } & HTMLPr </div> ) -export const OfficeItem = ({ office, ...props }: { office: BranchOffice } & HTMLProps<any>) => ( +export const OfficeItem = ({ office, ...props }: { office: Office } & HTMLProps<any>) => ( <div className="office-item" { ...props }> <div>{ office.address.city }</div> <Typography variant="caption">{ formatAddress(office.address) }</Typography> </div> ) -export const BranchForm: React.FC<BranchOfficeProps> = ({ 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<InternshipFormValues>(); 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<BranchOfficeProps> = ({ value: office, onChang disabled={ disabled } getOptionLabel={ office => typeof office == "string" ? office : office.address.city } renderOption={ office => <OfficeItem office={ office }/> } - renderInput={ props => <TextField { ...props } label={ t("forms.internship.fields.city") } fullWidth/> } + renderInput={ + props => + <TextField { ...props } + label={ t("forms.internship.fields.city") } + fullWidth + error={ touched.city && !!errors.city } + helperText={ touched.city && errors.city } + /> + } 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 /> </Grid> <Grid item md={ 2 }> - <TextField label={ t("forms.internship.fields.postal-code") } fullWidth disabled={ !canEdit } { ...fieldProps("postalCode") }/> + <Field label={ t("forms.internship.fields.postal-code") } name="postalCode" fullWidth disabled={ !canEdit } component={ TextFieldFormik }/> </Grid> <Grid item md={ 3 }> - <TextField label={ t("forms.internship.fields.country") } fullWidth disabled={ !canEdit } { ...fieldProps("country") }/> + <Field label={ t("forms.internship.fields.country") } name="country" fullWidth disabled={ !canEdit } component={ TextFieldFormik }/> </Grid> <Grid item md={ 10 }> - <TextField label={ t("forms.internship.fields.street") } fullWidth disabled={ !canEdit } { ...fieldProps("street") }/> + <Field label={ t("forms.internship.fields.street") } name="street" fullWidth disabled={ !canEdit } component={ TextFieldFormik }/> </Grid> <Grid item md={ 2 }> - <TextField label={ t("forms.internship.fields.building") } fullWidth disabled={ !canEdit } { ...fieldProps("building") }/> + <Field label={ t("forms.internship.fields.building") } name="building" fullWidth disabled={ !canEdit } component={ TextFieldFormik }/> </Grid> </Grid> </div> @@ -102,42 +123,50 @@ export const MentorForm = () => { return ( <Grid container> - <Grid item md={6}> - <Field name="mentorFirstName" label={ t("forms.internship.fields.first-name") } fullWidth component={ TextFieldFormik } /> + <Grid item md={ 6 }> + <Field name="mentorFirstName" label={ t("forms.internship.fields.first-name") } fullWidth component={ TextFieldFormik }/> </Grid> - <Grid item md={6}> - <Field name="mentorLastName" label={ t("forms.internship.fields.last-name") } fullWidth component={ TextFieldFormik } /> + <Grid item md={ 6 }> + <Field name="mentorLastName" label={ t("forms.internship.fields.last-name") } fullWidth component={ TextFieldFormik }/> </Grid> - <Grid item md={8}> - <Field name="mentorEmail" label={ t("forms.internship.fields.e-mail") } fullWidth component={ TextFieldFormik } /> + <Grid item md={ 8 }> + <Field name="mentorEmail" label={ t("forms.internship.fields.e-mail") } fullWidth component={ TextFieldFormik }/> </Grid> - <Grid item md={4}> - <Field name="mentorPhone" label={ t("forms.internship.fields.phone") } fullWidth component={ TextFieldFormik } /> + <Grid item md={ 4 }> + <Field name="mentorPhone" label={ t("forms.internship.fields.phone") } fullWidth component={ TextFieldFormik }/> </Grid> </Grid> ); } -export const CompanyForm: React.FunctionComponent<CompanyFormProps> = ({ internship, onChange }) => { - const [company, setCompany] = useProxyState<Company>(internship.company || emptyCompany, company => onChange({ ...internship, company })); - const [mentor, setMentor] = useProxyState<Mentor>(internship.mentor || emptyMentor, mentor => onChange({ ...internship, mentor })); - const [office, setOffice] = useProxyState<BranchOffice>(internship.office || emptyBranchOffice, office => onChange({ ...internship, office })); +export const CompanyForm: React.FunctionComponent = () => { + const { values, setValues, errors, touched, setFieldTouched } = useFormikContext<InternshipFormValues>(); 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<CompanyFormProps> = ({ interns <Grid container> <Grid item> <Autocomplete options={ sampleCompanies } - getOptionLabel={ option => option.name } + getOptionLabel={ option => typeof option === "string" ? option : option.name } renderOption={ company => <CompanyItem company={ company }/> } - renderInput={ props => <TextField { ...props } label={ t("forms.internship.fields.company-name") } fullWidth/> } - onChange={ handleCompanyChange } value={ company } + renderInput={ props => <TextField { ...props } label={ t("forms.internship.fields.company-name") } fullWidth + error={ touched.companyName && !!errors.companyName } helperText={ touched.companyName && errors.companyName }/> } + onChange={ handleCompanyChange } value={ values.company || values.companyName } freeSolo /> </Grid> <Grid item md={ 4 }> - <TextField label={ t("forms.internship.fields.nip") } fullWidth { ...fieldProps("nip") } disabled={ !canEdit }/> + <Field label={ t("forms.internship.fields.nip") } fullWidth name="companyNip" disabled={ !canEdit } component={ TextFieldFormik }/> </Grid> - {/*<Grid item md={ 8 }>*/} - {/* <TextField label={ "Url" } fullWidth { ...fieldProps("url") } disabled={ !canEdit }/>*/} - {/*</Grid>*/} </Grid> <Typography variant="subtitle1" className="subsection-header">{ t("internship.mentor") }</Typography> - <MentorForm /> + <MentorForm/> <Typography variant="subtitle1" className="subsection-header">{ t("internship.office") }</Typography> - <BranchForm value={ office } onChange={ setOffice } offices={ company.offices } /> + <BranchForm/> </> ) } 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<Internship>, - onChange: (internship: Nullable<Internship>) => 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<any>) => { @@ -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<InternshipFormValues>(); return ( <Grid container> <Grid item md={ 4 }> - <Autocomplete renderInput={ props => <TextField { ...props } label={ t("forms.internship.fields.kind") } fullWidth/> } + <Autocomplete renderInput={ props => <TextField { ...props } label={ t("forms.internship.fields.kind") } fullWidth error={ !!errors.kind } helperText={ errors.kind }/> } getOptionLabel={ (option: InternshipType) => internshipTypeLabels[option].label } renderOption={ (option: InternshipType) => <InternshipTypeItem type={ option }/> } 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 } /> </Grid> <Grid item md={ 8 }> - { internship.type === InternshipType.Other && <TextField label={ t("forms.internship.fields.kind") } fullWidth/> } + { + values.kind === InternshipType.Other && + <Field label={ t("forms.internship.fields.kind-other") } name="kindOther" fullWidth component={ TextFieldFormik } /> + } </Grid> - {/*<Grid item>*/ } - {/* <FormGroup>*/ } - {/* <FormLabel component="legend" className="subsection-header">Realizowane punkty programu praktyk (minimum 3)</FormLabel>*/ } - {/* { course.possibleProgramEntries.map(entry => {*/ } - {/* return (*/ } - {/* <FormControlLabel label={ entry.description } key={ entry.id }*/ } - {/* control={ <Checkbox /> }*/ } - {/* />*/ } - {/* )*/ } - {/* }) }*/ } - {/* </FormGroup>*/ } - {/*</Grid>*/ } </Grid> ) } -const InternshipDurationForm = ({ internship, onChange }: InternshipFormSectionProps) => { +const InternshipDurationForm = () => { const { t } = useTranslation(); - - const [startDate, setStartDate] = useProxyState<Moment | null>(internship.startDate, value => onChange({ ...internship, startDate: value })); - const [endDate, setEndDate] = useProxyState<Moment | null>(internship.endDate, value => onChange({ ...internship, endDate: value })); + const { + values: { startDate, endDate, workingHours }, + errors, + touched, + setFieldTouched, + setFieldValue + } = useFormikContext<InternshipFormValues>(); const [overrideHours, setHoursOverride] = useState<number | null>(null) - const [workingHours, setWorkingHours] = useState<number>(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 ( <Grid container> <Grid item md={ 6 }> - <DatePicker value={ startDate } onChange={ setStartDate } + <DatePicker value={ startDate } onChange={ value => setFieldValue("startDate", value) } format="DD MMMM yyyy" - clearable disableToolbar fullWidth + disableToolbar fullWidth variant="inline" label={ t("forms.internship.fields.start-date") } minDate={ moment() } /> </Grid> <Grid item md={ 6 }> - <DatePicker value={ endDate } onChange={ setEndDate } + <DatePicker value={ endDate } onChange={ value => setFieldValue("endDate", value) } format="DD MMMM yyyy" - clearable disableToolbar fullWidth + disableToolbar fullWidth variant="inline" label={ t("forms.internship.fields.end-date") } minDate={ startDate || moment() } /> </Grid> <Grid item md={ 4 }> - <TextField fullWidth label={ t("forms.internship.fields.working-hours") } - value={ workingHours } onChange={ ev => setWorkingHours(parseInt(ev.target.value) || 0) } - helperText={ t("forms.internship.help.working-hours") } + <Field component={ TextFieldFormik } + name="workingHours" + label={ t("forms.internship.fields.working-hours") } + helperText={ t("forms.internship.help.working-hours") } + fullWidth /> </Grid> <Grid item md={ 4 }> - <TextField fullWidth label={ t("forms.internship.fields.total-hours") } - value={ hours } onChange={ ev => setHoursOverride(parseInt(ev.target.value) || 0) } + <TextField fullWidth + label={ t("forms.internship.fields.total-hours") } + error={ !!errors.hours && touched.hours } + helperText={ touched.hours && errors.hours } + value={ hours || "" } + onChange={ ev => setHoursOverride(parseInt(ev.target.value) || 0) } /> </Grid> <Grid item md={ 4 }> <TextField fullWidth label={ t("forms.internship.fields.weeks") } - value={ weeks } disabled + value={ weeks || "" } + disabled helperText={ t("forms.internship.help.weeks") } /> </Grid> @@ -175,36 +179,86 @@ const InternshipDurationForm = ({ internship, onChange }: InternshipFormSectionP ); } -export const InternshipForm: React.FunctionComponent<InternshipFormProps> = props => { - const initialInternshipState = useSelector<AppState, Nullable<Internship>>(state => getInternshipProposal(state.proposal) || { +type InternshipConverterContext = { + internship: Internship, +} + +const converter: Transformer<Nullable<Internship>, InternshipFormValues, InternshipConverterContext> = { + transform(internship: Nullable<Internship>): 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<Internship> { + 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<AppState, Nullable<Internship>>(state => getInternshipProposal(state.proposal) || { ...emptyInternship, + office: null, + company: null, + mentor: null, intern: sampleStudent }); - const [internship, setInternship] = useState<Nullable<Internship>>(initialInternshipState) + const edition = useSelector<AppState, Edition>(state => state.edition as Edition); + const { t } = useTranslation(); + const dispatch = useDispatch(); const history = useHistory(); const [confirmDialogOpen, setConfirmDialogOpen] = useState<boolean>(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<Partial<InternshipFormState>>({ + const validationSchema = Yup.object<Partial<InternshipFormValues>>({ 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<InternshipFormProps> = 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 <Form> + <Typography variant="h3" className="section-header">{ t('internship.sections.intern-info') }</Typography> + <StudentForm /> + <Typography variant="h3" className="section-header">{ t('internship.sections.kind' )}</Typography> + <InternshipProgramForm /> + <Typography variant="h3" className="section-header">{ t('internship.sections.duration') }</Typography> + <InternshipDurationForm /> + <Typography variant="h3" className="section-header">{ t('internship.sections.place') }</Typography> + <CompanyForm /> + <Actions> + <Button variant="contained" color="primary" onClick={ handleSubmitConfirmation }>{ t("confirm") }</Button> + + <Button component={ RouterLink } to={ route("home") }> + { t('go-back') } + </Button> + </Actions> + + <Dialog open={ confirmDialogOpen } onClose={ handleCancel }> + <DialogContent> + <DialogContentText>{ t('forms.internship.send-confirmation') }</DialogContentText> + </DialogContent> + <DialogActions> + <Button onClick={ handleCancel }>{ t('cancel') }</Button> + <Button color="primary" autoFocus onClick={ submitForm }>{ t('confirm') }</Button> + </DialogActions> + </Dialog> + </Form> + } + return ( - <Formik initialValues={ emptyInternshipValues } onSubmit={ values => console.log(values) } - validationSchema={ validationSchema } validateOnChange={ false } validateOnBlur={ true }> - { formik => <Form> - <Typography variant="h3" className="section-header">{ t('internship.sections.intern-info') }</Typography> - <StudentForm student={ sampleStudent }/> - <Typography variant="h3" className="section-header">{ t('internship.sections.kind' )}</Typography> - <InternshipProgramForm internship={ internship } onChange={ setInternship }/> - <Typography variant="h3" className="section-header">{ t('internship.sections.duration') }</Typography> - <InternshipDurationForm internship={ internship } onChange={ setInternship }/> - <Typography variant="h3" className="section-header">{ t('internship.sections.place') }</Typography> - <CompanyForm internship={ internship } onChange={ setInternship }/> - <Actions> - <Button variant="contained" color="primary" onClick={ () => formik.validateForm(formik.values) }>{ t("confirm") }</Button> - - <Button component={ RouterLink } to={ route("home") }> - { t('go-back') } - </Button> - </Actions> - - <Dialog open={ confirmDialogOpen } onClose={ handleCancel }> - <DialogContent> - <DialogContentText>{ t('forms.internship.send-confirmation') }</DialogContentText> - </DialogContent> - <DialogActions> - <Button onClick={ handleCancel }>{ t('cancel') }</Button> - <Button color="primary" autoFocus onClick={ formik.submitForm }>{ t('confirm') }</Button> - </DialogActions> - </Dialog> - </Form> } + <Formik initialValues={ values } + onSubmit={ handleSubmit } + validationSchema={ validationSchema } + validateOnChange={ false } + validateOnBlur={ true } + > + <InnerForm /> </Formik> ) } 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<InternshipFormValues>(); return <> <Grid container> 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<boolean>(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<T> = string | T extends Object ? Serializable<T> : any; export type Serializable<T> = { [K in keyof T]: Simplify<T[K]> } -export type Transformer<TFrom, TResult> = { - transform(subject: TFrom): TResult; - reverseTransform(subject: TResult): TFrom; +export type Transformer<TFrom, TResult, TContext = never> = { + transform(subject: TFrom, context?: TContext): TResult; + reverseTransform(subject: TResult, context?: TContext): TFrom; } export type SerializationTransformer<T, TSerialized = Serializable<T>> = Transformer<T, TSerialized> 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" -- 2.45.2