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')} + + {allStates.map((s) => ( + + {t(`status.${s}`)} + + ))} + + + ) +} 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 ( @@ -15,6 +17,7 @@ function FileUpload({ type="file" style={{ display: 'none' }} onChange={(e) => onUpload([...(e.target.files as FileList)])} + {...rest} /> {buttonLabel} diff --git a/src/components/common/form/AcceptPrivacyPolicyField.tsx b/src/components/common/form/AcceptPrivacyPolicyField.tsx index b3226c0aa..ebe453107 100644 --- a/src/components/common/form/AcceptPrivacyPolicyField.tsx +++ b/src/components/common/form/AcceptPrivacyPolicyField.tsx @@ -3,13 +3,11 @@ import { Typography } from '@mui/material' import { routes } from 'common/routes' import ExternalLink from 'components/common/ExternalLink' -import CheckboxField from 'components/common/form/CheckboxField' +import CheckboxField, { CheckboxFieldProps } from 'components/common/form/CheckboxField' -export type AcceptGDPRFieldProps = { - name: string -} +export type AcceptGDPRFieldProps = Omit -export default function AcceptPrivacyPolicyField({ name }: AcceptGDPRFieldProps) { +export default function AcceptPrivacyPolicyField({ name, ...rest }: AcceptGDPRFieldProps) { const { t } = useTranslation() return ( {t('validation:gdpr')} } + {...rest} /> ) } diff --git a/src/components/common/form/AcceptTermsField.tsx b/src/components/common/form/AcceptTermsField.tsx index 0aa6b1780..d5f3b7998 100644 --- a/src/components/common/form/AcceptTermsField.tsx +++ b/src/components/common/form/AcceptTermsField.tsx @@ -3,13 +3,11 @@ import { Typography } from '@mui/material' import { routes } from 'common/routes' import ExternalLink from 'components/common/ExternalLink' -import CheckboxField from 'components/common/form/CheckboxField' +import CheckboxField, { CheckboxFieldProps } from 'components/common/form/CheckboxField' -export type AcceptTermsFieldProps = { - name: string -} +export type AcceptTermsFieldProps = Omit -export default function AcceptTermsField({ name }: AcceptTermsFieldProps) { +export default function AcceptTermsField({ name, ...rest }: AcceptTermsFieldProps) { const { t } = useTranslation() return ( } + {...rest} /> ) } diff --git a/src/gql/campaign-applications.ts b/src/gql/campaign-applications.ts index 7ed1a12cd..0cdd43181 100644 --- a/src/gql/campaign-applications.ts +++ b/src/gql/campaign-applications.ts @@ -1,6 +1,4 @@ -import { CampaignTypeCategory } from 'components/common/campaign-types/categories' - -export interface CreateCampaignApplicationInput { +export interface CampaignApplicationRequest { /** * What would the campaign be called. ('Help Vesko' or 'Castrate Plovdiv Cats') */ @@ -28,7 +26,7 @@ export interface CreateCampaignApplicationInput { beneficiary: string /** What is the relationship between the Organizer and the Beneficiary ('They're my elderly relative and I'm helping with the internet-computer stuff') */ - organizerBeneficiaryRel: string + organizerBeneficiaryRel?: string /** What is the result that the collected donations will help achieve */ goal: string @@ -51,10 +49,20 @@ export interface CreateCampaignApplicationInput { /** Anything that the operator needs to know about the campaign */ otherNotes?: string - category?: CampaignTypeCategory + /** when does the campaign end - funds gathered, ongoing (no end), or specific date , */ + campaignEnd: string + + /** specific date of the if applicable */ + campaignEndDate?: string + + campaignTypeId?: string + + state?: string + ticketURL?: string + archived?: boolean } -export type CreateCampaignApplicationResponse = CreateCampaignApplicationInput & { +export type CampaignApplicationResponse = CampaignApplicationRequest & { id: string } @@ -63,4 +71,19 @@ export interface UploadCampaignApplicationFilesRequest { files: File[] } -export type UploadCampaignApplicationFilesResponse = unknown +export type UploadCampaignApplicationFilesResponse = { + id: string + filename: string +} + +export type CampaignApplicationExisting = CampaignApplicationResponse & { + documents: Array<{ + id: string + filename: string + }> +} + +export interface CampaignApplicationAdminResponse extends CampaignApplicationResponse { + updatedAt?: string + createdAt: string +} diff --git a/src/pages/admin/campaign-applications/edit/[id].tsx b/src/pages/admin/campaign-applications/edit/[id].tsx index 1e3ffd3bc..ecf9e170a 100644 --- a/src/pages/admin/campaign-applications/edit/[id].tsx +++ b/src/pages/admin/campaign-applications/edit/[id].tsx @@ -1,11 +1,8 @@ import EditPage from 'components/admin/campaign-applications/EditPage' +import { getServerSideProps as getPropsForOrganizerEdit } from 'pages/campaigns/application/[id]/index' -import { securedPropsWithTranslation } from 'middleware/auth/securedProps' import { GetServerSideProps } from 'next' -export const getServerSideProps: GetServerSideProps = securedPropsWithTranslation( - ['common', 'auth', 'validation', 'campaigns', 'campaign-application'], - '', -) +export const getServerSideProps: GetServerSideProps = getPropsForOrganizerEdit export default EditPage diff --git a/src/pages/campaigns/application/[id]/index.tsx b/src/pages/campaigns/application/[id]/index.tsx new file mode 100644 index 000000000..3104bd60f --- /dev/null +++ b/src/pages/campaigns/application/[id]/index.tsx @@ -0,0 +1,43 @@ +import { routes } from 'common/routes' +import EditCampaignApplicationPage from 'components/client/campaign-application/EditCampaignApplicationPage' +import { securedProps } from 'middleware/auth/securedProps' +import { GetServerSideProps, GetServerSidePropsResult } from 'next' +import { serverSideTranslations } from 'next-i18next/serverSideTranslations' + +export const getServerSideProps: GetServerSideProps<{ id: string }> = async (ctx) => { + const id = ctx.query.id + if (typeof id != 'string') { + return { + redirect: { + destination: '/404', + permanent: false, + }, + } as GetServerSidePropsResult<{ id: string }> + } + + const propsOrRedirect = await securedProps(ctx, routes.campaigns.applicationEdit(id)) + + // props i.e. means we're logged in + if ('props' in propsOrRedirect) { + const translation = await serverSideTranslations(ctx.locale ?? 'bg', [ + 'common', + 'auth', + 'validation', + 'campaigns', + 'campaign-application', + ]) + + return { + props: { + id, + ...propsOrRedirect.props, + ...translation, + }, + } + } + + // redirect + return propsOrRedirect +} + +export default EditCampaignApplicationPage diff --git a/src/pages/campaigns/application.tsx b/src/pages/campaigns/application/index.tsx similarity index 100% rename from src/pages/campaigns/application.tsx rename to src/pages/campaigns/application/index.tsx diff --git a/src/service/apiEndpoints.ts b/src/service/apiEndpoints.ts index 24c5d0df4..39a455d0c 100644 --- a/src/service/apiEndpoints.ts +++ b/src/service/apiEndpoints.ts @@ -426,7 +426,12 @@ export const endpoints = { }, campaignApplication: { create: { url: '/campaign-application/create', method: 'POST' }, + update: (id: string) => { url: `/campaign-application/${id}`, method: 'PATCH' }, uploadFile: (campaignId: string) => { url: `/campaign-application/uploadFile/${campaignId}`, method: 'POST' }, + deleteFile: (fileId: string) => + { url: `/campaign-application/fileById/${fileId}`, method: 'DELETE' }, + view: (id: string) => { url: `/campaign-application/byId/${id}`, method: 'GET' }, + listAllForAdmin: { url: `/campaign-application/list`, method: 'GET' }, }, } diff --git a/src/service/campaign-application.ts b/src/service/campaign-application.ts index 1fda99e1e..58874e3ae 100644 --- a/src/service/campaign-application.ts +++ b/src/service/campaign-application.ts @@ -1,23 +1,34 @@ -import { AxiosResponse } from 'axios' +import { AxiosError, AxiosResponse, isAxiosError } from 'axios' import { useSession } from 'next-auth/react' +import { useMutation, useQuery } from '@tanstack/react-query' import { - CreateCampaignApplicationInput, - CreateCampaignApplicationResponse, + CampaignApplicationFormData, + CampaignApplicationState, + CampaignEndTypes, +} from 'components/client/campaign-application/helpers/campaignApplication.types' +import { + CampaignApplicationExisting, + CampaignApplicationRequest, + CampaignApplicationResponse, UploadCampaignApplicationFilesRequest, UploadCampaignApplicationFilesResponse, } from 'gql/campaign-applications' +import { Person } from 'gql/person' +import { useState } from 'react' import { apiClient } from 'service/apiClient' import { endpoints } from 'service/apiEndpoints' -import { authConfig } from 'service/restRequests' +import { authConfig, authQueryFnFactory } from 'service/restRequests' +import { ApiErrors } from './apiErrors' export const useCreateCampaignApplication = () => { const { data: session } = useSession() - return async (data: CreateCampaignApplicationInput) => - await apiClient.post< - CreateCampaignApplicationInput, - AxiosResponse - >(endpoints.campaignApplication.create.url, data, authConfig(session?.accessToken)) + return async (data: CampaignApplicationRequest) => + await apiClient.post>( + endpoints.campaignApplication.create.url, + data, + authConfig(session?.accessToken), + ) } export const useUploadCampaignApplicationFiles = () => { @@ -39,3 +50,253 @@ export const useUploadCampaignApplicationFiles = () => { ) } } + +export const useDeleteCampaignApplicationFile = () => { + const { data: session } = useSession() + return async (id: string) => + await apiClient.delete( + endpoints.campaignApplication.deleteFile(id).url, + authConfig(session?.accessToken), + ) +} + +export function useViewCampaignApplicationCached(id: string, cacheFor = 60 * 1000) { + const { data } = useSession() + return useQuery( + [endpoints.campaignApplication.view(id).url], + authQueryFnFactory(data?.accessToken), + { + cacheTime: cacheFor, + }, + ) +} + +export const useUpdateCampaignApplication = () => { + const { data: session } = useSession() + return async ([data, id]: [CampaignApplicationRequest, string]) => + await apiClient.patch>( + endpoints.campaignApplication.update(id).url, + data, + authConfig(session?.accessToken), + ) +} + +const createMutation = () => + useMutation< + AxiosResponse, + AxiosError, + CampaignApplicationRequest + >({ + mutationFn: useCreateCampaignApplication(), + }) + +const fileUploadMutation = () => + useMutation< + AxiosResponse, + AxiosError, + UploadCampaignApplicationFilesRequest + >({ + mutationFn: useUploadCampaignApplicationFiles(), + }) + +const fileDeleteMutation = () => + useMutation({ + mutationFn: useDeleteCampaignApplicationFile(), + }) + +const updateMutation = () => + useMutation< + AxiosResponse, + AxiosError, + [CampaignApplicationRequest, string] + >({ + mutationFn: useUpdateCampaignApplication(), + }) + +export interface CreateOrEditApplication { + person?: Person + isEdit?: boolean + campaignApplication?: CampaignApplicationExisting +} + +export const useCreateOrEditApplication = ({ + person, + isEdit, + campaignApplication: existing, +}: CreateOrEditApplication) => { + const initialValues: CampaignApplicationFormData = mapExistingOrNew(isEdit, existing, person) + + const [files, setFiles] = useState( + existing?.documents?.map((d) => ({ name: d.filename } as File)) ?? [], + ) + const [submitting, setSubmitting] = useState(false) + const [successful, setSuccessful] = useState(false) + const [error, setError] = useState() + const [uploadedFiles, setFileUploadState] = useState>({ + successful: [], + failed: [], + }) + const [deletedFiles, setFileDeletedState] = useState>({ + successful: [], + failed: [], + }) + const [campaignApplicationResult, setCampaignApplicationResult] = + useState() + + const update = updateMutation() + const create = createMutation() + const fileDelete = fileDeleteMutation() + const fileUpload = fileUploadMutation() + + const createOrUpdateApplication = async (input: CampaignApplicationRequest) => { + if (submitting) { + return + } + setSubmitting(true) + setError(undefined) + setSuccessful(false) + setCampaignApplicationResult(undefined) + + // ---- create or edit the campaign application entity (excl. files - they are a separate call below) + const dataOrError = + isEdit && typeof existing?.id === 'string' + ? await update.mutateAsync([input, existing?.id]).catch((e) => e as AxiosError) + : 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 if (Array.isArray(dataOrError.response?.data?.message)) { + setError(dataOrError.response?.data?.message?.flatMap((m) => Object.values(m.constraints))) + } else { + setError(['could not create a campaign application due to unknown error']) + } + 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 + } + + const campaignApplication = dataOrError.data + setSuccessful(true) + setCampaignApplicationResult(campaignApplication) + + // ---- FILES + const uploadedFilesMap = new Map() + const deletedFilesMap = new Map() + const filesToUpload = isEdit + ? files.filter( + (f) => f.size > 0 && !existing?.documents.some((d) => d.filename === f.name), + ) ?? [] + : files + const filesToDelete = + existing?.documents?.filter((d) => !files.some((f) => f.name === d.filename)) ?? [] + + await Promise.all([ + ...filesToDelete.map((f) => + fileDelete + .mutateAsync(f.id) + .then(() => deletedFilesMap.set(f.filename, 'success')) + .catch(() => deletedFilesMap.set(f.filename, 'fail')), + ), + ...filesToUpload.map((f) => + fileUpload + .mutateAsync({ campaignApplicationId: campaignApplication.id, files: [f] }) + .then(() => uploadedFilesMap.set(f.name, 'success')) + .catch(() => 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) + const fileDeleteResults = [...deletedFilesMap.entries()].reduce((a, [key, value]) => { + value === 'fail' ? a.failed.push(key) : a.successful.push(key) + return a + }, deletedFiles) + + setFileUploadState(fileUploadResults) + setFileDeletedState(fileDeleteResults) + setSubmitting(false) + } + + return { + createOrUpdateApplication, + createOrUpdateSuccessful: successful, + submitting, + uploadedFiles, + error, + campaignApplicationResult, + files, + setFiles, + initialValues, + deletedFiles, + } +} + +export function mapExistingOrNew( + isEdit: boolean | undefined, + existing: CampaignApplicationExisting | undefined, + person: Person | undefined, +): CampaignApplicationFormData { + return { + organizer: { + name: (isEdit ? existing?.organizerName : `${person?.firstName} ${person?.lastName}`) ?? '', + phone: (isEdit ? existing?.organizerPhone : person?.phone) ?? '', + email: (isEdit ? existing?.organizerEmail : person?.email) ?? '', + acceptTermsAndConditions: isEdit ? true : false, + transparencyTermsAccepted: isEdit ? true : false, + personalInformationProcessingAccepted: isEdit ? true : false, + }, + applicationBasic: { + title: existing?.campaignName ?? '', + beneficiaryNames: existing?.beneficiary ?? '', + campaignType: existing?.campaignTypeId ?? '', + funds: isNaN(parseInt(existing?.amount ?? '')) ? 0 : parseInt(existing?.amount ?? '0'), + campaignEnd: existing?.campaignEnd ?? CampaignEndTypes.FUNDS, + campaignEndDate: existing?.campaignEndDate, + }, + applicationDetails: { + cause: existing?.goal ?? '', + currentStatus: existing?.history ?? '', + description: existing?.description ?? '', + organizerBeneficiaryRelationship: existing?.organizerBeneficiaryRel ?? '', + }, + admin: { + archived: existing?.archived ?? false, + state: (existing?.state as CampaignApplicationState) ?? 'review', + ticketURL: existing?.ticketURL ?? '', + }, + } +} + +export function mapCreateOrEditInput(i: CampaignApplicationFormData): CampaignApplicationRequest { + 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.applicationBasic.beneficiaryNames, + + campaignName: i.applicationBasic.title, + amount: i.applicationBasic.funds?.toString() ?? '', + goal: i.applicationDetails.cause, + description: i.applicationDetails.description, + organizerBeneficiaryRel: i.applicationDetails.organizerBeneficiaryRelationship ?? '-', + history: i.applicationDetails.currentStatus, + campaignEnd: i.applicationBasic.campaignEnd, + campaignEndDate: i.applicationBasic.campaignEndDate, + campaignTypeId: i.applicationBasic.campaignType, + + ...(i.admin ?? {}), // server disregards admin-only props if the user is not admin + } +} diff --git a/src/test/campaign-application/campaign-application.test.tsx b/src/test/campaign-application/campaign-application.test.tsx new file mode 100644 index 000000000..7f5cd7a7d --- /dev/null +++ b/src/test/campaign-application/campaign-application.test.tsx @@ -0,0 +1,382 @@ +import { act, renderHook } from '@testing-library/react' +import { + CampaignApplicationExisting, + CampaignApplicationRequest, + CampaignApplicationResponse, +} from 'gql/campaign-applications' +import { Person } from 'gql/person' +import { rest } from 'msw' +import { startResponseHandler } from 'test/response-handler/response-handler' +import { useCreateOrEditApplication } from '../../service/campaign-application' +import { Providers as AllTheProviders } from '../test-utils' +import { isPromise } from 'formik' +import { SetupServer } from 'msw/node' +import { ApiErrors } from 'service/apiErrors' + +describe('Campaign application create or update logic', () => { + let server: SetupServer + let requests: string[] + + beforeEach(() => { + ;[server, requests] = startResponseHandler() + }) + afterEach(() => { + if (typeof server?.close === 'function') { + server.close() + } + }) + + test('should be called and return the initial state and the create or edit function', () => { + const { result } = setup() + .withPerson({ + firstName: 'test', + lastName: 'm', + phone: '01234569', + email: 'test@test.com', + }) + .run() + expect(result?.current).toBeDefined() + + const { + createOrUpdateSuccessful: applicationCreated, + submitting, + campaignApplicationResult, + initialValues, + uploadedFiles, + deletedFiles, + error, + files, + setFiles, + createOrUpdateApplication, + } = result.current + + expect(applicationCreated).toBe(false) + expect(campaignApplicationResult).not.toBeDefined() + expect(submitting).toBe(false) + expect(uploadedFiles).toEqual({ successful: [], failed: [] }) + expect(deletedFiles).toEqual({ successful: [], failed: [] }) + expect(error).not.toBeDefined() + expect(files).toEqual([]) + expect(typeof setFiles === 'function').toBe(true) + expect(typeof createOrUpdateApplication === 'function').toBe(true) + expect(initialValues).toEqual({ + applicationBasic: { + beneficiaryNames: '', + campaignEnd: 'funds', + campaignEndDate: undefined, + campaignType: '', + funds: 0, + title: '', + }, + applicationDetails: { + cause: '', + currentStatus: '', + description: '', + organizerBeneficiaryRelationship: undefined, + }, + organizer: { + acceptTermsAndConditions: false, + email: 'test@test.com', + name: 'test m', + personalInformationProcessingAccepted: false, + phone: '01234569', + transparencyTermsAccepted: false, + }, + }) + }) + + test('when create called it should call the create mutation and fill in applicationCreated and campaignApplicationResult state', async () => { + // arrange + server.use(handleCreate({ campaignName: 'test' })) + const { result } = setup() + .withPerson({ + firstName: 'test', + lastName: 'm', + phone: '01234569', + email: 'test@test.com', + }) + .run() + + // act + await act(async () => result.current.createOrUpdateApplication({})) + + // assert + expect(requests).toEqual(['POST http://localhost/api/campaign-application/create {}']) + + expect(result.current.error).not.toBeDefined() + expect(result.current.createOrUpdateSuccessful).toBe(true) + expect(result.current.submitting).toBe(false) + expect(result.current.campaignApplicationResult).toEqual({ + campaignName: 'test', + id: '1234', + }) + }) + + test('when create called multiple times it should only call the create mutation once', async () => { + // arrange + let resolveCampaign: (c: Partial) => void = () => { + return + } + const responsePromise = new Promise>((res) => { + resolveCampaign = res + }) + server.use(handleCreate(responsePromise)) + const { result } = setup().run() + + const createPromise: Promise[] = [] + // act + await act(() => createPromise.push(result.current.createOrUpdateApplication({}))) + + expect(result.current.submitting).toBe(true) + await act(() => createPromise.push(result.current.createOrUpdateApplication({}))) + + resolveCampaign({ id: '42' }) + await act(() => Promise.all(createPromise)) + expect(result.current.submitting).toBe(false) + // assert + expect(requests.length).toBe(1) // only one request sent + expect(requests).toEqual(['POST http://localhost/api/campaign-application/create {}']) + }) + + test('when create called and an error occurs it should convey that via the error state and then clear it up when create called again', async () => { + // arrange + server.use( + handleCreateError({ + error: '', + message: [ + { + property: 't', + constraints: { test: 'error in the test' }, + }, + ], + statusCode: 1, + }), + ) + const { result } = setup().run() + + // act + await act(() => result.current.createOrUpdateApplication({})) + + // assert + expect(result.current.error).toEqual(['error in the test']) + expect(result.current.submitting).toBe(false) + + // act clean up error on a new attempt + server.use(handleCreate({})) + await act(() => result.current.createOrUpdateApplication({})) + expect(result.current.error).not.toBeDefined() + }) + + test('when create called and files added it should call the uploadFiles mutation as many times as there are files and fill in the files upload result', async () => { + // arrange + server.use(handleCreate({ campaignName: 'test' })) + server.use(handleFileUpload) + const { result } = setup().run() + await act(() => result.current.setFiles([new File([], '123.txt'), new File([], '456.txt')])) + + // act + await act(async () => result.current.createOrUpdateApplication({})) + + // assert + expect(requests).toEqual([ + 'POST http://localhost/api/campaign-application/create {}', + 'POST http://localhost/api/campaign-application/uploadFile/1234 ', + 'POST http://localhost/api/campaign-application/uploadFile/1234 ', + ]) + expect(result.current.uploadedFiles).toEqual({ + failed: [], + successful: ['123.txt', '456.txt'], + }) + }) + + test('when create called and files added it should call the uploadFiles mutation as many times as there are files and fill in the files upload result including failed ones', async () => { + // arrange + server.use(handleCreate({ campaignName: 'test' })) + let fileCount = 0 + server.use( + handleFileUploadWith((req, res, ctx) => { + fileCount += 1 + return res(ctx.status(fileCount > 1 ? 400 : 200), ctx.json({})) + }), + ) + const { result } = setup().run() + await act(() => result.current.setFiles([new File([], '123.txt'), new File([], '456.txt')])) + + // act + await act(async () => result.current.createOrUpdateApplication({})) + + // assert + expect(requests).toEqual([ + 'POST http://localhost/api/campaign-application/create {}', + 'POST http://localhost/api/campaign-application/uploadFile/1234 ', + 'POST http://localhost/api/campaign-application/uploadFile/1234 ', + ]) + expect(result.current.uploadedFiles).toEqual({ + failed: ['456.txt'], + successful: ['123.txt'], + }) + }) + + test('when update called and isEdit it should call the edit mutation and fill in applicationCreated and campaignApplicationResult state', async () => { + // arrange + server.use(handleEdit({ campaignName: 'test' })) + const { result } = setup() + .withCampaignApplication({ + id: '4321', + beneficiary: 'test bene', + }) + .withIsEdit(true) + .run() + + // act + await act(async () => result.current.createOrUpdateApplication({})) + + // assert + expect(requests).toEqual(['PATCH http://localhost/api/campaign-application/4321 {}']) + + expect(result.current.createOrUpdateSuccessful).toBe(true) + expect(result.current.submitting).toBe(false) + expect(result.current.campaignApplicationResult).toEqual({ + campaignName: 'test', + id: '1234', + }) + }) + + test.only('when edit called and files added and removed it should call the uploadFiles/deleteFiles mutation as many times as there are files and fill in the files upload/deleted result including failed ones', async () => { + // arrange + server.use(handleEdit()) + + let fileCount = 0 + server.use( + handleFileUploadWith((req, res, ctx) => { + fileCount += 1 + return res(ctx.status(fileCount > 1 ? 400 : 200), ctx.json({})) + }), + ) + let deleteFileCount = 0 + server.use( + handleFileDeleteWith((req, res, ctx) => { + deleteFileCount += 1 + return res(ctx.status(deleteFileCount > 1 ? 400 : 200), ctx.json({})) + }), + ) + const { result } = setup() + .withCampaignApplication({ + id: '1234', + documents: [ + { filename: 'my.txt', id: 'my' }, + { filename: 'my1.txt', id: 'my1' }, + ], + }) + .withIsEdit(true) + .run() + + await act(() => + result.current.setFiles([ + new File(['my file'], '123.txt'), + new File(['your file'], '456.txt'), + ]), + ) + + // act + await act(async () => result.current.createOrUpdateApplication({})) + + // assert + expect(requests).toEqual([ + 'PATCH http://localhost/api/campaign-application/1234 {}', + 'DELETE http://localhost/api/campaign-application/fileById/my ', + 'DELETE http://localhost/api/campaign-application/fileById/my1 ', + 'POST http://localhost/api/campaign-application/uploadFile/1234 ', + 'POST http://localhost/api/campaign-application/uploadFile/1234 ', + ]) + expect(result.current.uploadedFiles).toEqual({ + failed: ['456.txt'], + successful: ['123.txt'], + }) + expect(result.current.deletedFiles).toEqual({ + failed: ['my1.txt'], + successful: ['my.txt'], + }) + }) +}) + +function setup() { + let person: Person + let campaignApplication: CampaignApplicationExisting + let isEdit = false + + const runner = { + withCampaignApplication(c: Partial) { + campaignApplication = c as CampaignApplicationExisting + return runner + }, + withPerson(p: Partial) { + person = p as Person + return runner + }, + withIsEdit(is: boolean) { + isEdit = is + return runner + }, + default() { + return runner + }, + run() { + return renderHook( + () => + useCreateOrEditApplication({ + person, + campaignApplication, + isEdit, + }), + { + wrapper: AllTheProviders, + }, + ) + }, + } + + return runner +} + +export const handleCreate = ( + response: Partial | Promise>, +) => + rest.post('**/campaign-application/create', async (req, res, ctx) => { + const r = { + id: '1234', + ...(isPromise(response) ? await response : response), + } + + return res(ctx.status(200, 'ok'), ctx.json(r)) + }) + +export const handleCreateError = (response: ApiErrors) => + rest.post('**/campaign-application/create', async (req, res, ctx) => { + return res(ctx.status(400, 'Not ok'), ctx.json(response)) + }) + +export const handleFileUpload = rest.post( + '**/campaign-application/uploadFile/**', + async (req, res, ctx) => res(ctx.status(200, 'ok'), ctx.json({})), +) + +export const handleFileUploadWith = (handler: Parameters[1]) => + rest.post('**/campaign-application/uploadFile/**', handler) + +export const handleFileDeleteWith = (handler: Parameters[1]) => + rest.delete('**/campaign-application/fileById/**', handler) + +export const handleEdit = ( + response: + | Partial + | Promise> = {}, +) => + rest.patch('**/campaign-application/**', async (req, res, ctx) => { + const r = { + id: '1234', + ...(isPromise(response) ? await response : response), + } + + return res(ctx.status(200, 'ok'), ctx.json(r)) + }) diff --git a/src/test/response-handler/response-handler.ts b/src/test/response-handler/response-handler.ts new file mode 100644 index 000000000..2fd78a3c5 --- /dev/null +++ b/src/test/response-handler/response-handler.ts @@ -0,0 +1,46 @@ +import { DefaultBodyType, MockedRequest, rest } from 'msw' +import { setupServer } from 'msw/node' + +export const defaultHandlers = [ + // me + rest.post('**/me', (req, res, ctx) => { + return res( + ctx.status(200, 'ok'), + ctx.json({ + status: 'authenticated', + person: { + id: 'c7b3d8e0-5e0b-4b0f-8b3a-3b9f4b3d3b3d', + firstName: 'John', + lastName: 'Maverick', + email: 'john@podkrepi.bg', + phone: '01234567', + newsletter: false, + address: 'string', + emailConfirmed: true, + }, + }), + ) + }), +] + +/** + * Starts the mock response handler. + * + * I M P O R T A N T - clean up the server after use (afterEach) + * @param handlers + * @returns a tuple of 3 - [SetupServer, string[], Array] + */ +export function startResponseHandler(handlers: typeof defaultHandlers = []) { + const server = setupServer(...defaultHandlers, ...handlers) + + server.listen() + + const rawEvents: MockedRequest[] = [] + const urlMethodAndBody: string[] = [] + server.events.on('request:start', async (e) => { + rawEvents.push(e) + urlMethodAndBody.push(`${e.method} ${e.url.toString()} ${await e.text().catch(() => '')}`) + }) + + return [server, urlMethodAndBody, rawEvents] as const +} diff --git a/src/test/setupTests.ts b/src/test/setupTests.ts index 73c193871..dcf22ce62 100644 --- a/src/test/setupTests.ts +++ b/src/test/setupTests.ts @@ -16,3 +16,5 @@ jest.mock('next-i18next', () => ({ } }, })) + +jest.mock('next/config', () => () => ({ publicRuntimeConfig: { API_URL: 'http://localhost/api' } })) diff --git a/src/test/test-utils.tsx b/src/test/test-utils.tsx index 016ab7118..aba8ad4c1 100644 --- a/src/test/test-utils.tsx +++ b/src/test/test-utils.tsx @@ -1,10 +1,53 @@ -import { render, RenderOptions } from '@testing-library/react' import { ThemeProvider } from '@mui/material/styles' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, RenderOptions } from '@testing-library/react' import theme from 'common/theme' +import { SessionProvider } from 'next-auth/react' +import { ServerUser } from 'service/auth' -const Providers = ({ children }: { children: React.ReactElement }) => { - return {children} +export const Providers = ({ children }: { children: React.ReactElement }) => { + const user = { + name: 'user', + email: 'email', + } as unknown as ServerUser + return ( + + + { + // skip the errors as it fills the console with the 4xx and 5xx errors that are legitimate parts of the tests + // i.e. when a file fails to upload - inform the user + return + }, + log: console.log, + warn: console.warn, + }, + }) + }> + {children} + + + + ) } + const customRender = (ui: React.ReactElement, options: RenderOptions = {}) => render(ui, { wrapper: Providers, ...options }) diff --git a/yarn.lock b/yarn.lock index e699df2d8..b343770e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1902,6 +1902,32 @@ __metadata: languageName: node linkType: hard +"@mswjs/cookies@npm:^0.2.2": + version: 0.2.2 + resolution: "@mswjs/cookies@npm:0.2.2" + dependencies: + "@types/set-cookie-parser": ^2.4.0 + set-cookie-parser: ^2.4.6 + checksum: 23b1ef56d57efcc1b44600076f531a1fb703855af342a31e01bad4adaf0dab51f6d3b5595a95a7988c3f612ba075835f9a06c52833205284d101eb9a51dd72b0 + languageName: node + linkType: hard + +"@mswjs/interceptors@npm:^0.17.10": + version: 0.17.10 + resolution: "@mswjs/interceptors@npm:0.17.10" + dependencies: + "@open-draft/until": ^1.0.3 + "@types/debug": ^4.1.7 + "@xmldom/xmldom": ^0.8.3 + debug: ^4.3.3 + headers-polyfill: 3.2.5 + outvariant: ^1.2.1 + strict-event-emitter: ^0.2.4 + web-encoding: ^1.1.5 + checksum: 0e6d32f399144b5cefe6fd7620f2776c83adc9bbbbccf2eb4ea347332be059f585136c44168c09b544c41cd3d686f88e43432e10192227a24fbb0c98a2f52dc8 + languageName: node + linkType: hard + "@mui/base@npm:5.0.0-beta.40": version: 5.0.0-beta.40 resolution: "@mui/base@npm:5.0.0-beta.40" @@ -2566,6 +2592,13 @@ __metadata: languageName: node linkType: hard +"@open-draft/until@npm:^1.0.3": + version: 1.0.3 + resolution: "@open-draft/until@npm:1.0.3" + checksum: 323e92ebef0150ed0f8caedc7d219b68cdc50784fa4eba0377eef93533d3f46514eb2400ced83dda8c51bddc3d2c7b8e9cf95e5ec85ab7f62dfc015d174f62f2 + languageName: node + linkType: hard + "@panva/hkdf@npm:^1.0.2": version: 1.1.1 resolution: "@panva/hkdf@npm:1.1.1" @@ -3994,6 +4027,15 @@ __metadata: languageName: node linkType: hard +"@types/debug@npm:^4.1.7": + version: 4.1.12 + resolution: "@types/debug@npm:4.1.12" + dependencies: + "@types/ms": "*" + checksum: 47876a852de8240bfdaf7481357af2b88cb660d30c72e73789abf00c499d6bc7cd5e52f41c915d1b9cd8ec9fef5b05688d7b7aef17f7f272c2d04679508d1053 + languageName: node + linkType: hard + "@types/dompurify@npm:^3": version: 3.0.2 resolution: "@types/dompurify@npm:3.0.2" @@ -4082,6 +4124,13 @@ __metadata: languageName: node linkType: hard +"@types/js-levenshtein@npm:^1.1.1": + version: 1.1.3 + resolution: "@types/js-levenshtein@npm:1.1.3" + checksum: eb338696da976925ea8448a42d775d7615a14323dceeb08909f187d0b3d3b4c1f67a1c36ef586b1c2318b70ab141bba8fc58311ba1c816711704605aec09db8b + languageName: node + linkType: hard + "@types/jsdom@npm:^20.0.0": version: 20.0.1 resolution: "@types/jsdom@npm:20.0.1" @@ -4327,6 +4376,15 @@ __metadata: languageName: node linkType: hard +"@types/set-cookie-parser@npm:^2.4.0": + version: 2.4.10 + resolution: "@types/set-cookie-parser@npm:2.4.10" + dependencies: + "@types/node": "*" + checksum: 105cc90c7d7deeb344858f720b58bd137356586545ac00d1a448e050bfcc0f385553ff26bc9c674bd8c2e953a458149eadb1945ee3d1eee81e6c0656236ebc0a + languageName: node + linkType: hard + "@types/stack-utils@npm:^2.0.0": version: 2.0.1 resolution: "@types/stack-utils@npm:2.0.1" @@ -4606,6 +4664,20 @@ __metadata: languageName: node linkType: hard +"@xmldom/xmldom@npm:^0.8.3": + version: 0.8.10 + resolution: "@xmldom/xmldom@npm:0.8.10" + checksum: 4c136aec31fb3b49aaa53b6fcbfe524d02a1dc0d8e17ee35bd3bf35e9ce1344560481cd1efd086ad1a4821541482528672306d5e37cdbd187f33d7fadd3e2cf0 + languageName: node + linkType: hard + +"@zxing/text-encoding@npm:0.9.0": + version: 0.9.0 + resolution: "@zxing/text-encoding@npm:0.9.0" + checksum: c23b12aee7639382e4949961304a1294776afaffa40f579e09ffecd0e5e68cf26ef3edd75009de46da8a536e571448755ca68b3e2ea707d53793c0edb2e2c34a + languageName: node + linkType: hard + "abab@npm:^2.0.6": version: 2.0.6 resolution: "abab@npm:2.0.6" @@ -5002,6 +5074,15 @@ __metadata: languageName: node linkType: hard +"available-typed-arrays@npm:^1.0.7": + version: 1.0.7 + resolution: "available-typed-arrays@npm:1.0.7" + dependencies: + possible-typed-array-names: ^1.0.0 + checksum: 1aa3ffbfe6578276996de660848b6e95669d9a95ad149e3dd0c0cda77db6ee1dbd9d1dd723b65b6d277b882dd0c4b91a654ae9d3cf9e1254b7e93e4908d78fd3 + languageName: node + linkType: hard + "axe-core@npm:^4.6.2": version: 4.7.2 resolution: "axe-core@npm:4.7.2" @@ -5190,7 +5271,7 @@ __metadata: languageName: node linkType: hard -"bl@npm:^4.0.3": +"bl@npm:^4.0.3, bl@npm:^4.1.0": version: 4.1.0 resolution: "bl@npm:4.1.0" dependencies: @@ -5361,6 +5442,19 @@ __metadata: languageName: node linkType: hard +"call-bind@npm:^1.0.7": + version: 1.0.7 + resolution: "call-bind@npm:1.0.7" + dependencies: + es-define-property: ^1.0.0 + es-errors: ^1.3.0 + function-bind: ^1.1.2 + get-intrinsic: ^1.2.4 + set-function-length: ^1.2.1 + checksum: 295c0c62b90dd6522e6db3b0ab1ce26bdf9e7404215bda13cfee25b626b5ff1a7761324d58d38b1ef1607fc65aca2d06e44d2e18d0dfc6c14b465b00d8660029 + languageName: node + linkType: hard + "callsites@npm:^3.0.0": version: 3.1.0 resolution: "callsites@npm:3.1.0" @@ -5534,6 +5628,25 @@ __metadata: languageName: node linkType: hard +"chokidar@npm:^3.4.2": + version: 3.6.0 + resolution: "chokidar@npm:3.6.0" + dependencies: + anymatch: ~3.1.2 + braces: ~3.0.2 + fsevents: ~2.3.2 + glob-parent: ~5.1.2 + is-binary-path: ~2.1.0 + is-glob: ~4.0.1 + normalize-path: ~3.0.0 + readdirp: ~3.6.0 + dependenciesMeta: + fsevents: + optional: true + checksum: d2f29f499705dcd4f6f3bbed79a9ce2388cf530460122eed3b9c48efeab7a4e28739c6551fd15bec9245c6b9eeca7a32baa64694d64d9b6faeb74ddb8c4a413d + languageName: node + linkType: hard + "chownr@npm:^1.1.1": version: 1.1.4 resolution: "chownr@npm:1.1.4" @@ -5592,6 +5705,13 @@ __metadata: languageName: node linkType: hard +"cli-spinners@npm:^2.5.0": + version: 2.9.2 + resolution: "cli-spinners@npm:2.9.2" + checksum: 1bd588289b28432e4676cb5d40505cfe3e53f2e4e10fbe05c8a710a154d6fe0ce7836844b00d6858f740f2ffe67cdc36e0fce9c7b6a8430e80e6388d5aa4956c + languageName: node + linkType: hard + "cli-truncate@npm:^2.1.0": version: 2.1.0 resolution: "cli-truncate@npm:2.1.0" @@ -5649,6 +5769,13 @@ __metadata: languageName: node linkType: hard +"clone@npm:^1.0.2": + version: 1.0.4 + resolution: "clone@npm:1.0.4" + checksum: d06418b7335897209e77bdd430d04f882189582e67bd1f75a04565f3f07f5b3f119a9d670c943b6697d0afb100f03b866b3b8a1f91d4d02d72c4ecf2bb64b5dd + languageName: node + linkType: hard + "clone@npm:^2.1.1, clone@npm:^2.1.2": version: 2.1.2 resolution: "clone@npm:2.1.2" @@ -5845,7 +5972,7 @@ __metadata: languageName: node linkType: hard -"cookie@npm:^0.4.1": +"cookie@npm:^0.4.1, cookie@npm:^0.4.2": version: 0.4.2 resolution: "cookie@npm:0.4.2" checksum: a00833c998bedf8e787b4c342defe5fa419abd96b32f4464f718b91022586b8f1bafbddd499288e75c037642493c83083da426c6a9080d309e3bd90fd11baa9b @@ -6214,6 +6341,26 @@ __metadata: languageName: node linkType: hard +"defaults@npm:^1.0.3": + version: 1.0.4 + resolution: "defaults@npm:1.0.4" + dependencies: + clone: ^1.0.2 + checksum: 3a88b7a587fc076b84e60affad8b85245c01f60f38fc1d259e7ac1d89eb9ce6abb19e27215de46b98568dd5bc48471730b327637e6f20b0f1bc85cf00440c80a + languageName: node + linkType: hard + +"define-data-property@npm:^1.1.4": + version: 1.1.4 + resolution: "define-data-property@npm:1.1.4" + dependencies: + es-define-property: ^1.0.0 + es-errors: ^1.3.0 + gopd: ^1.0.1 + checksum: 8068ee6cab694d409ac25936eb861eea704b7763f7f342adbdfe337fc27c78d7ae0eff2364b2917b58c508d723c7a074326d068eef2e45c4edcd85cf94d0313b + languageName: node + linkType: hard + "define-lazy-prop@npm:^3.0.0": version: 3.0.0 resolution: "define-lazy-prop@npm:3.0.0" @@ -6690,6 +6837,22 @@ __metadata: languageName: node linkType: hard +"es-define-property@npm:^1.0.0": + version: 1.0.0 + resolution: "es-define-property@npm:1.0.0" + dependencies: + get-intrinsic: ^1.2.4 + checksum: f66ece0a887b6dca71848fa71f70461357c0e4e7249696f81bad0a1f347eed7b31262af4a29f5d726dc026426f085483b6b90301855e647aa8e21936f07293c6 + languageName: node + linkType: hard + +"es-errors@npm:^1.3.0": + version: 1.3.0 + resolution: "es-errors@npm:1.3.0" + checksum: ec1414527a0ccacd7f15f4a3bc66e215f04f595ba23ca75cdae0927af099b5ec865f9f4d33e9d7e86f512f252876ac77d4281a7871531a50678132429b1271b5 + languageName: node + linkType: hard + "es-get-iterator@npm:^1.1.3": version: 1.1.3 resolution: "es-get-iterator@npm:1.1.3" @@ -7697,6 +7860,19 @@ __metadata: languageName: node linkType: hard +"get-intrinsic@npm:^1.2.4": + version: 1.2.4 + resolution: "get-intrinsic@npm:1.2.4" + dependencies: + es-errors: ^1.3.0 + function-bind: ^1.1.2 + has-proto: ^1.0.1 + has-symbols: ^1.0.3 + hasown: ^2.0.0 + checksum: 414e3cdf2c203d1b9d7d33111df746a4512a1aa622770b361dadddf8ed0b5aeb26c560f49ca077e24bfafb0acb55ca908d1f709216ccba33ffc548ec8a79a951 + languageName: node + linkType: hard + "get-nonce@npm:^1.0.0": version: 1.0.1 resolution: "get-nonce@npm:1.0.1" @@ -7927,6 +8103,13 @@ __metadata: languageName: node linkType: hard +"graphql@npm:^16.8.1": + version: 16.9.0 + resolution: "graphql@npm:16.9.0" + checksum: 8cb3d54100e9227310383ce7f791ca48d12f15ed9f2021f23f8735f1121aafe4e5e611a853081dd935ce221724ea1ae4638faef5d2921fb1ad7c26b5f46611e9 + languageName: node + linkType: hard + "gzip-size@npm:^6.0.0": version: 6.0.0 resolution: "gzip-size@npm:6.0.0" @@ -7973,6 +8156,15 @@ __metadata: languageName: node linkType: hard +"has-property-descriptors@npm:^1.0.2": + version: 1.0.2 + resolution: "has-property-descriptors@npm:1.0.2" + dependencies: + es-define-property: ^1.0.0 + checksum: fcbb246ea2838058be39887935231c6d5788babed499d0e9d0cc5737494c48aba4fe17ba1449e0d0fbbb1e36175442faa37f9c427ae357d6ccb1d895fbcd3de3 + languageName: node + linkType: hard + "has-proto@npm:^1.0.1": version: 1.0.1 resolution: "has-proto@npm:1.0.1" @@ -7996,6 +8188,15 @@ __metadata: languageName: node linkType: hard +"has-tostringtag@npm:^1.0.2": + version: 1.0.2 + resolution: "has-tostringtag@npm:1.0.2" + dependencies: + has-symbols: ^1.0.3 + checksum: 999d60bb753ad714356b2c6c87b7fb74f32463b8426e159397da4bde5bca7e598ab1073f4d8d4deafac297f2eb311484cd177af242776bf05f0d11565680468d + languageName: node + linkType: hard + "has-unicode@npm:^2.0.1": version: 2.0.1 resolution: "has-unicode@npm:2.0.1" @@ -8196,6 +8397,13 @@ __metadata: languageName: node linkType: hard +"headers-polyfill@npm:3.2.5": + version: 3.2.5 + resolution: "headers-polyfill@npm:3.2.5" + checksum: a3c4bdd661584fd39e40c0f91412abc514616edfbd20d29a75567e591f90ef5c445c8e209b7f3c2b2375d27e95e4690f33417368a168d4832484a93861ab6a3c + languageName: node + linkType: hard + "hey-listen@npm:^1.0.8": version: 1.0.8 resolution: "hey-listen@npm:1.0.8" @@ -8548,6 +8756,29 @@ __metadata: languageName: node linkType: hard +"inquirer@npm:^8.2.0": + version: 8.2.6 + resolution: "inquirer@npm:8.2.6" + dependencies: + ansi-escapes: ^4.2.1 + chalk: ^4.1.1 + cli-cursor: ^3.1.0 + cli-width: ^3.0.0 + external-editor: ^3.0.3 + figures: ^3.0.0 + lodash: ^4.17.21 + mute-stream: 0.0.8 + ora: ^5.4.1 + run-async: ^2.4.0 + rxjs: ^7.5.5 + string-width: ^4.1.0 + strip-ansi: ^6.0.0 + through: ^2.3.6 + wrap-ansi: ^6.0.1 + checksum: 387ffb0a513559cc7414eb42c57556a60e302f820d6960e89d376d092e257a919961cd485a1b4de693dbb5c0de8bc58320bfd6247dfd827a873aa82a4215a240 + languageName: node + linkType: hard + "internal-slot@npm:^1.0.3, internal-slot@npm:^1.0.4, internal-slot@npm:^1.0.5": version: 1.0.5 resolution: "internal-slot@npm:1.0.5" @@ -8756,6 +8987,15 @@ __metadata: languageName: node linkType: hard +"is-generator-function@npm:^1.0.7": + version: 1.0.10 + resolution: "is-generator-function@npm:1.0.10" + dependencies: + has-tostringtag: ^1.0.0 + checksum: d54644e7dbaccef15ceb1e5d91d680eb5068c9ee9f9eb0a9e04173eb5542c9b51b5ab52c5537f5703e48d5fddfd376817c1ca07a84a407b7115b769d4bdde72b + languageName: node + linkType: hard + "is-glob@npm:^4.0.0, is-glob@npm:^4.0.1, is-glob@npm:^4.0.3, is-glob@npm:~4.0.1": version: 4.0.3 resolution: "is-glob@npm:4.0.3" @@ -8790,6 +9030,13 @@ __metadata: languageName: node linkType: hard +"is-interactive@npm:^1.0.0": + version: 1.0.0 + resolution: "is-interactive@npm:1.0.0" + checksum: 824808776e2d468b2916cdd6c16acacebce060d844c35ca6d82267da692e92c3a16fdba624c50b54a63f38bdc4016055b6f443ce57d7147240de4f8cdabaf6f9 + languageName: node + linkType: hard + "is-lambda@npm:^1.0.1": version: 1.0.1 resolution: "is-lambda@npm:1.0.1" @@ -8811,6 +9058,13 @@ __metadata: languageName: node linkType: hard +"is-node-process@npm:^1.2.0": + version: 1.2.0 + resolution: "is-node-process@npm:1.2.0" + checksum: 930765cdc6d81ab8f1bbecbea4a8d35c7c6d88a3ff61f3630e0fc7f22d624d7661c1df05c58547d0eb6a639dfa9304682c8e342c4113a6ed51472b704cee2928 + languageName: node + linkType: hard + "is-number-object@npm:^1.0.4": version: 1.0.7 resolution: "is-number-object@npm:1.0.7" @@ -8956,6 +9210,15 @@ __metadata: languageName: node linkType: hard +"is-typed-array@npm:^1.1.3": + version: 1.1.13 + resolution: "is-typed-array@npm:1.1.13" + dependencies: + which-typed-array: ^1.1.14 + checksum: 150f9ada183a61554c91e1c4290086d2c100b0dff45f60b028519be72a8db964da403c48760723bf5253979b8dffe7b544246e0e5351dcd05c5fdb1dcc1dc0f0 + languageName: node + linkType: hard + "is-unicode-supported@npm:^0.1.0": version: 0.1.0 resolution: "is-unicode-supported@npm:0.1.0" @@ -9573,6 +9836,13 @@ __metadata: languageName: node linkType: hard +"js-levenshtein@npm:^1.1.6": + version: 1.1.6 + resolution: "js-levenshtein@npm:1.1.6" + checksum: 409f052a7f1141be4058d97da7860e08efd97fc588b7a4c5cfa0548bc04f6d576644dae65ab630266dff685d56fb90d494e03d4d79cb484c287746b4f1bf0694 + languageName: node + linkType: hard + "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -11099,6 +11369,40 @@ __metadata: languageName: node linkType: hard +"msw@npm:1": + version: 1.3.4 + resolution: "msw@npm:1.3.4" + dependencies: + "@mswjs/cookies": ^0.2.2 + "@mswjs/interceptors": ^0.17.10 + "@open-draft/until": ^1.0.3 + "@types/cookie": ^0.4.1 + "@types/js-levenshtein": ^1.1.1 + chalk: ^4.1.1 + chokidar: ^3.4.2 + cookie: ^0.4.2 + graphql: ^16.8.1 + headers-polyfill: 3.2.5 + inquirer: ^8.2.0 + is-node-process: ^1.2.0 + js-levenshtein: ^1.1.6 + node-fetch: ^2.6.7 + outvariant: ^1.4.0 + path-to-regexp: ^6.2.0 + strict-event-emitter: ^0.4.3 + type-fest: ^2.19.0 + yargs: ^17.3.1 + peerDependencies: + typescript: ">= 4.4.x" + peerDependenciesMeta: + typescript: + optional: true + bin: + msw: cli/index.js + checksum: 57646ecb831e98f00387e60bad4d535e426d406ae2645340e59500c219059be225f1f02a5ff21aee9daeb7a8bdde922a00fb82930781d27e3f3fdaf6b292c25f + languageName: node + linkType: hard + "multimatch@npm:^5.0.0": version: 5.0.0 resolution: "multimatch@npm:5.0.0" @@ -11638,6 +11942,23 @@ __metadata: languageName: node linkType: hard +"ora@npm:^5.4.1": + version: 5.4.1 + resolution: "ora@npm:5.4.1" + dependencies: + bl: ^4.1.0 + chalk: ^4.1.0 + cli-cursor: ^3.1.0 + cli-spinners: ^2.5.0 + is-interactive: ^1.0.0 + is-unicode-supported: ^0.1.0 + log-symbols: ^4.1.0 + strip-ansi: ^6.0.0 + wcwidth: ^1.0.1 + checksum: 28d476ee6c1049d68368c0dc922e7225e3b5600c3ede88fade8052837f9ed342625fdaa84a6209302587c8ddd9b664f71f0759833cbdb3a4cf81344057e63c63 + languageName: node + linkType: hard + "os-tmpdir@npm:~1.0.2": version: 1.0.2 resolution: "os-tmpdir@npm:1.0.2" @@ -11652,6 +11973,13 @@ __metadata: languageName: node linkType: hard +"outvariant@npm:^1.2.1": + version: 1.4.3 + resolution: "outvariant@npm:1.4.3" + checksum: 4a3551fb2b45309e585eebf88bad094dbe56ac6d3a28d59dd2e4050b431aa2beb6097a0763fce3cd82ca0f077026f380a9b60fffc306aaf430141421e7a7b6ed + languageName: node + linkType: hard + "p-limit@npm:^2.2.0": version: 2.3.0 resolution: "p-limit@npm:2.3.0" @@ -11837,6 +12165,13 @@ __metadata: languageName: node linkType: hard +"path-to-regexp@npm:^6.2.0": + version: 6.3.0 + resolution: "path-to-regexp@npm:6.3.0" + checksum: eca78602e6434a1b6799d511d375ec044e8d7e28f5a48aa5c28d57d8152fb52f3fc62fb1cfc5dfa2198e1f041c2a82ed14043d75740a2fe60e91b5089a153250 + languageName: node + linkType: hard + "path-type@npm:^4.0.0": version: 4.0.0 resolution: "path-type@npm:4.0.0" @@ -11966,6 +12301,7 @@ __metadata: lodash: ^4.17.21 mobx: 6.3.2 mobx-react: 7.2.0 + msw: 1 next: 14.2.10 next-auth: ^4.24.5 next-i18next: ^14.0.3 @@ -12018,6 +12354,13 @@ __metadata: languageName: node linkType: hard +"possible-typed-array-names@npm:^1.0.0": + version: 1.0.0 + resolution: "possible-typed-array-names@npm:1.0.0" + checksum: b32d403ece71e042385cc7856385cecf1cd8e144fa74d2f1de40d1e16035dba097bc189715925e79b67bdd1472796ff168d3a90d296356c9c94d272d5b95f3ae + languageName: node + linkType: hard + "postcss-media-query-parser@npm:^0.2.3": version: 0.2.3 resolution: "postcss-media-query-parser@npm:0.2.3" @@ -13147,7 +13490,7 @@ __metadata: languageName: node linkType: hard -"rxjs@npm:^7.5.1": +"rxjs@npm:^7.5.1, rxjs@npm:^7.5.5": version: 7.8.1 resolution: "rxjs@npm:7.8.1" dependencies: @@ -13303,6 +13646,20 @@ __metadata: languageName: node linkType: hard +"set-function-length@npm:^1.2.1": + version: 1.2.2 + resolution: "set-function-length@npm:1.2.2" + dependencies: + define-data-property: ^1.1.4 + es-errors: ^1.3.0 + function-bind: ^1.1.2 + get-intrinsic: ^1.2.4 + gopd: ^1.0.1 + has-property-descriptors: ^1.0.2 + checksum: a8248bdacdf84cb0fab4637774d9fb3c7a8e6089866d04c817583ff48e14149c87044ce683d7f50759a8c50fb87c7a7e173535b06169c87ef76f5fb276dfff72 + languageName: node + linkType: hard + "shallow-equal@npm:^3.1.0": version: 3.1.0 resolution: "shallow-equal@npm:3.1.0" @@ -13684,6 +14041,15 @@ __metadata: languageName: node linkType: hard +"strict-event-emitter@npm:^0.2.4": + version: 0.2.8 + resolution: "strict-event-emitter@npm:0.2.8" + dependencies: + events: ^3.3.0 + checksum: 6ac06fe72a6ee6ae64d20f1dd42838ea67342f1b5f32b03b3050d73ee6ecee44b4d5c4ed2965a7154b47991e215f373d4e789e2b2be2769cd80e356126c2ca53 + languageName: node + linkType: hard + "strict-event-emitter@npm:^0.4.3": version: 0.4.6 resolution: "strict-event-emitter@npm:0.4.6" @@ -14470,6 +14836,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^2.19.0": + version: 2.19.0 + resolution: "type-fest@npm:2.19.0" + checksum: a4ef07ece297c9fba78fc1bd6d85dff4472fe043ede98bd4710d2615d15776902b595abf62bd78339ed6278f021235fb28a96361f8be86ed754f778973a0d278 + languageName: node + linkType: hard + "type@npm:^1.0.1": version: 1.2.0 resolution: "type@npm:1.2.0" @@ -14787,6 +15160,19 @@ __metadata: languageName: node linkType: hard +"util@npm:^0.12.3": + version: 0.12.5 + resolution: "util@npm:0.12.5" + dependencies: + inherits: ^2.0.3 + is-arguments: ^1.0.4 + is-generator-function: ^1.0.7 + is-typed-array: ^1.1.3 + which-typed-array: ^1.1.2 + checksum: 705e51f0de5b446f4edec10739752ac25856541e0254ea1e7e45e5b9f9b0cb105bc4bd415736a6210edc68245a7f903bf085ffb08dd7deb8a0e847f60538a38a + languageName: node + linkType: hard + "uuid@npm:^8.3.2": version: 8.3.2 resolution: "uuid@npm:8.3.2" @@ -14915,6 +15301,28 @@ __metadata: languageName: node linkType: hard +"wcwidth@npm:^1.0.1": + version: 1.0.1 + resolution: "wcwidth@npm:1.0.1" + dependencies: + defaults: ^1.0.3 + checksum: 814e9d1ddcc9798f7377ffa448a5a3892232b9275ebb30a41b529607691c0491de47cba426e917a4d08ded3ee7e9ba2f3fe32e62ee3cd9c7d3bafb7754bd553c + languageName: node + linkType: hard + +"web-encoding@npm:^1.1.5": + version: 1.1.5 + resolution: "web-encoding@npm:1.1.5" + dependencies: + "@zxing/text-encoding": 0.9.0 + util: ^0.12.3 + dependenciesMeta: + "@zxing/text-encoding": + optional: true + checksum: 2234a2b122f41006ce07859b3c0bf2e18f46144fda2907d5db0b571b76aa5c26977c646100ad9c00d2f8a4f6f2b848bc02147845d8c447ab365ec4eff376338d + languageName: node + linkType: hard + "web-namespaces@npm:^2.0.0": version: 2.0.1 resolution: "web-namespaces@npm:2.0.1" @@ -15030,6 +15438,19 @@ __metadata: languageName: node linkType: hard +"which-typed-array@npm:^1.1.14, which-typed-array@npm:^1.1.2": + version: 1.1.15 + resolution: "which-typed-array@npm:1.1.15" + dependencies: + available-typed-arrays: ^1.0.7 + call-bind: ^1.0.7 + for-each: ^0.3.3 + gopd: ^1.0.1 + has-tostringtag: ^1.0.2 + checksum: 65227dcbfadf5677aacc43ec84356d17b5500cb8b8753059bb4397de5cd0c2de681d24e1a7bd575633f976a95f88233abfd6549c2105ef4ebd58af8aa1807c75 + languageName: node + linkType: hard + "which-typed-array@npm:^1.1.9": version: 1.1.9 resolution: "which-typed-array@npm:1.1.9" @@ -15086,7 +15507,7 @@ __metadata: languageName: node linkType: hard -"wrap-ansi@npm:^6.2.0": +"wrap-ansi@npm:^6.0.1, wrap-ansi@npm:^6.2.0": version: 6.2.0 resolution: "wrap-ansi@npm:6.2.0" dependencies:
{e}
{t('result.uploadFailedDirection')}