From 9fbeecbf72a157da50b2fa93c04ac3edc73c64e5 Mon Sep 17 00:00:00 2001 From: Georgi Parlakov Date: Thu, 5 Sep 2024 15:02:16 +0300 Subject: [PATCH 01/10] feat: file upload and summary - handle file upload one by one to allow for handling errors individually (otherwise the server just returns response failed for any one file that fails and no information about the successful uploads) - add summary where the uploaded and failed (if any) files are top and center - add a short explanation of what to do about failed files --- public/locales/bg/campaign-application.json | 10 +- public/locales/en/campaign-application.json | 8 + src/common/routes.ts | 1 + .../CampaignApplicationForm.tsx | 222 ++++++++++++++---- .../CampaignApplicationFormActions.tsx | 61 ----- .../helpers/campaignApplication.types.ts | 1 + .../steps/CampaignApplication.tsx | 2 +- .../steps/CampaignApplicationDetails.tsx | 61 +---- .../steps/CampaignApplicationSummary.tsx | 99 ++++++++ .../common/file-upload/FileUpload.tsx | 3 + src/gql/campaign-applications.ts | 7 +- .../[id]/index.tsx} | 0 src/pages/campaigns/application/index.tsx | 11 + 13 files changed, 323 insertions(+), 163 deletions(-) delete mode 100644 src/components/client/campaign-application/CampaignApplicationFormActions.tsx create mode 100644 src/components/client/campaign-application/steps/CampaignApplicationSummary.tsx rename src/pages/campaigns/{application.tsx => application/[id]/index.tsx} (100%) create mode 100644 src/pages/campaigns/application/index.tsx diff --git a/public/locales/bg/campaign-application.json b/public/locales/bg/campaign-application.json index 4d69d7341..ca72ea451 100644 --- a/public/locales/bg/campaign-application.json +++ b/public/locales/bg/campaign-application.json @@ -62,6 +62,14 @@ } }, "alerts": { - "successfully-created": "Успешно създадена кампания. " + "successfully-created": "Успешно създадена заявка за кампания. " + }, + "result": { + "created": "Успешно създадена заявка за кампания.", + "editButton": "Редакция на заявка за кампания", + "uploadOk": "Успешно добавени файлове", + "uploadFailed": "Неуспешно добавени файлове", + "uploadFailedWhat": "Какво мога да направя?", + "uploadFailedDirection": "Моля посетете страницата за редакция на заявката за кампания от бутона по-долу и опитайте отново да добавите файла(файловете)" } } diff --git a/public/locales/en/campaign-application.json b/public/locales/en/campaign-application.json index 3e32e7797..055bfc381 100644 --- a/public/locales/en/campaign-application.json +++ b/public/locales/en/campaign-application.json @@ -63,5 +63,13 @@ }, "alerts": { "successfully-created": "Campaign application successfully created." + }, + "result": { + "created": "Successfully created campaign application", + "editButton": "Edit campaign application", + "uploadOk": "Fails uploaded", + "uploadFailed": "Failed upload", + "uploadFailedWhat": "What can I do?", + "uploadFailedDirection": "Please visit the campaign application edit page linked below and retry uploading the files." } } diff --git a/src/common/routes.ts b/src/common/routes.ts index 6f03310d0..5de0f4367 100644 --- a/src/common/routes.ts +++ b/src/common/routes.ts @@ -92,6 +92,7 @@ export const routes = { index: '/campaigns', create: '/campaigns/create', application: 'campaigns/application', + applicationEdit: (id: string) => `/campaigns/application/${id}`, viewCampaignBySlug: (slug: string) => `/campaigns/${slug}`, viewExpenses: (slug: string) => `/campaigns/${slug}/expenses`, oneTimeDonation: (slug: string) => `/campaigns/donation/${slug}`, diff --git a/src/components/client/campaign-application/CampaignApplicationForm.tsx b/src/components/client/campaign-application/CampaignApplicationForm.tsx index 59521a002..79a39d0f0 100644 --- a/src/components/client/campaign-application/CampaignApplicationForm.tsx +++ b/src/components/client/campaign-application/CampaignApplicationForm.tsx @@ -1,6 +1,6 @@ -import { Grid, StepLabel } from '@mui/material' +import { Grid, StepLabel, Typography } from '@mui/material' import { Person } from 'gql/person' -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { CampaignApplicationFormData, @@ -8,8 +8,9 @@ import { Steps, } from './helpers/campaignApplication.types' +import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos' +import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos' import GenericForm from 'components/common/form/GenericForm' -import CampaignApplicationFormActions from './CampaignApplicationFormActions' import CampaignApplicationRemark from './CampaignApplicationRemark' import CampaignApplicationStepperIcon from './CampaignApplicationStepperIcon' import CampaignApplication from './steps/CampaignApplication' @@ -28,9 +29,8 @@ import { UploadCampaignApplicationFilesResponse, } from 'gql/campaign-applications' import { CampaignTypesResponse } from 'gql/campaign-types' -import { t } from 'i18next' import { useTranslation } from 'next-i18next' -import { ApiErrors, matchValidator } from 'service/apiErrors' +import { ApiErrors } from 'service/apiErrors' import { useCreateCampaignApplication, useUploadCampaignApplicationFiles, @@ -42,6 +42,16 @@ import { StyledCampaignApplicationStepper, StyledStepConnector, } from './helpers/campaignApplication.styled' +import { + ActionButton, + ActionLinkButton, + ActionSubmitButton, + Root, +} from './helpers/campaignApplicationFormActions.styled' +import { red } from '@mui/material/colors' +import CampaignApplicationSummary from './steps/CampaignApplicationSummary' +import { useRouter } from 'next/router' +import { routes } from 'common/routes' const steps: StepType[] = [ { @@ -61,9 +71,6 @@ type Props = { export default function CampaignApplicationForm({ person }: Props) { const { t } = useTranslation('campaign-application') - const [activeStep, setActiveStep] = useState(Steps.ORGANIZER) - const isLast = activeStep === Steps.CAMPAIGN_DETAILS - const [submitting, setSubmitting] = useState(false) const initialValues: CampaignApplicationFormData = { organizer: { @@ -94,36 +101,31 @@ export default function CampaignApplicationForm({ person }: Props) { } const [files, setFiles] = useState([]) + const router = useRouter() + + const { + createApplication, + applicationCreated, + submitting, + uploadedFiles, + error: createCampaignError, + campaignApplicationResult: camApp, + } = useCreateApplication() - const { data } = useCampaignTypesList() - const create = useCreateApplication() + const { data: types } = useCampaignTypesList() const handleSubmit = async ( formData: CampaignApplicationFormData, - { setFieldError, resetForm }: FormikHelpers, + { resetForm }: FormikHelpers, ) => { - if (isLast) { - if (submitting) { - return - } - setSubmitting(true) - try { - const createInput = mapCreateInput(formData, data ?? []) - await create(createInput, files) - + if (activeStep === Steps.CREATED_DETAILS && camApp?.id != null) { + router.push(routes.campaigns.applicationEdit(camApp?.id)) + } else if (shouldSubmit) { + const createInput = mapCreateInput(formData, types ?? []) + await createApplication(createInput, files) + if (applicationCreated) { + setActiveStep(Steps.CREATED_DETAILS) resetForm() - setSubmitting(false) AlertStore.show(t('alerts.successfully-created'), 'success') - - // take user to next campaign application page - } catch (error) { - setSubmitting(false) - console.error(error) - if (isAxiosError(error)) { - const { response } = error as AxiosError - response?.data.message.map(({ property, constraints }) => { - setFieldError(property, t(matchValidator(constraints))) - }) - } } } else { setActiveStep((prevActiveStep) => prevActiveStep + 1) @@ -134,6 +136,15 @@ export default function CampaignApplicationForm({ person }: Props) { setActiveStep((prevActiveStep) => prevActiveStep - 1) }, []) + const [activeStep, setActiveStep] = useState(Steps.ORGANIZER) + const shouldSubmit = activeStep === Steps.CAMPAIGN_DETAILS + + useEffect(() => { + if (applicationCreated && camApp?.id) { + setActiveStep(Steps.CREATED_DETAILS) + } + }, [applicationCreated]) + return ( <> @@ -156,20 +167,69 @@ export default function CampaignApplicationForm({ person }: Props) { {activeStep === Steps.CAMPAIGN_DETAILS && ( )} + {activeStep === Steps.CREATED_DETAILS && ( + + )} - + + + {activeStep === Steps.ORGANIZER ? ( + }> + {t('cta.back')} + + ) : ( + } + disabled={ + applicationCreated /**after campaign application is created disable going back and editing */ + }> + {t('cta.back')} + + )} + + + } + disabled={submitting} + /> + + + {(activeStep === Steps.ORGANIZER || activeStep === Steps.CAMPAIGN) && ( + + )} + {/* campaign errors */} + {createCampaignError && ( + <> + Errors: + {createCampaignError?.map((e, i) => ( +

{e}

+ ))} + + )} - {(activeStep === Steps.ORGANIZER || activeStep === Steps.CAMPAIGN) && ( - - )} ) } @@ -181,8 +241,6 @@ const useCreateApplication = () => { CreateCampaignApplicationInput >({ mutationFn: useCreateCampaignApplication(), - onError: () => AlertStore.show(t('common:alerts.error'), 'error'), - onSuccess: () => AlertStore.show(t('common:alerts.message-sent'), 'success'), }) const fileUpload = useMutation< @@ -193,14 +251,79 @@ const useCreateApplication = () => { mutationFn: useUploadCampaignApplicationFiles(), }) - return async (i: CreateCampaignApplicationInput, files: File[]) => { - const { - data: { id }, - } = await create.mutateAsync(i) + const [submitting, setSubmitting] = useState(false) + const [created, setCreated] = useState(false) + const [error, setError] = useState() + const [uploadedFiles, setFileUploadState] = useState>({ + successful: [], + failed: [], + }) + const [campaignApplicationResult, setCampaignApplicationResult] = + useState() + + const createApplication = async (input: CreateCampaignApplicationInput, files: File[]) => { + if (submitting) { + return + } + setSubmitting(true) + + const dataOrError = await create.mutateAsync(input).catch((e) => e as AxiosError) + + if (isAxiosError(dataOrError)) { + setSubmitting(false) + if (typeof dataOrError.response?.data.message === 'string') { + setError([dataOrError.response?.data.message]) + } else { + setError(dataOrError.response?.data?.message?.flatMap((m) => Object.values(m.constraints))) + } + return + } + + if (dataOrError?.data?.id == null) { + // it appears the create was not successful after all so still + setSubmitting(false) + setError(['could not create a campaign application']) + return + } - await fileUpload.mutateAsync({ campaignApplicationId: id, files }) + const campaignApplication = dataOrError.data + setCreated(true) + setCampaignApplicationResult(campaignApplication) - return { id } + const uploadedFilesMap = new Map() + await Promise.all( + files.map((f) => + fileUpload + .mutateAsync({ campaignApplicationId: campaignApplication.id, files: [f] }) + .then(() => { + uploadedFilesMap.set(f.name, 'success') + }) + .catch((e) => { + console.log('----error', e) + // one of the files was rejected - note + uploadedFilesMap.set(f.name, 'fail') + }), + ), + ) + + const fileUploadResults = [...uploadedFilesMap.entries()].reduce((a, [key, value]) => { + value === 'fail' ? a.failed.push(key) : a.successful.push(key) + return a + }, uploadedFiles) + + setFileUploadState(fileUploadResults) + setSubmitting(false) + + return { id: campaignApplication.id, ...input } + } + + return { + createApplication, + applicationCreated: created, + submitting, + uploadedFiles, + error, + campaignApplicationResult, } } @@ -228,5 +351,6 @@ function mapCreateInput( campaignGuarantee: i.details.campaignGuarantee, history: i.details.currentStatus, otherFinanceSources: i.details.otherFinancialSources, + campaignEnd: i.application.campaignEnd, } } diff --git a/src/components/client/campaign-application/CampaignApplicationFormActions.tsx b/src/components/client/campaign-application/CampaignApplicationFormActions.tsx deleted file mode 100644 index e1443040d..000000000 --- a/src/components/client/campaign-application/CampaignApplicationFormActions.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { MouseEvent } from 'react' - -import { useTranslation } from 'next-i18next' - -import { Grid } from '@mui/material' -import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos' -import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos' - -import { - ActionButton, - ActionLinkButton, - ActionSubmitButton, - Root, -} from './helpers/campaignApplicationFormActions.styled' - -type CampaignApplicationFormActionsProps = { - activeStep: number - onBack?: (event: MouseEvent) => void - isLast: boolean - submitting: boolean -} - -export default function CampaignApplicationFormActions({ - onBack, - activeStep, - isLast, - submitting, -}: CampaignApplicationFormActionsProps) { - const { t } = useTranslation('campaign-application') - - return ( - - - {activeStep === 0 ? ( - }> - {t('cta.back')} - - ) : ( - }> - {t('cta.back')} - - )} - - - } - disabled={submitting} - /> - - - ) -} diff --git a/src/components/client/campaign-application/helpers/campaignApplication.types.ts b/src/components/client/campaign-application/helpers/campaignApplication.types.ts index c97b19852..bc22f9d00 100644 --- a/src/components/client/campaign-application/helpers/campaignApplication.types.ts +++ b/src/components/client/campaign-application/helpers/campaignApplication.types.ts @@ -7,6 +7,7 @@ export enum Steps { ORGANIZER = 0, CAMPAIGN = 1, CAMPAIGN_DETAILS = 2, + CREATED_DETAILS = 3, } export type CampaignApplicationOrganizer = { diff --git a/src/components/client/campaign-application/steps/CampaignApplication.tsx b/src/components/client/campaign-application/steps/CampaignApplication.tsx index b39b482fc..1f2aa0c0c 100644 --- a/src/components/client/campaign-application/steps/CampaignApplication.tsx +++ b/src/components/client/campaign-application/steps/CampaignApplication.tsx @@ -31,7 +31,7 @@ export default function CampaignApplication() { diff --git a/src/components/client/campaign-application/steps/CampaignApplicationDetails.tsx b/src/components/client/campaign-application/steps/CampaignApplicationDetails.tsx index 733974aa8..40bd720f6 100644 --- a/src/components/client/campaign-application/steps/CampaignApplicationDetails.tsx +++ b/src/components/client/campaign-application/steps/CampaignApplicationDetails.tsx @@ -1,10 +1,9 @@ -import { Grid, Typography } from '@mui/material' +import { Grid } from '@mui/material' import { useTranslation } from 'next-i18next' import FormTextField from 'components/common/form/FormTextField' import { StyledStepHeading } from '../helpers/campaignApplication.styled' -import theme from 'common/theme' import FileList from 'components/common/file-upload/FileList' import FileUpload from 'components/common/file-upload/FileUpload' import { Dispatch, SetStateAction } from 'react' @@ -26,8 +25,8 @@ export default function CampaignApplicationDetails({ files, setFiles }: Props) { @@ -35,67 +34,29 @@ export default function CampaignApplicationDetails({ files, setFiles }: Props) { - - - {t('steps.details.links.label')} - - - - - - - - - - - - - - - - { setFiles((prevFiles) => [...prevFiles, ...newFiles]) }} + accept="text/plain,application/json,application/pdf,image/png,image/jpeg,application/xml,text/xml,application/msword,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" /> + camApp?: CreateCampaignApplicationResponse +} + +export default function CampaignApplicationSummary({ uploadedFiles, camApp }: SummaryProps) { + const { t } = useTranslation('campaign-application') + + return ( + <> + + {uploadedFiles.successful.length > 0 && ( +

+ {t('result.uploadOk')}: {uploadedFiles.successful.join()} +

+ )} + {uploadedFiles.failed.length > 0 && ( + +

+ {t('result.uploadFailed')}:{' '} + {uploadedFiles.failed?.map((f) => ( + // eslint-disable-next-line react/jsx-key + {f} + ))} +

+

{t('result.uploadFailedDirection')}

+
+ )} +
+ + + + + {t('steps.organizer.name')} : {camApp?.organizerName ?? '-'} + + + + + + + {t('steps.organizer.phone')} : {camApp?.organizerPhone ?? '-'} + + + + + {t('steps.organizer.email')}: {camApp?.organizerEmail ?? '-'} + + + + + {t('steps.application.beneficiary')}: {camApp?.beneficiary ?? '-'} + + + + + {t('steps.application.beneficiaryRelationship')}: + {camApp?.organizerBeneficiaryRel ?? '-'} + + + + + {t('steps.application.campaignTitle')}: {camApp?.campaignName ?? '-'} + + + + + {t('steps.application.funds')}: {camApp?.amount ?? '-'} + + + + + {t('steps.application.campaign-end.title')} {camApp?.campaignEnd ?? '-'} + + + + + {t('steps.details.cause')}: {camApp?.goal ?? '-'} + + + + + {t('steps.details.description')}: {camApp?.description ?? '-'} + + + + + {t('steps.details.current-status.label')}: {camApp?.history ?? '-'} + + + + + + ) +} diff --git a/src/components/common/file-upload/FileUpload.tsx b/src/components/common/file-upload/FileUpload.tsx index db3b59444..8ad2af498 100644 --- a/src/components/common/file-upload/FileUpload.tsx +++ b/src/components/common/file-upload/FileUpload.tsx @@ -3,9 +3,11 @@ import { Button } from '@mui/material' function FileUpload({ onUpload, buttonLabel, + ...rest }: { onUpload: (files: File[]) => void buttonLabel: string + accept?: string }) { return (