From 3a912bf107bfaa928acf87d4f675b3a9110fe94b Mon Sep 17 00:00:00 2001 From: Georgi Parlakov Date: Wed, 25 Sep 2024 19:10:14 +0300 Subject: [PATCH] Campaign Applications (alpha) (#1930) * 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 * feat: validation of create campaign application * fix: send campaignTypeId + editing + visuals - send campaignTypeId instead of the category (which is a property of the campaign type and is not enough to uniquely distinguish the type) - allow for editing and re-editing - style the summary - fix linting errors * chore: test create application - moved the useCreateOrEditApplication hook to own file to reuse for the admin campaign edit - added campaign end date handling (if the user selects a date for the end it pops up an input and fills in the preselected by user date or today if none) - removed some unused props from the cam app details - added msw v1 https://v1.mswjs.io/docs/getting-started/mocks/rest-api to mock the server responses for testing (Mock Service Worker) - expanded the test setup to provide Session and QueryClient * chore: add tests for the createOrEdit application hook * feat: admin edit - it uses the same controls/steps as the organizer edit but adds the admin only props on top - adds a link and copy button for the link where organizer can edit so as to include it if sending a mail to the org (e.g. for more info) - deletes the admin types - using same as regular - adds the status dropdown for admin edit - allows the accept terms checkboxes to be disabled for admin * feat: actual list of campaign applications * fix: small fixes * fix: pre-mature validation on basic step * fix: missing method --- package.json | 1 + public/locales/bg/campaign-application.json | 29 +- public/locales/en/campaign-application.json | 27 +- src/common/routes.ts | 1 + .../CampaignApplicationAdminPropsEdit.tsx | 26 +- .../CampaignApplicationOrganizerCanEditAt.tsx | 23 + .../CampaignApplicationsGrid.tsx | 97 ++-- .../admin/campaign-applications/EditPage.tsx | 135 ++++-- .../campaignApplicationAdmin.types.ts | 6 - .../CampaignApplicationForm.tsx | 271 +++++------ .../CampaignApplicationFormActions.tsx | 61 --- .../EditCampaignApplicationPage.tsx | 31 ++ .../helpers/campaign-application-status.tsx | 27 ++ .../helpers/campaignApplication.types.ts | 51 ++- .../helpers/validation-schema.ts | 81 +++- ...ation.tsx => CampaignApplicationBasic.tsx} | 53 ++- .../steps/CampaignApplicationDetails.tsx | 60 +-- .../steps/CampaignApplicationOrganizer.tsx | 14 +- .../{ => steps}/CampaignApplicationRemark.tsx | 0 .../CampaignApplicationStepperIcon.tsx | 2 +- .../steps/CampaignApplicationSummary.tsx | 145 ++++++ .../common/file-upload/FileUpload.tsx | 3 + .../common/form/AcceptPrivacyPolicyField.tsx | 9 +- .../common/form/AcceptTermsField.tsx | 9 +- src/gql/campaign-applications.ts | 37 +- .../admin/campaign-applications/edit/[id].tsx | 7 +- .../campaigns/application/[id]/index.tsx | 43 ++ .../index.tsx} | 0 src/service/apiEndpoints.ts | 5 + src/service/campaign-application.ts | 279 +++++++++++- .../campaign-application.test.tsx | 382 ++++++++++++++++ src/test/response-handler/response-handler.ts | 46 ++ src/test/setupTests.ts | 2 + src/test/test-utils.tsx | 49 +- yarn.lock | 429 +++++++++++++++++- 35 files changed, 1991 insertions(+), 450 deletions(-) create mode 100644 src/components/admin/campaign-applications/CampaignApplicationOrganizerCanEditAt.tsx delete mode 100644 src/components/admin/campaign-applications/campaignApplicationAdmin.types.ts delete mode 100644 src/components/client/campaign-application/CampaignApplicationFormActions.tsx create mode 100644 src/components/client/campaign-application/EditCampaignApplicationPage.tsx create mode 100644 src/components/client/campaign-application/helpers/campaign-application-status.tsx rename src/components/client/campaign-application/steps/{CampaignApplication.tsx => CampaignApplicationBasic.tsx} (63%) rename src/components/client/campaign-application/{ => steps}/CampaignApplicationRemark.tsx (100%) rename src/components/client/campaign-application/{ => steps}/CampaignApplicationStepperIcon.tsx (81%) create mode 100644 src/components/client/campaign-application/steps/CampaignApplicationSummary.tsx create mode 100644 src/pages/campaigns/application/[id]/index.tsx rename src/pages/campaigns/{application.tsx => application/index.tsx} (100%) create mode 100644 src/test/campaign-application/campaign-application.test.tsx create mode 100644 src/test/response-handler/response-handler.ts diff --git a/package.json b/package.json index 5621762de..6059144d7 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,7 @@ "jest": "^29.6.1", "jest-environment-jsdom": "^29.6.1", "lint-staged": "11.0.0", + "msw": "1", "next-sitemap": "^3.1.52", "prettier": "2.3.0", "shx": "^0.3.3", diff --git a/public/locales/bg/campaign-application.json b/public/locales/bg/campaign-application.json index 4d69d7341..1a075e329 100644 --- a/public/locales/bg/campaign-application.json +++ b/public/locales/bg/campaign-application.json @@ -40,12 +40,15 @@ } }, "photos": "Снимков/видео материал", - "documents": "Документи" + "documents": "Документи", + "disclaimer": "Приемат се до 10 документа. Всеки от документите може да бъде до 30МБ" }, "admin": { "title": "Администраторска редакция", "status": "Статус", - "external-url": "Външен линк" + "external-url": "Външен линк", + "archived": "Архивиран", + "organizer-edit-link": "Организатор може да редактира на" } }, "cta": { @@ -62,6 +65,26 @@ } }, "alerts": { - "successfully-created": "Успешно създадена кампания. " + "successfully-created": "Успешно създадена заявка за кампания. " + }, + "result": { + "created": "Успешно създадена заявка за кампания.", + "edited": "Успешно редактирана заявка за кампания.", + "editButton": "Редакция на заявка за кампания", + "uploadOk": "Добавени файлове", + "deleteOk": "Премахнати файлове", + "uploadFailed": "Неуспешно добавени файлове", + "deleteFailed": "Неуспешно премахнати файлове", + "uploadFailedWhat": "Какво мога да направя?", + "uploadFailedDirection": "Моля посетете страницата за редакция на заявката за кампания от бутона по-долу и опитайте отново да добавите/премахнете файловете" + }, + "status": { + "selectorLabel": "Статус", + "review": "Нова за ревю", + "requestInfo": "За още информация", + "forCommitteeReview": "За ревю от комисия", + "approved": "Одобрена", + "denied": "Отказана", + "abandoned": "Изоставена" } } diff --git a/public/locales/en/campaign-application.json b/public/locales/en/campaign-application.json index 3e32e7797..847b7f696 100644 --- a/public/locales/en/campaign-application.json +++ b/public/locales/en/campaign-application.json @@ -40,12 +40,15 @@ } }, "photos": "Photo/Video material", - "documents": "Documents" + "documents": "Documents", + "disclaimer": "Up to 10 documents accepted. Each of the documents can be up to 30MB is size" }, "admin": { "title": "Admin edit", "status": "Status", - "external-url": "External URL" + "external-url": "External URL", + "archived": "Archived", + "organizer-edit-link": "Organizer can edit at" } }, "cta": { @@ -63,5 +66,25 @@ }, "alerts": { "successfully-created": "Campaign application successfully created." + }, + "result": { + "created": "Successfully created campaign application", + "edited": "Successfully edited campaign application", + "editButton": "Edit campaign application", + "uploadOk": "Fails added", + "deleteOk": "Files removed", + "uploadFailed": "Failed file add", + "deleteFailed": "Failed to remove files", + "uploadFailedWhat": "What can I do?", + "uploadFailedDirection": "Please visit the campaign application edit page linked below and retry adding/removing the files." + }, + "status": { + "selectorLabel": "Status", + "review": "New - for review", + "requestInfo": "Request for more info", + "forCommitteeReview": "For committee review", + "approved": "Approved", + "denied": "Denied", + "abandoned": "Abandoned" } } 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/admin/campaign-applications/CampaignApplicationAdminPropsEdit.tsx b/src/components/admin/campaign-applications/CampaignApplicationAdminPropsEdit.tsx index b1a49e689..52171a6c7 100644 --- a/src/components/admin/campaign-applications/CampaignApplicationAdminPropsEdit.tsx +++ b/src/components/admin/campaign-applications/CampaignApplicationAdminPropsEdit.tsx @@ -1,33 +1,45 @@ import { useTranslation } from 'next-i18next' import { Grid } from '@mui/material' +import { StatusSelector } from 'components/client/campaign-application/helpers/campaign-application-status' import { StyledFormTextField, StyledStepHeading, } from 'components/client/campaign-application/helpers/campaignApplication.styled' +import { CamAppDetail } from 'components/client/campaign-application/steps/CampaignApplicationSummary' +import CheckboxField from 'components/common/form/CheckboxField' +import OrganizerCanEditAt from './CampaignApplicationOrganizerCanEditAt' -export default function CampaignApplicationAdminPropsEdit() { +export default function CampaignApplicationAdminPropsEdit({ id }: { id: string }) { const { t } = useTranslation('campaign-application') - return ( {t('steps.admin.title')} - - + + + + + - + + + } + /> + ) } diff --git a/src/components/admin/campaign-applications/CampaignApplicationOrganizerCanEditAt.tsx b/src/components/admin/campaign-applications/CampaignApplicationOrganizerCanEditAt.tsx new file mode 100644 index 000000000..71efd59d5 --- /dev/null +++ b/src/components/admin/campaign-applications/CampaignApplicationOrganizerCanEditAt.tsx @@ -0,0 +1,23 @@ +import { routes } from 'common/routes' +import { CopyTextButton } from 'components/common/CopyTextButton' +import getConfig from 'next/config' +import Copy from '@mui/icons-material/CopyAll' +import { Typography } from '@mui/material' +export type Props = { + id: string +} +const OrganizerCanEditAt = ({ id }: Props) => { + const { publicRuntimeConfig } = getConfig() + const url = `${publicRuntimeConfig?.APP_URL}${routes.campaigns.applicationEdit(id)}` + + return ( + <> + + {url} + + } text={url} title={`Copy ${url}`} /> + + ) +} + +export default OrganizerCanEditAt diff --git a/src/components/admin/campaign-applications/CampaignApplicationsGrid.tsx b/src/components/admin/campaign-applications/CampaignApplicationsGrid.tsx index a4e1e3473..4d230a824 100644 --- a/src/components/admin/campaign-applications/CampaignApplicationsGrid.tsx +++ b/src/components/admin/campaign-applications/CampaignApplicationsGrid.tsx @@ -1,12 +1,18 @@ import { useTranslation } from 'next-i18next' import { DataGrid, GridColDef, GridRenderCellParams } from '@mui/x-data-grid' +import { useQuery } from '@tanstack/react-query' import { routes } from 'common/routes' import theme from 'common/theme' +import { CampaignApplicationAdminResponse } from 'gql/campaign-applications' +import { useSession } from 'next-auth/react' import Link from 'next/link' +import { endpoints } from 'service/apiEndpoints' +import { authQueryFnFactory } from 'service/restRequests' export default function CampaignApplicationsGrid() { - const { t, i18n } = useTranslation() + const { t } = useTranslation('campaign-application') + const { list } = useCampaignsList() const commonProps: Partial = { align: 'left', @@ -16,33 +22,34 @@ export default function CampaignApplicationsGrid() { const columns: GridColDef[] = [ { - field: 'status', + field: 'state', headerName: t('campaigns:status'), ...commonProps, align: 'left', width: 220, + renderCell: (cellValues: GridRenderCellParams) => t(`status.${cellValues.row.state}`), }, { - field: 'title', + field: 'campaignName', headerName: t('campaigns:title'), ...commonProps, align: 'left', width: 250, renderCell: (cellValues: GridRenderCellParams) => ( - {cellValues.row.title} + {cellValues.row.campaignName} ), }, { - field: 'essence', + field: 'goal', headerName: t('campaigns:essence'), ...commonProps, align: 'left', width: 250, }, { - field: 'organizer', + field: 'organizerName', headerName: t('campaigns:organizer'), ...commonProps, align: 'left', @@ -71,65 +78,12 @@ export default function CampaignApplicationsGrid() { }, ] - const data = [ - { - updatedAt: 'date', - createdAt: '2024-5-5', - beneficiary: 'beneficiary', - organizer: 'organizer', - essence: 'essence', - title: 'title', - id: '1', - status: 'нова', - }, - { - updatedAt: 'yesterday', - createdAt: '10 days ago', - beneficiary: 'beneficiary', - organizer: 'organizer', - essence: 'essence', - title: 'title', - id: '2', - status: 'очаква документи', - }, - { - updatedAt: '', - createdAt: '', - beneficiary: 'beneficiary', - organizer: 'organizer', - essence: 'essence', - title: 'title', - id: '3', - status: 'очаква решение на комисия', - }, - - { - updatedAt: '', - createdAt: '', - beneficiary: 'beneficiary', - organizer: 'organizer', - essence: 'essence', - title: 'title', - id: '4', - status: 'одобрена', - }, - { - updatedAt: '', - createdAt: '', - beneficiary: 'beneficiary', - organizer: 'organizer', - essence: 'essence', - title: 'title', - id: '4', - status: 'отказана', - }, - ] return ( ) } + +function fetchMutation() { + const { data } = useSession() + return useQuery( + [endpoints.campaignApplication.listAllForAdmin.url], + authQueryFnFactory(data?.accessToken), + { + cacheTime: 10 * 60 * 1000, + staleTime: 10 * 60 * 1000, + }, + ) +} + +export const useCampaignsList = () => { + const { data, isLoading } = fetchMutation() + + return { + list: data?.sort((a, b) => b?.updatedAt?.localeCompare(a?.updatedAt ?? '') ?? 0), + isLoading, + } +} diff --git a/src/components/admin/campaign-applications/EditPage.tsx b/src/components/admin/campaign-applications/EditPage.tsx index 30683f19a..7fdc80e11 100644 --- a/src/components/admin/campaign-applications/EditPage.tsx +++ b/src/components/admin/campaign-applications/EditPage.tsx @@ -1,44 +1,121 @@ -import { Box } from '@mui/material' -import CampaignApplication from 'components/client/campaign-application/steps/CampaignApplication' +import { Box, CircularProgress, Grid, Typography } from '@mui/material' +import { red } from '@mui/material/colors' +import { CampaignApplicationFormData } from 'components/client/campaign-application/helpers/campaignApplication.types' +import { ActionSubmitButton } from 'components/client/campaign-application/helpers/campaignApplicationFormActions.styled' +import CampaignApplicationBasic from 'components/client/campaign-application/steps/CampaignApplicationBasic' import CampaignApplicationDetails from 'components/client/campaign-application/steps/CampaignApplicationDetails' import CampaignApplicationOrganizer from 'components/client/campaign-application/steps/CampaignApplicationOrganizer' +import CampaignApplicationSummary, { + CamAppDetail, +} from 'components/client/campaign-application/steps/CampaignApplicationSummary' import GenericForm from 'components/common/form/GenericForm' import AdminContainer from 'components/common/navigation/AdminContainer' import AdminLayout from 'components/common/navigation/AdminLayout' +import { CampaignApplicationExisting } from 'gql/campaign-applications' +import { useTranslation } from 'next-i18next' +import NotFoundPage from 'pages/404' +import { + mapCreateOrEditInput, + useCreateOrEditApplication, + useViewCampaignApplicationCached, +} from 'service/campaign-application' import CampaignApplicationAdminPropsEdit from './CampaignApplicationAdminPropsEdit' -import { CampaignApplicationAdminEdit } from './campaignApplicationAdmin.types' -import { useState } from 'react' +import OrganizerCanEditAt from './CampaignApplicationOrganizerCanEditAt' +import { campaignApplicationAdminValidationSchema } from 'components/client/campaign-application/helpers/validation-schema' -export default function EditPage() { - const [files, setFiles] = useState([]) - - const initialValues = { - organizer: { - name: 'Some organizer', - phone: '+35999999', - email: 'aReal@Email.com', - }, - - status: 'review', - ticketUrl: 'https://trello.com/this-campaign-application', - } as CampaignApplicationAdminEdit +export type Props = { + id: string +} +export function EditLoadedCampaign({ campaign }: { campaign: CampaignApplicationExisting }) { + const { createOrUpdateApplication, ...c } = useCreateOrEditApplication({ + isEdit: true, + campaignApplication: campaign, + }) + const { t } = useTranslation('campaign-application') return ( - - onSubmit={(v) => console.log(v)} - initialValues={initialValues}> - -
.
- -
.
- -
.
- - - + {c.createOrUpdateSuccessful ? ( + + + + + Admin props / Административни подробности + + + } + /> + + + + + + } + /> + ) : ( + + onSubmit={async (v) => { + const request = mapCreateOrEditInput(v) + await createOrUpdateApplication(request) + }} + initialValues={c.initialValues} + validationSchema={campaignApplicationAdminValidationSchema.defined()}> + + + + + + {c.error && + c.error.map((e, i) => ( + + {e} + + ))} + + )} +
) } + +export default function EditPage({ id }: Props) { + const { data, isLoading, isError } = useViewCampaignApplicationCached(id, 60 * 1000) + + if (isLoading) { + return ( + + + + ) + } + + if (isError) { + return + } + + return +} diff --git a/src/components/admin/campaign-applications/campaignApplicationAdmin.types.ts b/src/components/admin/campaign-applications/campaignApplicationAdmin.types.ts deleted file mode 100644 index 66722ada4..000000000 --- a/src/components/admin/campaign-applications/campaignApplicationAdmin.types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { CampaignApplicationFormData } from 'components/client/campaign-application/helpers/campaignApplication.types' - -export type CampaignApplicationAdminEdit = CampaignApplicationFormData & { - status: string - ticketUrl?: string -} diff --git a/src/components/client/campaign-application/CampaignApplicationForm.tsx b/src/components/client/campaign-application/CampaignApplicationForm.tsx index 59521a002..42e7e6c54 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 { Person } from 'gql/person' -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { CampaignApplicationFormData, @@ -8,40 +8,36 @@ 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' +import CampaignApplicationBasic from './steps/CampaignApplicationBasic' import CampaignApplicationDetails from './steps/CampaignApplicationDetails' import CampaignApplicationOrganizer from './steps/CampaignApplicationOrganizer' +import CampaignApplicationRemark from './steps/CampaignApplicationRemark' +import CampaignApplicationStepperIcon from './steps/CampaignApplicationStepperIcon' import { validationSchema } from './helpers/validation-schema' -import { useMutation } from '@tanstack/react-query' -import { AxiosError, AxiosResponse, isAxiosError } from 'axios' +import { routes } from 'common/routes' import { FormikHelpers } from 'formik' -import { - CreateCampaignApplicationInput, - CreateCampaignApplicationResponse, - UploadCampaignApplicationFilesRequest, - UploadCampaignApplicationFilesResponse, -} from 'gql/campaign-applications' -import { CampaignTypesResponse } from 'gql/campaign-types' -import { t } from 'i18next' +import { CampaignApplicationExisting } from 'gql/campaign-applications' import { useTranslation } from 'next-i18next' -import { ApiErrors, matchValidator } from 'service/apiErrors' -import { - useCreateCampaignApplication, - useUploadCampaignApplicationFiles, -} from 'service/campaign-application' -import { useCampaignTypesList } from 'service/campaignTypes' +import { useRouter } from 'next/router' +import { mapCreateOrEditInput, useCreateOrEditApplication } from 'service/campaign-application' import { AlertStore } from 'stores/AlertStore' import { StyledCampaignApplicationStep, StyledCampaignApplicationStepper, StyledStepConnector, } from './helpers/campaignApplication.styled' +import { + ActionButton, + ActionLinkButton, + ActionSubmitButton, + Root, +} from './helpers/campaignApplicationFormActions.styled' +import CampaignApplicationSummary from './steps/CampaignApplicationSummary' const steps: StepType[] = [ { @@ -57,73 +53,50 @@ const steps: StepType[] = [ type Props = { person?: Person + isEdit?: boolean + campaignApplication?: CampaignApplicationExisting } -export default function CampaignApplicationForm({ person }: Props) { +export default function CampaignApplicationForm({ + person, + isEdit, + campaignApplication: existing, +}: 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: { - name: `${person?.firstName} ${person?.lastName}` ?? '', - phone: person?.phone ?? '', - email: person?.email ?? '', - acceptTermsAndConditions: false, - transparencyTermsAccepted: false, - personalInformationProcessingAccepted: false, - }, - application: { - title: '', - beneficiaryNames: '', - campaignType: '', - funds: 0, - campaignEnd: '', - }, - details: { - campaignGuarantee: '', - cause: '', - currentStatus: '', - description: '', - documents: [], - links: [], - organizerBeneficiaryRelationship: '-', - otherFinancialSources: '', - }, - } - - const [files, setFiles] = useState([]) + const router = useRouter() + + const { + createOrUpdateApplication, + createOrUpdateSuccessful: applicationCreated, + submitting, + uploadedFiles, + error: createCampaignError, + campaignApplicationResult: camApp, + files, + setFiles, + initialValues, + deletedFiles, + } = useCreateOrEditApplication({ + person, + isEdit, + campaignApplication: existing, + }) - const { data } = useCampaignTypesList() - const create = useCreateApplication() const handleSubmit = async ( formData: CampaignApplicationFormData, - { setFieldError, resetForm }: FormikHelpers, + { resetForm }: FormikHelpers, ) => { - if (isLast) { - if (submitting) { - return + if (activeStep === Steps.CREATED_DETAILS && camApp?.id != null) { + router.push(routes.campaigns.applicationEdit(camApp?.id)) // go to the edit page + if (isEdit) { + router.reload() // in case we are re-editing refresh the whole page to reset all the things } - setSubmitting(true) - try { - const createInput = mapCreateInput(formData, data ?? []) - await create(createInput, files) - + } else if (shouldSubmit) { + const createOrEdit = mapCreateOrEditInput(formData) + await createOrUpdateApplication(createOrEdit) + if (applicationCreated) { 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 +107,16 @@ export default function CampaignApplicationForm({ person }: Props) { setActiveStep((prevActiveStep) => prevActiveStep - 1) }, []) + const [activeStep, setActiveStep] = useState(Steps.ORGANIZER) + const shouldSubmit = activeStep === Steps.CAMPAIGN_DETAILS + + // move to last step after campaign application created successfully + useEffect(() => { + if (applicationCreated && camApp?.id) { + setActiveStep(Steps.CREATED_DETAILS) + } + }, [applicationCreated]) + return ( <> @@ -152,81 +135,79 @@ export default function CampaignApplicationForm({ person }: Props) { {activeStep === Steps.ORGANIZER && } - {activeStep === Steps.CAMPAIGN && } + {activeStep === Steps.CAMPAIGN_BASIC && } {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_BASIC) && ( + + )} + {/* campaign errors */} + {createCampaignError && ( + <> + Errors: + {createCampaignError?.map((e, i) => ( +

{e}

+ ))} + + )} - {(activeStep === Steps.ORGANIZER || activeStep === Steps.CAMPAIGN) && ( - - )} ) } - -const useCreateApplication = () => { - const create = useMutation< - AxiosResponse, - AxiosError, - CreateCampaignApplicationInput - >({ - mutationFn: useCreateCampaignApplication(), - onError: () => AlertStore.show(t('common:alerts.error'), 'error'), - onSuccess: () => AlertStore.show(t('common:alerts.message-sent'), 'success'), - }) - - const fileUpload = useMutation< - AxiosResponse, - AxiosError, - UploadCampaignApplicationFilesRequest - >({ - mutationFn: useUploadCampaignApplicationFiles(), - }) - - return async (i: CreateCampaignApplicationInput, files: File[]) => { - const { - data: { id }, - } = await create.mutateAsync(i) - - await fileUpload.mutateAsync({ campaignApplicationId: id, files }) - - return { id } - } -} - -function mapCreateInput( - i: CampaignApplicationFormData, - types: CampaignTypesResponse[], -): CreateCampaignApplicationInput { - return { - acceptTermsAndConditions: i.organizer.acceptTermsAndConditions, - personalInformationProcessingAccepted: i.organizer.personalInformationProcessingAccepted, - transparencyTermsAccepted: i.organizer.transparencyTermsAccepted, - - organizerName: i.organizer.name, - organizerEmail: i.organizer.email, - organizerPhone: i.organizer.phone, - - beneficiary: i.application.beneficiaryNames, - - campaignName: i.application.title, - amount: i.application.funds?.toString() ?? '', - goal: i.details.cause, - category: types.find((c) => c.id === i.application.campaignType)?.category, - description: i.details.description, - organizerBeneficiaryRel: i.details.organizerBeneficiaryRelationship ?? '-', - campaignGuarantee: i.details.campaignGuarantee, - history: i.details.currentStatus, - otherFinanceSources: i.details.otherFinancialSources, - } -} 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/EditCampaignApplicationPage.tsx b/src/components/client/campaign-application/EditCampaignApplicationPage.tsx new file mode 100644 index 000000000..2326a4845 --- /dev/null +++ b/src/components/client/campaign-application/EditCampaignApplicationPage.tsx @@ -0,0 +1,31 @@ +import { CircularProgress, Grid } from '@mui/material' +import NotFoundPage from 'pages/404' +import { useViewCampaignApplicationCached } from 'service/campaign-application' +import Layout from '../layout/Layout' +import CampaignApplicationForm from './CampaignApplicationForm' + +interface EditProps { + id: string +} + +export default function EditCampaignApplicationPage({ id }: EditProps) { + const { data, isLoading, isError } = useViewCampaignApplicationCached(id) + + if (isLoading) { + return ( + + + + ) + } + + if (isError) { + return + } + + return ( + + + + ) +} diff --git a/src/components/client/campaign-application/helpers/campaign-application-status.tsx b/src/components/client/campaign-application/helpers/campaign-application-status.tsx new file mode 100644 index 000000000..31d440d26 --- /dev/null +++ b/src/components/client/campaign-application/helpers/campaign-application-status.tsx @@ -0,0 +1,27 @@ +import { FormControl, FormControlProps, InputLabel, MenuItem, Select } from '@mui/material' +import { useField } from 'formik' +import { useTranslation } from 'next-i18next' +import { allStates } from './campaignApplication.types' + +export type Props = { name: string } & FormControlProps + +export const StatusSelector = ({ name, ...control }: Props) => { + const [field] = useField(name) + const { t } = useTranslation('campaign-application') + return ( + + {t('status.selectorLabel')} + + + ) +} diff --git a/src/components/client/campaign-application/helpers/campaignApplication.types.ts b/src/components/client/campaign-application/helpers/campaignApplication.types.ts index c97b19852..cba46e6d5 100644 --- a/src/components/client/campaign-application/helpers/campaignApplication.types.ts +++ b/src/components/client/campaign-application/helpers/campaignApplication.types.ts @@ -5,8 +5,9 @@ export type Step = { export enum Steps { NONE = -1, ORGANIZER = 0, - CAMPAIGN = 1, + CAMPAIGN_BASIC = 1, CAMPAIGN_DETAILS = 2, + CREATED_DETAILS = 3, } export type CampaignApplicationOrganizer = { @@ -18,27 +19,51 @@ export type CampaignApplicationOrganizer = { personalInformationProcessingAccepted: boolean } -export type CampaignApplication = { +export type CampaignApplicationBasic = { beneficiaryNames: string title: string campaignType: string funds: number campaignEnd: string + campaignEndDate?: string +} + +export type CampaignApplicationDetails = { + cause: string + organizerBeneficiaryRelationship?: string + description?: string + currentStatus?: string +} + +// keep in sync with api repo/podkrepi.dbml -> Enum CampaignApplicationState +export type CampaignApplicationState = + | 'review' + | 'requestInfo' + | 'forCommitteeReview' + | 'approved' + | 'denied' + | 'abandoned' + +export const allStates: CampaignApplicationState[] = [ + 'review', + 'requestInfo', + 'forCommitteeReview', + 'approved', + 'denied', + 'abandoned', +] + +export type CampaignApplicationAdmin = { + state: CampaignApplicationState + ticketURL?: string + archived?: boolean } export type CampaignApplicationFormData = { organizer: CampaignApplicationOrganizer - application: CampaignApplication - details: { - organizerBeneficiaryRelationship: string - campaignGuarantee: string | undefined - otherFinancialSources: string | undefined - description: string - currentStatus: string - cause: string - links: string[] - documents: string[] - } + applicationBasic: CampaignApplicationBasic + applicationDetails: CampaignApplicationDetails + admin?: CampaignApplicationAdmin } export type CampaignApplicationFormDataSteps = { diff --git a/src/components/client/campaign-application/helpers/validation-schema.ts b/src/components/client/campaign-application/helpers/validation-schema.ts index 12b46930e..58f1d7598 100644 --- a/src/components/client/campaign-application/helpers/validation-schema.ts +++ b/src/components/client/campaign-application/helpers/validation-schema.ts @@ -1,29 +1,82 @@ import * as yup from 'yup' -import { name, phone, email } from 'common/form/validation' +import { email, name, phone } from 'common/form/validation' import { - CampaignApplicationFormDataSteps, + CampaignApplicationAdmin, + CampaignApplicationBasic, + CampaignApplicationDetails, + CampaignApplicationFormData, CampaignApplicationOrganizer, + CampaignApplicationState, Steps, } from './campaignApplication.types' -const organizer: yup.SchemaOf = yup - .object() - .shape({ - name: name.required(), - phone: phone.required(), - email: email.required(), - }) - .defined() +const organizerSchema: yup.SchemaOf = yup.object().shape({ + name: name.required(), + phone: phone.required(), + email: email.required(), + acceptTermsAndConditions: yup.bool().oneOf([true], 'validation:terms-of-use').required(), + transparencyTermsAccepted: yup.bool().oneOf([true], 'validation:required').required(), + personalInformationProcessingAccepted: yup + .bool() + .oneOf([true], 'validation:terms-of-service') + .required(), +}) + +const basicSchema: yup.SchemaOf = yup.object().shape({ + beneficiaryNames: yup.string().required(), + campaignEnd: yup.string().required(), + campaignType: yup.string().required(), + funds: yup.number().required(), + title: yup.string().required(), + campaignEndDate: yup.string().optional(), +}) + +const detailsSchema: yup.SchemaOf = yup.object().shape({ + cause: yup.string().required(), + campaignGuarantee: yup.string().optional(), + currentStatus: yup.string().optional(), + description: yup.string().optional(), + documents: yup.array().optional(), + links: yup.array().optional(), + organizerBeneficiaryRelationship: yup.string().optional(), + otherFinancialSources: yup.string().optional(), +}) + +const adminPropsSchema: yup.SchemaOf = yup.object().shape({ + state: yup + .mixed() + .oneOf(['review', 'requestInfo', 'forCommitteeReview', 'approved', 'denied', 'abandoned']) + .required(), + ticketURL: yup.string().optional(), + archived: yup.bool().optional(), +}) export const validationSchema: { - [key in Steps]?: - | yup.SchemaOf - | yup.SchemaOf + [Steps.NONE]: undefined + [Steps.ORGANIZER]: yup.SchemaOf> + [Steps.CAMPAIGN_BASIC]: yup.SchemaOf> + [Steps.CAMPAIGN_DETAILS]: yup.SchemaOf> + [Steps.CREATED_DETAILS]: undefined } = { [Steps.NONE]: undefined, + [Steps.CREATED_DETAILS]: undefined, [Steps.ORGANIZER]: yup.object().shape({ - organizer: organizer.required(), + organizer: organizerSchema.defined(), + }), + [Steps.CAMPAIGN_BASIC]: yup.object().shape({ + applicationBasic: basicSchema.defined(), + }), + [Steps.CAMPAIGN_DETAILS]: yup.object().shape({ + applicationDetails: detailsSchema.defined(), }), } + +export const campaignApplicationAdminValidationSchema: yup.SchemaOf = + yup.object().shape({ + organizer: organizerSchema.defined(), + applicationBasic: basicSchema.defined(), + applicationDetails: detailsSchema.defined(), + admin: adminPropsSchema.nullable().defined(), + }) diff --git a/src/components/client/campaign-application/steps/CampaignApplication.tsx b/src/components/client/campaign-application/steps/CampaignApplicationBasic.tsx similarity index 63% rename from src/components/client/campaign-application/steps/CampaignApplication.tsx rename to src/components/client/campaign-application/steps/CampaignApplicationBasic.tsx index b39b482fc..864dcd8f9 100644 --- a/src/components/client/campaign-application/steps/CampaignApplication.tsx +++ b/src/components/client/campaign-application/steps/CampaignApplicationBasic.tsx @@ -1,17 +1,33 @@ import { FormControl, Grid, Typography } from '@mui/material' -import { Field, useField } from 'formik' +import { Field, useFormikContext } from 'formik' import { useTranslation } from 'next-i18next' -import { StyledFormTextField, StyledStepHeading } from '../helpers/campaignApplication.styled' -import { CampaignEndTypes } from '../helpers/campaignApplication.types' import CampaignTypeSelect from 'components/client/campaigns/CampaignTypeSelect' import FormDatePicker from 'components/common/form/FormDatePicker' +import { StyledFormTextField, StyledStepHeading } from '../helpers/campaignApplication.styled' +import { CampaignApplicationFormData, CampaignEndTypes } from '../helpers/campaignApplication.types' import theme from 'common/theme' +import { useEffect, useState } from 'react' -export default function CampaignApplication() { +export default function CampaignApplicationBasic() { const { t } = useTranslation('campaign-application') - const [campaignEnd] = useField('application.campaignEnd') + const { values, setFieldValue } = useFormikContext() + // if user selects the date we'll fill in the previously selected (or new Date()) or remove that in case they chose another option + const [selectedDate, setSelectedDate] = useState( + values?.applicationBasic?.campaignEndDate ?? new Date().toString(), + ) + useEffect(() => { + const endDate = values.applicationBasic?.campaignEndDate + if (endDate != null && endDate != selectedDate) { + setSelectedDate(endDate) + } + setFieldValue( + 'applicationBasic.campaignEndDate', + values?.applicationBasic?.campaignEnd === CampaignEndTypes.DATE ? selectedDate : undefined, + false, + ) + }, [values?.applicationBasic?.campaignEnd]) return ( @@ -23,7 +39,7 @@ export default function CampaignApplication() { @@ -31,24 +47,24 @@ export default function CampaignApplication() {
- + @@ -65,7 +81,7 @@ export default function CampaignApplication() { {t('steps.application.campaign-end.options.funds')} @@ -76,7 +92,7 @@ export default function CampaignApplication() { {t('steps.application.campaign-end.options.ongoing')} @@ -87,18 +103,19 @@ export default function CampaignApplication() { {t('steps.application.campaign-end.options.date')}
- {campaignEnd.value === CampaignEndTypes.DATE && ( - - - - )} + {values?.applicationBasic?.campaignEnd === CampaignEndTypes.DATE && + values?.applicationBasic?.campaignEndDate != null && ( + + + + )} diff --git a/src/components/client/campaign-application/steps/CampaignApplicationDetails.tsx b/src/components/client/campaign-application/steps/CampaignApplicationDetails.tsx index 733974aa8..6fa0945f6 100644 --- a/src/components/client/campaign-application/steps/CampaignApplicationDetails.tsx +++ b/src/components/client/campaign-application/steps/CampaignApplicationDetails.tsx @@ -4,7 +4,6 @@ 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,68 +34,31 @@ 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" /> + {t('steps.details.disclaimer')} diff --git a/src/components/client/campaign-application/steps/CampaignApplicationOrganizer.tsx b/src/components/client/campaign-application/steps/CampaignApplicationOrganizer.tsx index 5a975dc94..a96556b3d 100644 --- a/src/components/client/campaign-application/steps/CampaignApplicationOrganizer.tsx +++ b/src/components/client/campaign-application/steps/CampaignApplicationOrganizer.tsx @@ -8,7 +8,11 @@ import AcceptPrivacyPolicyField from 'components/common/form/AcceptPrivacyPolicy import { StyledStepHeading, StyledFormTextField } from '../helpers/campaignApplication.styled' -export default function CampaignApplicationOrganizer() { +type Props = { + isAdmin?: boolean +} + +export default function CampaignApplicationOrganizer({ isAdmin }: Props) { const { t } = useTranslation('campaign-application') return ( @@ -46,7 +50,7 @@ export default function CampaignApplicationOrganizer() { - + @@ -55,11 +59,15 @@ export default function CampaignApplicationOrganizer() { label={ {t('steps.organizer.transparencyTerms')} } + disabled={isAdmin} /> - + diff --git a/src/components/client/campaign-application/CampaignApplicationRemark.tsx b/src/components/client/campaign-application/steps/CampaignApplicationRemark.tsx similarity index 100% rename from src/components/client/campaign-application/CampaignApplicationRemark.tsx rename to src/components/client/campaign-application/steps/CampaignApplicationRemark.tsx diff --git a/src/components/client/campaign-application/CampaignApplicationStepperIcon.tsx b/src/components/client/campaign-application/steps/CampaignApplicationStepperIcon.tsx similarity index 81% rename from src/components/client/campaign-application/CampaignApplicationStepperIcon.tsx rename to src/components/client/campaign-application/steps/CampaignApplicationStepperIcon.tsx index 930065e59..4b86a72ca 100644 --- a/src/components/client/campaign-application/CampaignApplicationStepperIcon.tsx +++ b/src/components/client/campaign-application/steps/CampaignApplicationStepperIcon.tsx @@ -1,5 +1,5 @@ import { StepIconProps } from '@mui/material/StepIcon' -import { StyledCampaignApplicationStepperIcon } from './helpers/campaignApplication.styled' +import { StyledCampaignApplicationStepperIcon } from '../helpers/campaignApplication.styled' export default function CampaignApplicationStepperIcon(props: StepIconProps) { const icons: { [index: string]: React.ReactElement } = { diff --git a/src/components/client/campaign-application/steps/CampaignApplicationSummary.tsx b/src/components/client/campaign-application/steps/CampaignApplicationSummary.tsx new file mode 100644 index 000000000..f320c102b --- /dev/null +++ b/src/components/client/campaign-application/steps/CampaignApplicationSummary.tsx @@ -0,0 +1,145 @@ +import { Grid, Typography } from '@mui/material' +import { green, orange, red } from '@mui/material/colors' +import { CampaignApplicationResponse } from 'gql/campaign-applications' +import { useTranslation } from 'next-i18next' +import { CampaignEndTypes } from '../helpers/campaignApplication.types' + +export interface SummaryProps { + uploadedFiles: Record + camApp?: CampaignApplicationResponse + deletedFiles?: Record + isEdit?: boolean + prependChildren?: JSX.Element +} + +function FilesDetail({ + label, + files, + type, +}: { + label: string + files?: string[] + type?: 'success' | 'failure' | 'successful-delete' +}) { + return ( + Number(files?.length) > 0 && ( + <> + + {label} + + + {files?.map((f) => ( + + {f} + + ))} + + + ) + ) +} + +export function CamAppDetail({ label, value }: { label: string; value?: string | JSX.Element }) { + const normalized = + typeof value === 'string' && value.trim() != '' ? value : value != null ? value : '-' + return ( + <> + + {label} + + + {normalized} + + + ) +} + +export default function CampaignApplicationSummary({ + uploadedFiles, + camApp, + deletedFiles, + isEdit, + prependChildren, +}: SummaryProps) { + const { t } = useTranslation('campaign-application') + + return ( + <> + {t(isEdit ? 'result.edited' : 'result.created')} + + + + {prependChildren} + + + {uploadedFiles.failed.length > 0 && ( + <> + +

{t('result.uploadFailedDirection')}

+ + )} + {deletedFiles && deletedFiles.failed.length > 0 && ( + <> + +

{t('result.uploadFailedDirection')}

+ + )} + + + + + + + + + + + +
+
+
+ + ) +} 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 (